Skip to content

Commit 68f0d6a

Browse files
committed
Support pasting REPL sessions and retrying unexecuted input
1 parent f5bd077 commit 68f0d6a

10 files changed

Lines changed: 591 additions & 17 deletions

File tree

‎Doc/tutorial/appendix.rst‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@ details. Function keys provide some additional functionality.
2424
makes pasting larger blocks of code easier. Press :kbd:`F3` to return to
2525
the regular prompt.
2626

27+
In terminals that support bracketed paste, a pasted session starting with
28+
``>>>`` or ``...`` shows a hint while paste mode is off. The hint remains
29+
while the buffer is recognized as a session. Pressing :kbd:`F3` enters paste mode
30+
and removes prompts and output from the current buffer. Wrapped input lines
31+
are rejoined, and input abandoned with :kbd:`Control-C` is omitted.
32+
Review and edit the code, then press :kbd:`F3` again and :kbd:`Enter` to run it.
33+
34+
Pasting a session into existing input shows the hint temporarily, even inside
35+
a string. If paste mode is already active, each pasted session is stripped
36+
automatically, including when inserting into existing input.
37+
Pasting into an :func:`input` prompt or :mod:`pdb` leaves the text unchanged.
38+
39+
The pasted code runs as one block. An unhandled exception stops execution,
40+
so statements after it do not run, even if the copied session continued
41+
after the exception. When an error leaves later statements in the input
42+
unexecuted, a message below the next prompt reports this. This also applies
43+
to typed input. You can inspect the traceback, then copy and paste the
44+
remaining lines, including their ``...`` prompts.
45+
Expression results from earlier statements are not displayed through
46+
:func:`sys.displayhook`, so updates to ``_`` can differ from entering each
47+
statement separately. Output that looks like a prompted input line can be
48+
mistaken for code.
49+
2750
When using the new interactive shell, exit the shell by typing :kbd:`exit`
2851
or :kbd:`quit`. Adding call parentheses after those commands is not
2952
required.

‎Doc/whatsnew/3.16.rst‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ Summary -- Release highlights
6767
New features
6868
============
6969

70+
Pasting interactive sessions
71+
----------------------------
72+
73+
The :ref:`interactive shell <tut-interac>` can now strip ``>>>`` and ``...``
74+
prompts and output from pasted Python sessions, including selections that
75+
start with a ``...`` continuation prompt. Press :kbd:`F3` to strip a
76+
session already in the input buffer or enable stripping for subsequent
77+
pastes. This requires a terminal that supports bracketed paste. If an error
78+
stops execution before later statements in an input block, a message reports
79+
that those statements were not executed.
80+
(Contributed by Bartosz Sławecki in :gh:`157612`.)
7081

7182

7283
Other language changes

‎Lib/_pyrepl/commands.py‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
2121

2222
import os
23+
import re
2324
import time
2425

2526
# Categories of actions:
@@ -494,10 +495,45 @@ def do(self) -> None:
494495
self.reader.invalidate_full()
495496

496497

498+
_REPL_PASTE_HINT = "press F3 (paste mode) to strip >>> and ... prompts"
499+
500+
497501
class paste_mode(Command):
498502
def do(self) -> None:
499-
self.reader.paste_mode = not self.reader.paste_mode
500-
self.reader.invalidate_prompt()
503+
r = self.reader
504+
r.paste_mode = not r.paste_mode
505+
if r.paste_mode and getattr(r, "can_strip_repl_prompts", False):
506+
r.buffer[:] = _strip_repl_prompts(r.get_unicode())
507+
r.pos = len(r.buffer)
508+
r.invalidate_buffer(0)
509+
r.invalidate_prompt()
510+
511+
512+
def _is_repl_session(data: str) -> bool:
513+
return data[:3] in {">>>", "..."} and data[3:4] in {"", " ", "\r", "\n"}
514+
515+
516+
def _strip_repl_prompts(data: str) -> str:
517+
lines: list[str] = []
518+
statement_start = None
519+
for line in re.findall(r"[^\r\n]*(?:\r\n?|\n|$)", data):
520+
if _is_repl_session(line):
521+
if line.startswith(">>>") or statement_start is None:
522+
statement_start = len(lines)
523+
lines.append(line[3:].removeprefix(" "))
524+
elif statement_start is not None:
525+
if line.rstrip("\r\n") == "KeyboardInterrupt":
526+
# Ctrl-C leaves the abandoned input on screen with its prompts.
527+
del lines[statement_start:]
528+
statement_start = None
529+
elif (previous := lines[-1].rstrip("\r\n")).endswith("\\"):
530+
# PyREPL wraps input with a backslash and no prompt on the
531+
# next screen row. Rejoin it without the display backslash.
532+
lines[-1] = previous[:-1] + line
533+
else:
534+
# Output (including a traceback) ends the input statement.
535+
statement_start = None
536+
return "".join(lines)
501537

