Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,13 @@ os.path
(Contributed by Petr Viktorin for :cve:`2025-4517`.)


pdb
---

* Use the new interactive shell as the default input shell for :mod:`pdb`.
(Contributed by Tian Gao in :gh:`145379`.)


pickle
------

Expand Down
139 changes: 134 additions & 5 deletions Lib/pdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,93 @@ def get_default_backend():
return _default_backend


def _pyrepl_available():
"""return whether pdb should use _pyrepl for input"""
if not os.getenv("PYTHON_BASIC_REPL"):
from _pyrepl.main import CAN_USE_PYREPL

return CAN_USE_PYREPL
return False
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if not os.getenv("PYTHON_BASIC_REPL"):
from _pyrepl.main import CAN_USE_PYREPL
return CAN_USE_PYREPL
return False
if os.getenv('PYTHON_BASIC_REPL'):
CAN_USE_PYREPL = False
else:
try:
from _pyrepl.main import CAN_USE_PYREPL
except ModuleNotFoundError:
CAN_USE_PYREPL = False
return CAN_USE_PYREPL



class PdbPyReplInput:
def __init__(self, pdb_instance, stdin, stdout, prompt):
import _pyrepl.readline

self.pdb_instance = pdb_instance
self.prompt = prompt
self.console = code.InteractiveConsole()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this should be something like this:

class _PdbInteractiveConsole(code.InteractiveConsole):
    def __init__(self, ns=None, message=None):
        self._message = message
        super().__init__(locals=ns, local_exit=True)

    def write(self, data):
        if self._message is not None:
            self._message(data, end='')
        else:
            super().write(data)

    def _more_lines(self, text):
        # Generic Python multi-line completeness heuristic.
        # Strips pyrepl's trailing auto-indent before compiling.
        src = text.rstrip(" \t")
        n = len(src)
        if n > 0 and text[n-1] == '\n':
            text = src
        try:
            code_obj = self.compile(text, "<stdin>", "single")
        except (OverflowError, SyntaxError, ValueError):
            lines = text.splitlines(keepends=True)
            if len(lines) == 1:
                return False
            last = lines[-1]
            return ((last.startswith((" ", "\t")) or last.strip() != "")
                    and not last.endswith("\n"))
        return code_obj is None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.console = code.InteractiveConsole()
self.console = _PdbInteractiveConsole()

if not (os.isatty(stdin.fileno())):
raise ValueError("stdin is not a TTY")
self.readline_wrapper = _pyrepl.readline._ReadlineWrapper(
f_in=stdin.fileno(),
f_out=stdout.fileno(),
config=_pyrepl.readline.ReadlineConfig(
completer_delims=frozenset(' \t\n`@#%^&*()=+[{]}\\|;:\'",<>?')
)
)

def readline(self):
from _pyrepl.simple_interact import _more_lines
Comment thread
gaogaotiantian marked this conversation as resolved.
Outdated

def more_lines(text):
Comment thread
gaogaotiantian marked this conversation as resolved.
if text.strip() == "\x1a":
# Ctrl + Z raises EOFError to quit pdb
# This is similarly handled in simple_interact.py
raise EOFError
cmd, _, line = self.pdb_instance.parseline(text)
if not line or not cmd:
return False
func = getattr(self.pdb_instance, 'do_' + cmd, None)
if func is not None:
return False
return _more_lines(self.console, text)
Comment thread
gaogaotiantian marked this conversation as resolved.
Outdated

try:
pyrepl_completer = self.readline_wrapper.get_completer()
self.readline_wrapper.set_completer(self.complete)
return (
self.readline_wrapper.multiline_input(
more_lines,
self.prompt,
'... ' + ' ' * (len(self.prompt) - 4)
) + '\n'
)
except EOFError:
return 'EOF'
finally:
self.readline_wrapper.set_completer(pyrepl_completer)

def complete(self, text, state):
"""
This function is very similar to cmd.Cmd.complete.
However, cmd.Cmd.complete assumes that we use readline module, but
pyrepl does not use it.
"""
if state == 0:
origline = self.readline_wrapper.get_line_buffer()
line = origline.lstrip()
stripped = len(origline) - len(line)
begidx = self.readline_wrapper.get_begidx() - stripped
endidx = self.readline_wrapper.get_endidx() - stripped
if begidx>0:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if begidx>0:
if begidx > 0:

