]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-145378: Use PyREPL as the default input console for pdb (#145379)
authorTian Gao <gaogaotiantian@hotmail.com>
Thu, 30 Apr 2026 05:55:09 +0000 (22:55 -0700)
committerGitHub <noreply@github.com>
Thu, 30 Apr 2026 05:55:09 +0000 (22:55 -0700)
Doc/whatsnew/3.15.rst
Lib/pdb.py
Lib/test/test_pdb.py
Misc/NEWS.d/next/Library/2026-03-01-01-58-10.gh-issue-145378.oy6rb9.rst [new file with mode: 0644]

index 3c2c7a7e399d09c854ccf487872e951a2fe2ed02..56b2553a401920c234836f3022b84cbb78cce54b 100644 (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
 ------
 
index 7b08d2bb70183dd5b56cbbc19429447ec244e66c..c4bc0020646b0df291c2be486943c36e27e0e219 100644 (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]]
index 0e23cd6604379c5662dac46de12264447aeee6ef..c5171f3388c965ca0a20360a8aaa51d53bf248ba 100644 (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("'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):
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 (file)
index 0000000..b6a6273
--- /dev/null
@@ -0,0 +1 @@
+Use ``PyREPL`` as the default input console for :mod:`pdb`