502538

503539
class perform_bracketed_paste(Command):
@@ -513,4 +549,13 @@ def do(self) -> None:
513549
l=len(data),
514550
s=time.time() - start,
515551
)
516-
self.reader.insert(data.replace(done, ""))
552+
data = data.replace(done, "")
553+
if (
554+
getattr(self.reader, "paste_repl_sessions", False)
555+
and _is_repl_session(data)
556+
):
557+
if self.reader.paste_mode:
558+
data = _strip_repl_prompts(data)
559+
else:
560+
self.reader.error(_REPL_PASTE_HINT)
561+
self.reader.insert(data)

‎Lib/_pyrepl/console.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def __init__(
214214
) -> None:
215215
super().__init__(locals=locals, filename=filename, local_exit=local_exit)
216216
self.can_colorize = _colorize.can_colorize()
217+
self.unexecuted_statements = False
217218

218219
def showsyntaxerror(self, filename=None, **kwargs):
219220
super().showsyntaxerror(filename=filename, **kwargs)
@@ -237,6 +238,7 @@ def runcode(self, code):
237238
return None
238239

239240
def runsource(self, source, filename="<input>", symbol="single"):
241+
self.unexecuted_statements = False
240242
try:
241243
tree = self.compile.compiler(
242244
source,
@@ -269,16 +271,14 @@ def runsource(self, source, filename="<input>", symbol="single"):
269271
try:
270272
code = self.compile.compiler(item, filename, the_symbol)
271273
linecache._register_code(code, source, filename)
272-
except SyntaxError as e:
273-
if e.args[0] == "'await' outside function":
274+
except Exception as e:
275+
if isinstance(e, SyntaxError) and e.args[0] == "'await' outside function":
274276
python = os.path.basename(sys.executable)
275277
e.add_note(
276278
f"Try the asyncio REPL ({python} -m asyncio) to use"
277279
f" top-level 'await' and run background asyncio tasks."
278280
)
279-
self.showsyntaxerror(filename, source=source)
280-
return False
281-
except Exception:
281+
self.unexecuted_statements = stmt is not last_stmt
282282
self.showsyntaxerror(filename, source=source)
283283
return False
284284

@@ -287,5 +287,6 @@ def runsource(self, source, filename="<input>", symbol="single"):
287287

288288
result = self.runcode(code)
289289
if result is self.STATEMENT_FAILED:
290+
self.unexecuted_statements = stmt is not last_stmt
290291
break
291292
return False

‎Lib/_pyrepl/reader.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,12 +581,16 @@ def _render_wrapped_rows(
581581
for row in wrapped_rows
582582
]
583583

584+
def get_message(self) -> str:
585+
return self.msg
586+
584587
def _render_message_lines(self) -> tuple[RenderLine, ...]:
585-
if not self.msg:
588+
message = self.get_message()
589+
if not message:
586590
return ()
587591
width = self.console.width
588592
render_lines: list[RenderLine] = []
589-
for message_line in self.msg.split("\n"):
593+
for message_line in message.split("\n"):
590594
line = RenderLine.from_rendered_text(message_line)
591595
if not line.cells:
592596
render_lines.append(line)
@@ -882,13 +886,16 @@ def debug(self, msg: str) -> None:
882886
# self.error(f"[debug] {msg}")
883887
pass
884888

885-
def error(self, msg: str = "none") -> None:
889+
def _format_error(self, msg: str) -> str:
886890
error_prefix = self.error_prefix
887891
if self.can_colorize:
888892
t = THEME()
889893
error_prefix = f"{t.error}{error_prefix}{t.reset}"
894+
return error_prefix + msg
895+
896+
def error(self, msg: str = "none") -> None:
890897
self.console.beep()
891-
self.msg = error_prefix + msg
898+
self.msg = self._format_error(msg)
892899
self.invalidate_message()
893900

894901
def update_screen(self) -> None:

‎Lib/_pyrepl/readline.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader):
111111
# Instance fields
112112
config: ReadlineConfig
113113
more_lines: MoreLinesCallable | None = None
114+
paste_repl_sessions: bool = False
114115
last_used_indentation: str | None = None
115116