cmd, args, foo = self.pdb_instance.parseline(line)
if not cmd:
compfunc = self.pdb_instance.completedefault
else:
try:
compfunc = getattr(self.pdb_instance, 'complete_' + cmd)
except AttributeError:
compfunc = self.pdb_instance.completedefault
else:
compfunc = self.pdb_instance.completenames
self.completion_matches = compfunc(text, line, begidx, endidx)
try:
return self.completion_matches[state]
except IndexError:
return None


class Pdb(bdb.Bdb, cmd.Cmd):
_previous_sigint_handler = None

Expand Down Expand Up @@ -386,6 +473,12 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, skip=None,
except ImportError:
pass

self.pyrepl_input = None
if _pyrepl_available():
try:
self.pyrepl_input = PdbPyReplInput(self, self.stdin, self.stdout, self.prompt)
except Exception:
pass
self.allow_kbdint = False
self.nosigint = nosigint
# Consider these characters as part of the command so when the users type
Expand Down Expand Up @@ -624,14 +717,40 @@ def user_exception(self, frame, exc_info):
self.message('%s%s' % (prefix, self._format_exc(exc_value)))
self.interaction(frame, exc_traceback)

@contextmanager
def _replace_attribute(self, attrs):
original_attrs = {}
for attr, value in attrs.items():
original_attrs[attr] = getattr(self, attr)
setattr(self, attr, value)
try:
yield
finally:
for attr, value in original_attrs.items():
setattr(self, attr, value)

@contextmanager
def _maybe_use_pyrepl_as_stdin(self):
if self.pyrepl_input is None:
yield
return

with self._replace_attribute({
'stdin': self.pyrepl_input,
'use_rawinput': False,
'prompt': '',
}):
yield

# General interaction function
def _cmdloop(self):
while True:
try:
# keyboard interrupts allow for an easy way to cancel
# the current command, so allow them during interactive input
self.allow_kbdint = True
self.cmdloop()
with self._maybe_use_pyrepl_as_stdin():
self.cmdloop()
self.allow_kbdint = False
break
except KeyboardInterrupt:
Expand Down Expand Up @@ -2364,10 +2483,20 @@ def do_interact(self, arg):
contains all the (global and local) names found in the current scope.
"""
ns = {**self.curframe.f_globals, **self.curframe.f_locals}
with self._enable_rlcompleter(ns):
console = _PdbInteractiveConsole(ns, message=self.message)
console.interact(banner="*pdb interact start*",
exitmsg="*exit from pdb interact command*")
console = _PdbInteractiveConsole(ns, message=self.message)
if self.pyrepl_input is not None:
from _pyrepl.simple_interact import run_multiline_interactive_console
self.message("*pdb interact start*")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Please declare as common variables

banner = "*pdb interact start*"
exitmsg = "*exit from pdb interact command*"

try:
run_multiline_interactive_console(console)
except SystemExit:
pass
self.message("*exit from pdb interact command*")
else:
with self._enable_rlcompleter(ns):
console = _PdbInteractiveConsole(ns, message=self.message)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not redundant declaration from L2486?

console.interact(banner="*pdb interact start*",
exitmsg="*exit from pdb interact command*")

def do_alias(self, arg):
"""alias [name [command]]
Expand Down
103 changes: 76 additions & 27 deletions Lib/test/test_pdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import io
import os
import pdb
import re
import sys
import types
import codecs
Expand Down Expand Up @@ -5006,6 +5007,20 @@ def setUpClass(cls):
if readline.backend == "editline":
raise unittest.SkipTest("libedit readline is not supported for pdb")

def _run_pty(self, script, input, env=None):
if env is None:
# By default, we use basic repl for the test.
# Subclass can overwrite this method and set env to use advanced REPL
env = os.environ | {'PYTHON_BASIC_REPL': '1'}
output = run_pty(script, input, env=env)
# filter all control characters
# Strip ANSI CSI sequences (good enough for most REPL/prompt output)
output = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output.decode("utf-8"))
return output

def _pyrepl_available(self):
return pdb._pyrepl_available()

