GH-145378: Use PyREPL as the default input console for pdb (#145379)

This commit is contained in:
Tian Gao
2026-04-29 22:55:09 -07:00
committed by GitHub
parent 16952218d0
commit 234c12c0fc
4 changed files with 246 additions and 34 deletions
+7
View File
@@ -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
------
+162 -7
View File
@@ -318,12 +318,34 @@ class _ZipTarget(_ExecutableTarget):
class _PdbInteractiveConsole(code.InteractiveConsole):
def __init__(self, ns, message):
def __init__(self, ns=None, message=None):
self._message = message
super().__init__(locals=ns, local_exit=True)
def write(self, data):
self._message(data, end='')
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.
# This should be functionally identical to simple_interact._more_lines
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
# Interaction prompt line will separate file and call info from code
@@ -352,6 +374,96 @@ 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"):
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 = _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):
def more_lines(text):
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 self.console.more_lines(text)
try:
pyrepl_completer = self.readline_wrapper.get_completer()
self.readline_wrapper.set_completer(self.complete)
multiline = (
self.readline_wrapper.multiline_input(
more_lines,
self.prompt,
'... ' + ' ' * (len(self.prompt) - 4)
) + '\n'
)
return multiline
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:
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
@@ -386,6 +498,12 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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
@@ -624,6 +742,31 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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:
@@ -631,7 +774,8 @@ class Pdb(bdb.Bdb, cmd.Cmd):
# 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:
@@ -2364,10 +2508,21 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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)
banner = "*pdb interact start*"
exitmsg = "*exit from pdb interact command*"
if self.pyrepl_input is not None:
from _pyrepl.simple_interact import run_multiline_interactive_console
self.message(banner)
try:
run_multiline_interactive_console(console)
except SystemExit:
pass
self.message(exitmsg)
else:
with self._enable_rlcompleter(ns):
console.interact(banner=banner,
exitmsg=exitmsg)
def do_alias(self, arg):
"""alias [name [command]]
+76 -27
View File
@@ -6,6 +6,7 @@ import gc
import io
import os
import pdb
import re
import sys
import types
import codecs
@@ -5006,6 +5007,20 @@ class PdbTestReadline(unittest.TestCase):
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()
@@ -5017,12 +5032,12 @@ class PdbTestReadline(unittest.TestCase):
# 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("""
@@ -5039,11 +5054,11 @@ class PdbTestReadline(unittest.TestCase):
# 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("""
@@ -5057,9 +5072,9 @@ class PdbTestReadline(unittest.TestCase):
# 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("""
@@ -5075,10 +5090,10 @@ class PdbTestReadline(unittest.TestCase):
# 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("""
@@ -5094,9 +5109,9 @@ class PdbTestReadline(unittest.TestCase):
# 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')
@@ -5116,9 +5131,9 @@ class PdbTestReadline(unittest.TestCase):
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("""
@@ -5134,9 +5149,9 @@ class PdbTestReadline(unittest.TestCase):
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')
@@ -5162,10 +5177,10 @@ class PdbTestReadline(unittest.TestCase):
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("""
@@ -5189,11 +5204,45 @@ class PdbTestReadline(unittest.TestCase):
# continue
input += b"c\n"
output = run_pty(script, input)
output = self._run_pty(script, input)
self.assertIn(b"'disp' is not defined", output)
self.assertIn(b'special', output)
self.assertIn(b'84', output)
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"
output = self._run_pty(script, input)
self.assertIn('42', output)
def load_tests(loader, tests, pattern):
@@ -0,0 +1 @@
Use ``PyREPL`` as the default input console for :mod:`pdb`