116117
def __post_init__(self) -> None:
@@ -120,6 +121,19 @@ def __post_init__(self) -> None:
120121
self.commands["backspace_dedent"] = backspace_dedent
121122
self.commands["backspace-dedent"] = backspace_dedent
122123

124+
@property
125+
def can_strip_repl_prompts(self) -> bool:
126+
return (
127+
self.paste_repl_sessions
128+
and commands._is_repl_session(self.get_unicode())
129+
)
130+
131+
def get_message(self) -> str:
132+
message = super().get_message()
133+
if not message and not self.paste_mode and self.can_strip_repl_prompts:
134+
return self._format_error(commands._REPL_PASTE_HINT)
135+
return message
136+
123137
def get_stem(self) -> str:
124138
b = self.buffer
125139
p = self.pos - 1
@@ -382,23 +396,31 @@ def input(self, prompt: object = "") -> str:
382396
sys.audit("builtins.input/result", result)
383397
return result
384398

385-
def multiline_input(self, more_lines: MoreLinesCallable, ps1: str, ps2: str) -> str:
399+
def multiline_input(
400+
self,
401+
more_lines: MoreLinesCallable,
402+
ps1: str,
403+
ps2: str,
404+
*,
405+
paste_repl_sessions: bool = False,
406+
) -> str:
386407
"""Read an input on possibly multiple lines, asking for more
387408
lines as long as 'more_lines(unicodetext)' returns an object whose
388409
boolean value is true.
389410
"""
390411
reader = self.get_reader()
391-
saved = reader.more_lines
412+
saved = reader.more_lines, reader.paste_repl_sessions
392413
try:
393414
reader.more_lines = more_lines
415+
reader.paste_repl_sessions = paste_repl_sessions
394416
reader.ps1 = ps1
395417
reader.ps2 = ps1
396418
reader.ps3 = ps2
397419
reader.ps4 = ""
398420
with warnings.catch_warnings(action="ignore"):
399421
return reader.readline()
400422
finally:
401-
reader.more_lines = saved
423+
reader.more_lines, reader.paste_repl_sessions = saved
402424
reader.paste_mode = False
403425

404426
def parse_and_bind(self, string: str) -> None:

‎Lib/_pyrepl/simple_interact.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ def maybe_run_command(statement: str) -> bool:
138138
ps1 = getattr(sys, "ps1", ">>> ")
139139
ps2 = getattr(sys, "ps2", "... ")
140140
try:
141-
statement = multiline_input(more_lines, ps1, ps2)
141+
statement = multiline_input(
142+
more_lines, ps1, ps2, paste_repl_sessions=True,
143+
)
142144
except EOFError:
143145
break
144146

@@ -148,6 +150,8 @@ def maybe_run_command(statement: str) -> bool:
148150
input_name = f"<python-input-{input_n}>"
149151
more = console.push(_strip_final_indent(statement), filename=input_name, _symbol="single") # type: ignore[call-arg]
150152
assert not more
153+
if getattr(console, "unexecuted_statements", False):
154+
_get_reader().error("code after exception did not run")
151155
try:
152156
append_history_file()
153157
except (FileNotFoundError, PermissionError, OSError) as e:

‎Lib/test/test_pyrepl/test_interact.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ def test_multiple_statements_fail_early(self):
6767
self.assertIn('Exception: foobar', f.getvalue())
6868
self.assertNotIn('spam&eggs', f.getvalue())
6969

70+
def test_unexecuted_statements(self):
71+
console = InteractiveColoredConsole()
72+
cases = (
73+
("1 / 0\nafter = True", True),
74+
("if True:\n 1 / 0\nafter = True", True),
75+
("1 / 0; after = True", True),
76+
("before = True\n1 / 0", False),
77+
("1 / 0\n# No remaining statements", False),
78+
("try:\n 1 / 0\nexcept ZeroDivisionError:\n pass\nafter = True", False),
79+
("before = True\nreturn\nafter = True", True),
80+
("before = True", False),
81+
("", False),
82+
)
83+
for source, expected in cases:
84+
with (
85+
self.subTest(source=source),
86+
contextlib.redirect_stderr(io.StringIO()),
87+
):
88+
self.assertFalse(console.runsource(source))
89+
self.assertEqual(console.unexecuted_statements, expected)
90+
7091
def test_empty(self):
7192
namespace = {}
7293
code = ""

0 commit comments

Comments
 (0)