From 68f0d6a0fd69722ed8a848b8b2a552a7da822347 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 20 Oct 2026 17:25:54 -0700 Subject: [PATCH] Support pasting REPL sessions and retrying unexecuted input --- Doc/tutorial/appendix.rst | 23 + Doc/whatsnew/3.16.rst | 11 + Lib/_pyrepl/commands.py | 51 +- Lib/_pyrepl/console.py | 11 +- Lib/_pyrepl/reader.py | 15 +- Lib/_pyrepl/readline.py | 28 +- Lib/_pyrepl/simple_interact.py | 6 +- Lib/test/test_pyrepl/test_interact.py | 21 + Lib/test/test_pyrepl/test_pyrepl.py | 436 +++++++++++++++++- ...-09-16-12-00-00.gh-issue-157612.GyH8xN.rst | 6 + 10 files changed, 591 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157612.GyH8xN.rst diff --git a/Doc/tutorial/appendix.rst b/Doc/tutorial/appendix.rst index 2539edb51ba3a09..4a8a55ad7d706f4 100644 --- a/Doc/tutorial/appendix.rst +++ b/Doc/tutorial/appendix.rst @@ -24,6 +24,29 @@ details. Function keys provide some additional functionality. makes pasting larger blocks of code easier. Press :kbd:`F3` to return to the regular prompt. +In terminals that support bracketed paste, a pasted session starting with +``>>>`` or ``...`` shows a hint while paste mode is off. The hint remains +while the buffer is recognized as a session. Pressing :kbd:`F3` enters paste mode +and removes prompts and output from the current buffer. Wrapped input lines +are rejoined, and input abandoned with :kbd:`Control-C` is omitted. +Review and edit the code, then press :kbd:`F3` again and :kbd:`Enter` to run it. + +Pasting a session into existing input shows the hint temporarily, even inside +a string. If paste mode is already active, each pasted session is stripped +automatically, including when inserting into existing input. +Pasting into an :func:`input` prompt or :mod:`pdb` leaves the text unchanged. + +The pasted code runs as one block. An unhandled exception stops execution, +so statements after it do not run, even if the copied session continued +after the exception. When an error leaves later statements in the input +unexecuted, a message below the next prompt reports this. This also applies +to typed input. You can inspect the traceback, then copy and paste the +remaining lines, including their ``...`` prompts. +Expression results from earlier statements are not displayed through +:func:`sys.displayhook`, so updates to ``_`` can differ from entering each +statement separately. Output that looks like a prompted input line can be +mistaken for code. + When using the new interactive shell, exit the shell by typing :kbd:`exit` or :kbd:`quit`. Adding call parentheses after those commands is not required. diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index e54cf2e8bf6f9a6..611f8f942449101 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -67,6 +67,17 @@ Summary -- Release highlights New features ============ +Pasting interactive sessions +---------------------------- + +The :ref:`interactive shell ` can now strip ``>>>`` and ``...`` +prompts and output from pasted Python sessions, including selections that +start with a ``...`` continuation prompt. Press :kbd:`F3` to strip a +session already in the input buffer or enable stripping for subsequent +pastes. This requires a terminal that supports bracketed paste. If an error +stops execution before later statements in an input block, a message reports +that those statements were not executed. +(Contributed by Bartosz Sławecki in :gh:`157612`.) Other language changes diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index a3b541525aee103..154537273dc220a 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -20,6 +20,7 @@ # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. import os +import re import time # Categories of actions: @@ -494,10 +495,45 @@ def do(self) -> None: self.reader.invalidate_full() +_REPL_PASTE_HINT = "press F3 (paste mode) to strip >>> and ... prompts" + + class paste_mode(Command): def do(self) -> None: - self.reader.paste_mode = not self.reader.paste_mode - self.reader.invalidate_prompt() + r = self.reader + r.paste_mode = not r.paste_mode + if r.paste_mode and getattr(r, "can_strip_repl_prompts", False): + r.buffer[:] = _strip_repl_prompts(r.get_unicode()) + r.pos = len(r.buffer) + r.invalidate_buffer(0) + r.invalidate_prompt() + + +def _is_repl_session(data: str) -> bool: + return data[:3] in {">>>", "..."} and data[3:4] in {"", " ", "\r", "\n"} + + +def _strip_repl_prompts(data: str) -> str: + lines: list[str] = [] + statement_start = None + for line in re.findall(r"[^\r\n]*(?:\r\n?|\n|$)", data): + if _is_repl_session(line): + if line.startswith(">>>") or statement_start is None: + statement_start = len(lines) + lines.append(line[3:].removeprefix(" ")) + elif statement_start is not None: + if line.rstrip("\r\n") == "KeyboardInterrupt": + # Ctrl-C leaves the abandoned input on screen with its prompts. + del lines[statement_start:] + statement_start = None + elif (previous := lines[-1].rstrip("\r\n")).endswith("\\"): + # PyREPL wraps input with a backslash and no prompt on the + # next screen row. Rejoin it without the display backslash. + lines[-1] = previous[:-1] + line + else: + # Output (including a traceback) ends the input statement. + statement_start = None + return "".join(lines) class perform_bracketed_paste(Command): @@ -513,4 +549,13 @@ def do(self) -> None: l=len(data), s=time.time() - start, ) - self.reader.insert(data.replace(done, "")) + data = data.replace(done, "") + if ( + getattr(self.reader, "paste_repl_sessions", False) + and _is_repl_session(data) + ): + if self.reader.paste_mode: + data = _strip_repl_prompts(data) + else: + self.reader.error(_REPL_PASTE_HINT) + self.reader.insert(data) diff --git a/Lib/_pyrepl/console.py b/Lib/_pyrepl/console.py index 0da3c36ef9ed470..4e29a98a4323263 100644 --- a/Lib/_pyrepl/console.py +++ b/Lib/_pyrepl/console.py @@ -214,6 +214,7 @@ def __init__( ) -> None: super().__init__(locals=locals, filename=filename, local_exit=local_exit) self.can_colorize = _colorize.can_colorize() + self.unexecuted_statements = False def showsyntaxerror(self, filename=None, **kwargs): super().showsyntaxerror(filename=filename, **kwargs) @@ -237,6 +238,7 @@ def runcode(self, code): return None def runsource(self, source, filename="", symbol="single"): + self.unexecuted_statements = False try: tree = self.compile.compiler( source, @@ -269,16 +271,14 @@ def runsource(self, source, filename="", symbol="single"): try: code = self.compile.compiler(item, filename, the_symbol) linecache._register_code(code, source, filename) - except SyntaxError as e: - if e.args[0] == "'await' outside function": + except Exception as e: + if isinstance(e, SyntaxError) and e.args[0] == "'await' outside function": python = os.path.basename(sys.executable) e.add_note( f"Try the asyncio REPL ({python} -m asyncio) to use" f" top-level 'await' and run background asyncio tasks." ) - self.showsyntaxerror(filename, source=source) - return False - except Exception: + self.unexecuted_statements = stmt is not last_stmt self.showsyntaxerror(filename, source=source) return False @@ -287,5 +287,6 @@ def runsource(self, source, filename="", symbol="single"): result = self.runcode(code) if result is self.STATEMENT_FAILED: + self.unexecuted_statements = stmt is not last_stmt break return False diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index d552214f322d76d..fa325f93fae2574 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -581,12 +581,16 @@ def _render_wrapped_rows( for row in wrapped_rows ] + def get_message(self) -> str: + return self.msg + def _render_message_lines(self) -> tuple[RenderLine, ...]: - if not self.msg: + message = self.get_message() + if not message: return () width = self.console.width render_lines: list[RenderLine] = [] - for message_line in self.msg.split("\n"): + for message_line in message.split("\n"): line = RenderLine.from_rendered_text(message_line) if not line.cells: render_lines.append(line) @@ -882,13 +886,16 @@ def debug(self, msg: str) -> None: # self.error(f"[debug] {msg}") pass - def error(self, msg: str = "none") -> None: + def _format_error(self, msg: str) -> str: error_prefix = self.error_prefix if self.can_colorize: t = THEME() error_prefix = f"{t.error}{error_prefix}{t.reset}" + return error_prefix + msg + + def error(self, msg: str = "none") -> None: self.console.beep() - self.msg = error_prefix + msg + self.msg = self._format_error(msg) self.invalidate_message() def update_screen(self) -> None: diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 4232a868a34e9ad..663df82659214d0 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -111,6 +111,7 @@ class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader): # Instance fields config: ReadlineConfig more_lines: MoreLinesCallable | None = None + paste_repl_sessions: bool = False last_used_indentation: str | None = None def __post_init__(self) -> None: @@ -120,6 +121,19 @@ def __post_init__(self) -> None: self.commands["backspace_dedent"] = backspace_dedent self.commands["backspace-dedent"] = backspace_dedent + @property + def can_strip_repl_prompts(self) -> bool: + return ( + self.paste_repl_sessions + and commands._is_repl_session(self.get_unicode()) + ) + + def get_message(self) -> str: + message = super().get_message() + if not message and not self.paste_mode and self.can_strip_repl_prompts: + return self._format_error(commands._REPL_PASTE_HINT) + return message + def get_stem(self) -> str: b = self.buffer p = self.pos - 1 @@ -382,15 +396,23 @@ def input(self, prompt: object = "") -> str: sys.audit("builtins.input/result", result) return result - def multiline_input(self, more_lines: MoreLinesCallable, ps1: str, ps2: str) -> str: + def multiline_input( + self, + more_lines: MoreLinesCallable, + ps1: str, + ps2: str, + *, + paste_repl_sessions: bool = False, + ) -> str: """Read an input on possibly multiple lines, asking for more lines as long as 'more_lines(unicodetext)' returns an object whose boolean value is true. """ reader = self.get_reader() - saved = reader.more_lines + saved = reader.more_lines, reader.paste_repl_sessions try: reader.more_lines = more_lines + reader.paste_repl_sessions = paste_repl_sessions reader.ps1 = ps1 reader.ps2 = ps1 reader.ps3 = ps2 @@ -398,7 +420,7 @@ def multiline_input(self, more_lines: MoreLinesCallable, ps1: str, ps2: str) -> with warnings.catch_warnings(action="ignore"): return reader.readline() finally: - reader.more_lines = saved + reader.more_lines, reader.paste_repl_sessions = saved reader.paste_mode = False def parse_and_bind(self, string: str) -> None: diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 5a7c8c20202640b..3af962860c076b6 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -138,7 +138,9 @@ def maybe_run_command(statement: str) -> bool: ps1 = getattr(sys, "ps1", ">>> ") ps2 = getattr(sys, "ps2", "... ") try: - statement = multiline_input(more_lines, ps1, ps2) + statement = multiline_input( + more_lines, ps1, ps2, paste_repl_sessions=True, + ) except EOFError: break @@ -148,6 +150,8 @@ def maybe_run_command(statement: str) -> bool: input_name = f"" more = console.push(_strip_final_indent(statement), filename=input_name, _symbol="single") # type: ignore[call-arg] assert not more + if getattr(console, "unexecuted_statements", False): + _get_reader().error("code after exception did not run") try: append_history_file() except (FileNotFoundError, PermissionError, OSError) as e: diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 673827608abc477..a8f3b2eee3e6adc 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -67,6 +67,27 @@ def test_multiple_statements_fail_early(self): self.assertIn('Exception: foobar', f.getvalue()) self.assertNotIn('spam&eggs', f.getvalue()) + def test_unexecuted_statements(self): + console = InteractiveColoredConsole() + cases = ( + ("1 / 0\nafter = True", True), + ("if True:\n 1 / 0\nafter = True", True), + ("1 / 0; after = True", True), + ("before = True\n1 / 0", False), + ("1 / 0\n# No remaining statements", False), + ("try:\n 1 / 0\nexcept ZeroDivisionError:\n pass\nafter = True", False), + ("before = True\nreturn\nafter = True", True), + ("before = True", False), + ("", False), + ) + for source, expected in cases: + with ( + self.subTest(source=source), + contextlib.redirect_stderr(io.StringIO()), + ): + self.assertFalse(console.runsource(source)) + self.assertEqual(console.unexecuted_statements, expected) + def test_empty(self): namespace = {} code = "" diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index d748c07d60d2a4b..4269a23714a16b9 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import textwrap from functools import partial from pkgutil import ModuleInfo from unittest import TestCase, skipUnless, SkipTest @@ -22,6 +23,7 @@ force_not_colorized, make_clean_env, Py_DEBUG, + subTests, ) from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR from test.support.import_helper import import_module @@ -38,7 +40,7 @@ ) from _colorize import ANSIColors, get_theme from _pyrepl import terminfo -from _pyrepl.console import Event +from _pyrepl.console import Event, InteractiveColoredConsole from _pyrepl.completing_reader import stripcolor from _pyrepl._module_completer import ( ImportParser, @@ -2043,6 +2045,438 @@ def test_bracketed_paste_single_line(self): output = multiline_input(reader) self.assertEqual(output, input_code) + def handle_session_events(self, events, **kwargs): + return handle_all_events( + events, + prepare_console=FakeConsole, + prepare_reader=partial( + ReadlineAlikeReader, + config=ReadlineConfig(readline_completer=None), + more_lines=more_lines, + paste_repl_sessions=True, + **kwargs, + ), + ) + + @subTests( + ("source", "expected"), + [ + (">>> 1 + 1", "1 + 1"), + ("... 1 + 1", "1 + 1"), + ("... after = True\n... value = 42\n", "after = True\nvalue = 42\n"), + ( + '... values = [1, 2, \\\n3, 4]\n', + 'values = [1, 2, 3, 4]\n', + ), + ('... value = 1\nKeyboardInterrupt\n', ''), + ( + """\ + >>> 1 + 1 + 2 + >>> """, + "1 + 1\n", + ), + ( + """\ + >>> def double(value): + ... return value * 2 + ... + >>> result = double(3) + >>> print(result) + 6 + """, + """\ + def double(value): + return value * 2 + + result = double(3) + print(result) + """, + ), + ( + """\ + >>> 1 / 0 + Traceback (most recent call last): + File "", line 1, in + ZeroDivisionError: division by zero + >>> print('hello') + hello + """, + """\ + 1 / 0 + print('hello') + """, + ), + ( + """\ + >>> if True: + ... print('hello') + ... + hello + >>>""", + """\ + if True: + print('hello') + + """, + ), + ( + '''\ + >>> text = """hello + ... >>> world + ... ... + ... """ + ''', + '''\ + text = """hello + >>> world + ... + """ + ''', + ), + (">>> print('caf\u00e9')\n", "print('caf\u00e9')\n"), + ( + '>>> text = "\v\f\x85\u2028\u2029"\n', + 'text = "\v\f\x85\u2028\u2029"\n', + ), + ( + '>>> text = "abc\\\ndef\\\nghi"\n>>> text\nabcdefghi\n', + 'text = "abcdefghi"\ntext\n', + ), + ( + '>>> values = [1, 2, \\\n3, 4]\n>>> values.append(5)\n', + 'values = [1, 2, 3, 4]\nvalues.append(5)\n', + ), + ( + '>>> if True:\n... text = "abc\\\ndef"\n...\n', + 'if True:\n text = "abcdef"\n\n', + ), + ( + '>>> value = 1 + \\\n... 2\n', + 'value = 1 + \\\n2\n', + ), + ( + '>>> x = 1\n>>> x = 2\nKeyboardInterrupt\n>>> x\n1\n', + 'x = 1\nx\n', + ), + ( + '>>> x = 1\n>>> if True:\n... x = 2\n' + '...\nKeyboardInterrupt\n>>> x\n1\n', + 'x = 1\nx\n', + ), + ( + '>>> raise KeyboardInterrupt\n' + 'Traceback (most recent call last):\n' + ' File "", line 1, in \n' + 'KeyboardInterrupt\n>>> x = 1\n', + 'raise KeyboardInterrupt\nx = 1\n', + ), + (">>>", ""), + ("...", ""), + ], + ) + @subTests("paste_mode", (False, True)) + @subTests("newline", ("\n", "\r\n", "\r")) + def test_bracketed_paste_repl_session(self, source, expected, paste_mode, newline): + source = textwrap.dedent(source).replace("\n", newline) + expected = textwrap.dedent(expected).replace("\n", newline) + events = code_to_events("\x1b[200~" + source + "\x1b[201~") + if paste_mode: + events = itertools.chain([Event("key", "f3")], events) + reader, console = self.handle_session_events(events) + self.assertEqual(reader.get_unicode(), expected if paste_mode else source) + self.assertEqual(reader.pos, len(reader.buffer)) + self.assertFalse(reader.finished) + if not paste_mode: + self.assertEndsWith( + stripcolor(reader.screen[-1]), + "press F3 (paste mode) to strip >>> and ... prompts", + ) + console.events = iter([Event("key", "f3")]) + reader.pos = len(reader.buffer) // 2 + reader.handle1() + self.assertEqual(reader.get_unicode(), expected) + self.assertEqual(reader.pos, len(reader.buffer)) + self.assertTrue(reader.paste_mode) + self.assertFalse(reader.finished) + self.assertFalse(reader.rendered_screen.overlays) + + def test_repl_session_hint_follows_buffer(self): + events = code_to_events("\x1b[200~>>> 1 + 1\x1b[201~") + reader, console = self.handle_session_events(events) + hint = "press F3 (paste mode) to strip >>> and ... prompts" + console.events = itertools.chain( + code_to_events("\x022\x01"), + [Event("resize", "")], + ) + with patch.object(console, "beep") as beep: + for _ in range(4): + reader.handle1() + self.assertEndsWith(stripcolor(reader.screen[-1]), hint) + beep.assert_not_called() + + # A command error takes precedence, then the hint returns. + reader.do_cmd(("yank", [])) + self.assertIn("nothing to yank", stripcolor(reader.screen[-1])) + console.events = code_to_events("\x01") + reader.handle1() + self.assertEndsWith(stripcolor(reader.screen[-1]), hint) + beep.assert_called_once() + + # Removing the prompt dismisses the hint; restoring it brings it back. + console.events = code_to_events("\x04>") + reader.handle1() + self.assertFalse(reader.can_strip_repl_prompts) + self.assertFalse(reader.rendered_screen.overlays) + reader.handle1() + self.assertTrue(reader.can_strip_repl_prompts) + self.assertEndsWith(stripcolor(reader.screen[-1]), hint) + + console.events = iter([Event("key", "f3")]) + reader.handle1() + self.assertEqual(reader.get_unicode(), "1 + 21") + self.assertFalse(reader.can_strip_repl_prompts) + self.assertFalse(reader.rendered_screen.overlays) + self.assertFalse(reader.finished) + + @subTests("initial", ("value = ", 'text = """\n')) + @subTests("next_keys", ("x", "\x02", "\x1b[200~ordinary\x1b[201~")) + def test_repl_session_hint_for_paste(self, initial, next_keys): + source = ">>> 1 + 1" + paste = "\x1b[200~" + source + "\x1b[201~" + reader = self.prepare_reader(code_to_events(paste * 2 + next_keys)) + reader.more_lines = more_lines + reader.paste_repl_sessions = True + reader.insert(initial) + + for count in (1, 2): + reader.handle1() + self.assertEqual(reader.get_unicode(), initial + source * count) + self.assertFalse(reader.can_strip_repl_prompts) + self.assertEndsWith( + stripcolor(reader.screen[-1]), + "press F3 (paste mode) to strip >>> and ... prompts", + ) + + reader.handle1() + self.assertFalse(reader.rendered_screen.overlays) + + @subTests( + "source", + ( + "print('hello')\n", + "...value\n", + ".... value\n", + ">>>> value\n", + ">>>value\n", + textwrap.dedent('''\ + text = """ + >>> example + ... example + """ + '''), + ), + ) + def test_bracketed_paste_preserves_non_session(self, source): + events = itertools.chain( + code_to_events("\x1b[200~" + source + "\x1b[201~"), + [Event("key", "f3")] * 2, + ) + reader, _ = self.handle_session_events(events, paste_mode=True) + self.assertEqual(reader.get_unicode(), source) + self.assertFalse(reader.rendered_screen.overlays) + + @subTests( + ("initial", "pos"), + ( + (initial, pos) + for initial in (" ", "value = ", 'text = """\n') + for pos in sorted({0, len(initial) // 2, len(initial)}) + ), + ) + @subTests("paste_mode", (False, True)) + def test_bracketed_paste_session_in_nonempty_buffer( + self, initial, pos, paste_mode + ): + source = textwrap.dedent("""\ + >>> print('hello') + hello + """) + + events = code_to_events("\x1b[200~" + source + "\x1b[201~") + reader, _ = self.handle_session_events( + events, buffer=list(initial), pos=pos, paste_mode=paste_mode, + ) + pasted = "print('hello')\n" if paste_mode else source + self.assertEqual( + reader.get_unicode(), initial[:pos] + pasted + initial[pos:] + ) + self.assertEqual(reader.pos, pos + len(pasted)) + self.assertFalse(reader.finished) + if paste_mode: + self.assertFalse(reader.rendered_screen.overlays) + else: + self.assertEndsWith( + stripcolor(reader.screen[-1]), + "press F3 (paste mode) to strip >>> and ... prompts", + ) + + def test_bracketed_paste_preserves_prompts_in_input(self): + source = ">>> not a REPL session" + events = itertools.chain( + [Event("key", "f3")], + code_to_events("\x1b[200~" + source + "\x1b[201~"), + [Event("key", "f3")], + code_to_events("\n"), + ) + reader = self.prepare_reader(events) + self.assertEqual(reader.readline(), source) + + def _paste_session(self, source, edit="", *, paste_first=True): + toggle = [Event("key", "f3")] + events = itertools.chain( + () if paste_first else toggle, + code_to_events("\x1b[200~" + source + "\x1b[201~"), + toggle if paste_first else (), + toggle, + code_to_events(edit + "\n"), + ) + reader = self.prepare_reader(events) + wrapper = _ReadlineWrapper(f_in=0, f_out=1, reader=reader) + result = wrapper.multiline_input( + more_lines, ">>> ", "... ", paste_repl_sessions=True, + ) + self.assertFalse(reader.paste_repl_sessions) + self.assertFalse(reader.paste_mode) + return result + + @subTests("paste_first", (False, True)) + def test_bracketed_paste_repl_session_edit_and_run(self, paste_first): + source = self._paste_session( + ">>> 1 + 1", edit="\b2", paste_first=paste_first, + ) + self.assertEqual(source, "1 + 2") + console = InteractiveColoredConsole() + with captured_stdout() as output: + self.assertFalse(console.push(source, _symbol="single")) + self.assertEqual(output.getvalue(), "3\n") + + @subTests( + ("session", "expected"), + ( + ('>>> value = "abc\\\ndef"\n', "abcdef"), + ('>>> value = [1, 2, \\\n3, 4]\n', [1, 2, 3, 4]), + ('>>> value = 1 + \\\n... 2\n', 3), + ('>>> value = 1\n>>> value = 2\nKeyboardInterrupt\n', 1), + ( + '>>> value = 1\n>>> if True:\n... value = 2\n' + '... \nKeyboardInterrupt\n', + 1, + ), + ), + ) + def test_bracketed_paste_repl_session_execution(self, session, expected): + source = self._paste_session(session) + namespace = {} + console = InteractiveColoredConsole(namespace) + with captured_stderr() as errors: + self.assertFalse(console.push(source, _symbol="single")) + self.assertEqual(errors.getvalue(), "") + self.assertEqual(namespace["value"], expected) + + def test_bracketed_paste_repl_session_stops_at_exception(self): + source = self._paste_session( + ">>> before = True\n>>> 1 / 0\n" + "Traceback (most recent call last):\n" + "ZeroDivisionError: division by zero\n" + ">>> after = True\n>>> print('hello')\nhello\n" + ) + namespace = {} + console = InteractiveColoredConsole(namespace) + with captured_stdout() as output, captured_stderr() as errors: + self.assertFalse(console.push(source, _symbol="single")) + self.assertTrue(namespace["before"]) + self.assertNotIn("after", namespace) + self.assertEqual(output.getvalue(), "") + self.assertIn("ZeroDivisionError", errors.getvalue()) + + @subTests("pasted", (False, True)) + @subTests("colorize", (False, True)) + @subTests(("failing", "exception"), ( + ("1 / 0", "ZeroDivisionError"), + ("return", "SyntaxError"), + )) + def test_retry_unexecuted_statements(self, pasted, colorize, failing, exception): + from _pyrepl import simple_interact + + source = f"before = True\n{failing}\nafter = True\n" + toggle = [Event("key", "f3")] + if pasted: + first_input = code_to_events("\x1b[200~" + source + "\x1b[201~") + else: + first_input = itertools.chain(toggle, code_to_events(source), toggle) + events = itertools.chain( + first_input, + code_to_events("\n\x1b[200~... after = True\n\x1b[201~"), + toggle, toggle, + code_to_events("\n\x04"), + ) + reader = self.prepare_reader(events) + reader.can_colorize = colorize + wrapper = _ReadlineWrapper(f_in=0, f_out=1, reader=reader) + console = InteractiveColoredConsole() + screens = [] + with ( + patch.object(pyrepl_readline, "_setup"), + patch.object(simple_interact, "_get_reader", return_value=reader), + patch.object(simple_interact, "multiline_input", wrapper.multiline_input), + patch.object(simple_interact, "append_history_file"), + patch.object(reader.console, "refresh", side_effect=screens.append), + captured_stderr() as errors, + ): + simple_interact.run_multiline_interactive_console(console) + self.assertTrue(console.locals["before"]) + self.assertTrue(console.locals["after"]) + self.assertIn(exception, errors.getvalue()) + notice = "code after exception did not run" + failure_screens = [ + screen + for screen in screens + if any(stripcolor(line).endswith(notice) for line in screen.screen_lines) + ] + self.assertTrue(failure_screens) + for screen in failure_screens: + lines = screen.overlays[-1].lines + self.assertEqual(len(lines), 1) + self.assertEndsWith(stripcolor(lines[0].text), notice) + self.assertFalse(reader.rendered_screen.overlays) + + @subTests("paste_mode", (None, "before", "after")) + @subTests("source", (">>> q = 1", ">>> run = 1", ">>> c")) + def test_bracketed_paste_preserves_session_in_pdb(self, paste_mode, source): + import pdb + + toggle = [Event("key", "f3")] + events = itertools.chain( + toggle if paste_mode == "before" else (), + code_to_events("\x1b[200~" + source + "\x1b[201~"), + toggle if paste_mode == "after" else (), + toggle if paste_mode else (), + code_to_events("\n"), + ) + reader = self.prepare_reader(events) + wrapper = _ReadlineWrapper(f_in=0, f_out=1, reader=reader) + debugger = pdb.Pdb(stdin=io.StringIO(), stdout=io.StringIO(), readrc=False) + with ( + patch("os.isatty", return_value=True), + patch.object(pyrepl_readline, "_ReadlineWrapper", return_value=wrapper), + ): + pdb_input = pdb.PdbPyReplInput( + debugger, Mock(), Mock(), debugger.prompt, + ) + self.assertEqual(pdb_input.readline(), source + "\n") + self.assertFalse(reader.rendered_screen.overlays) + @skipUnless(pty, "requires pty") class TestDumbTerminal(ReplTestCase): diff --git a/Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157612.GyH8xN.rst b/Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157612.GyH8xN.rst new file mode 100644 index 000000000000000..066235e1788441c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-16-12-00-00.gh-issue-157612.GyH8xN.rst @@ -0,0 +1,6 @@ +The new interactive shell can now strip ``>>>`` and ``...`` prompts and +output from pasted sessions using paste mode (:kbd:`F3`). +Selections starting with ``...`` continuation prompts are supported. When an +error skips later statements in an input block, a message reports that they +were not executed. +Patch by Bartosz Sławecki.