def test_basic_completion(self):
script = textwrap.dedent("""
import pdb; pdb.Pdb().set_trace()
Expand All @@ -5017,12 +5032,12 @@ def test_basic_completion(self):
# then add ntin and complete 'contin' to 'continue'
input = b"co\t\tntin\t\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'commands', output)
self.assertIn(b'condition', output)
self.assertIn(b'continue', output)
self.assertIn(b'hello!', output)
self.assertIn('commands', output)
self.assertIn('condition', output)
self.assertIn('continue', output)
self.assertIn('hello!', output)

def test_expression_completion(self):
script = textwrap.dedent("""
Expand All @@ -5039,11 +5054,11 @@ def test_expression_completion(self):
# Continue
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'special', output)
self.assertIn(b'species', output)
self.assertIn(b'$_frame', output)
self.assertIn('special', output)
self.assertIn('species', output)
self.assertIn('$_frame', output)

def test_builtin_completion(self):
script = textwrap.dedent("""
Expand All @@ -5057,9 +5072,9 @@ def test_builtin_completion(self):
# Continue
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'special', output)
self.assertIn('special', output)

def test_convvar_completion(self):
script = textwrap.dedent("""
Expand All @@ -5075,10 +5090,10 @@ def test_convvar_completion(self):
# Continue
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'<frame at 0x', output)
self.assertIn(b'102', output)
self.assertIn('<frame at 0x', output)
self.assertIn('102', output)

def test_local_namespace(self):
script = textwrap.dedent("""
Expand All @@ -5094,9 +5109,9 @@ def f():
# Continue
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'I love Python', output)
self.assertIn('I love Python', output)

@unittest.skipIf(sys.platform.startswith('freebsd'),
'\\x08 is not interpreted as backspace on FreeBSD')
Expand All @@ -5116,9 +5131,9 @@ def test_multiline_auto_indent(self):
input += b"f(-21-21)\n"
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'42', output)
self.assertIn('42', output)

def test_multiline_completion(self):
script = textwrap.dedent("""
Expand All @@ -5134,9 +5149,9 @@ def test_multiline_completion(self):
input += b"fun\t()\n"
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'42', output)
self.assertIn('42', output)

@unittest.skipIf(sys.platform.startswith('freebsd'),
'\\x08 is not interpreted as backspace on FreeBSD')
Expand All @@ -5162,10 +5177,10 @@ def func():
c
""").encode()

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn(b'5', output)
self.assertNotIn(b'Error', output)
self.assertIn('5', output)
self.assertNotIn('Error', output)

def test_interact_completion(self):
script = textwrap.dedent("""
Expand All @@ -5189,11 +5204,45 @@ def test_interact_completion(self):
# continue
input += b"c\n"

output = run_pty(script, input)
output = self._run_pty(script, input)

self.assertIn("'disp' is not defined", output)
self.assertIn('special', output)
self.assertIn('84', output)


@unittest.skipIf(not pdb._pyrepl_available(), "pyrepl is not available")
class PdbTestReadlinePyREPL(PdbTestReadline):
def _run_pty(self, script, input):
# Override the env to make sure pyrepl is used in this test class
return super()._run_pty(script, input, env={**os.environ})

def test_pyrepl_used(self):
script = textwrap.dedent("""
import pdb
db = pdb.Pdb()
print(db.pyrepl_input)
""")
input = b""
output = self._run_pty(script, input)
self.assertIn('PdbPyReplInput', output)

def test_pyrepl_multiline_change(self):
script = textwrap.dedent("""
import pdb; pdb.Pdb().set_trace()
""")

input = b"def f():\n"
# Auto-indent should work here
input += b"return x"
# The following command tries to add the argument x in f()
# up, left, left (in the parenthesis now), "x", down, down (at the end)
input += b"\x1bOA\x1bOD\x1bODx\x1bOB\x1bOB\n\n"
input += b"f(40 + 2)\n"
input += b"c\n"

self.assertIn(b"'disp' is not defined", output)
self.assertIn(b'special', output)
self.assertIn(b'84', output)
output = self._run_pty(script, input)
self.assertIn('42', output)


def load_tests(loader, tests, pattern):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use ``PyREPL`` as the default input console for :mod:`pdb`
Loading