https://github.com/python/cpython/commit/234c12c0fc6f9710cee34d033a7f6a808d74bfac
commit: 234c12c0fc6f9710cee34d033a7f6a808d74bfac
branch: main
author: Tian Gao <[email protected]>
committer: gaogaotiantian <[email protected]>
date: 2026-04-29T22:55:09-07:00
summary:
GH-145378: Use PyREPL as the default input console for pdb (#145379)
files:
A Misc/NEWS.d/next/Library/2026-03-01-01-58-10.gh-issue-145378.oy6rb9.rst
M Doc/whatsnew/3.15.rst
M Lib/pdb.py
M Lib/test/test_pdb.py
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index 3c2c7a7e399d09..56b2553a401920 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -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
------
diff --git a/Lib/pdb.py b/Lib/pdb.py
index 7b08d2bb70183d..c4bc0020646b0d 100644
--- a/Lib/pdb.py
+++ b/Lib/pdb.py
@@ -318,12 +318,34 @@ def namespace(self):
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 @@ 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
@@ -624,6 +742,31 @@ 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:
@@ -631,7 +774,8 @@ def _cmdloop(self):
# 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 @@ 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)
+ 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]]
diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py
index 0e23cd6604379c..c5171f3388c965 100644
--- a/Lib/test/test_pdb.py
+++ b/Lib/test/test_pdb.py
@@ -6,6 +6,7 @@
import io
import os
import pdb
+import re
import sys
import types
import codecs
@@ -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()
@@ -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("""
@@ -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("""
@@ -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("""
@@ -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("""
@@ -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')
@@ -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("""
@@ -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')
@@ -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("""
@@ -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)
+
+
[email protected](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):
diff --git
a/Misc/NEWS.d/next/Library/2026-03-01-01-58-10.gh-issue-145378.oy6rb9.rst
b/Misc/NEWS.d/next/Library/2026-03-01-01-58-10.gh-issue-145378.oy6rb9.rst
new file mode 100644
index 00000000000000..b6a6273d882d79
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-03-01-01-58-10.gh-issue-145378.oy6rb9.rst
@@ -0,0 +1 @@
+Use ``PyREPL`` as the default input console for :mod:`pdb`
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]