Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Doc/tutorial/appendix.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ Summary -- Release highlights
New features
============

Pasting interactive sessions
----------------------------

The :ref:`interactive shell <tut-interac>` 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
Expand Down
51 changes: 48 additions & 3 deletions Lib/_pyrepl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

import os
import re
import time

# Categories of actions:
Expand Down Expand Up @@ -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):
Expand All @@ -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)
11 changes: 6 additions & 5 deletions Lib/_pyrepl/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -237,6 +238,7 @@ def runcode(self, code):
return None

def runsource(self, source, filename="<input>", symbol="single"):
self.unexecuted_statements = False
try:
tree = self.compile.compiler(
source,
Expand Down Expand Up @@ -269,16 +271,14 @@ def runsource(self, source, filename="<input>", 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

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

result = self.runcode(code)
if result is self.STATEMENT_FAILED:
self.unexecuted_statements = stmt is not last_stmt
break
return False
15 changes: 11 additions & 4 deletions Lib/_pyrepl/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 25 additions & 3 deletions Lib/_pyrepl/readline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -382,23 +396,31 @@ 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
reader.ps4 = ""
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:
Expand Down
6 changes: 5 additions & 1 deletion Lib/_pyrepl/simple_interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -148,6 +150,8 @@ def maybe_run_command(statement: str) -> bool:
input_name = f"<python-input-{input_n}>"
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:
Expand Down
21 changes: 21 additions & 0 deletions Lib/test/test_pyrepl/test_interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
Loading
Loading