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]

Reply via email to