https://github.com/python/cpython/commit/f5bd07700ecd0f02cc424666b871f51de03b9232
commit: f5bd07700ecd0f02cc424666b871f51de03b9232
branch: main
author: Bartosz Sławecki <[email protected]>
committer: ambv <[email protected]>
date: 2026-10-20T11:55:45-07:00
summary:
gh-148228: Enable error overlays in the REPL (GH-148229)
files:
A Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
M Doc/whatsnew/3.16.rst
M Lib/_colorize.py
M Lib/_pyrepl/commands.py
M Lib/_pyrepl/layout.py
M Lib/_pyrepl/reader.py
M Lib/_pyrepl/readline.py
M Lib/test/test_pyrepl/test_reader.py
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 58f129bedcb3713..e54cf2e8bf6f9a6 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -108,6 +108,15 @@ Other language changes
(Contributed by Kumar Aditya in :gh:`157361`.)
+Default interactive shell
+=========================
+
+* The :term:`REPL` now displays error messages when editing commands cannot be
+ performed, such as when tab completion finds no matches or a history search
+ fails.
+ (Contributed by Bartosz Sławecki in :gh:`148228`.)
+
+
New modules
===========
diff --git a/Lib/_colorize.py b/Lib/_colorize.py
index 5f44a3aa05eb8f6..cfea60310ad55bc 100644
--- a/Lib/_colorize.py
+++ b/Lib/_colorize.py
@@ -402,6 +402,7 @@ class Pickletools(ThemeSection):
@dataclass(frozen=True, kw_only=True)
class Syntax(ThemeSection):
prompt: str = ANSIColors.BOLD_MAGENTA
+ error: str = ANSIColors.BOLD_RED
keyword: str = ANSIColors.BOLD_BLUE
keyword_constant: str = ANSIColors.BOLD_BLUE
builtin: str = ANSIColors.CYAN
diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py
index cf8b1a6c1e4ec4d..a3b541525aee103 100644
--- a/Lib/_pyrepl/commands.py
+++ b/Lib/_pyrepl/commands.py
@@ -262,7 +262,7 @@ def do(self) -> None:
r.select_item(r.historyi - 1)
return
r.pos = 0
- r.error("start of buffer")
+ r.debug("start of buffer")
return
if (
@@ -294,7 +294,7 @@ def do(self) -> None:
r.pos = r.eol(0)
return
r.pos = len(b)
- r.error("end of buffer")
+ r.debug("end of buffer")
return
if (
@@ -320,7 +320,7 @@ def do(self) -> None:
if p >= 0:
r.pos = p
else:
- self.reader.error("start of buffer")
+ self.reader.debug("start of buffer")
class right(MotionCommand):
@@ -332,7 +332,7 @@ def do(self) -> None:
if p <= len(b):
r.pos = p
else:
- self.reader.error("end of buffer")
+ self.reader.debug("end of buffer")
class beginning_of_line(MotionCommand):
@@ -396,7 +396,7 @@ def do(self) -> None:
b = r.buffer
s = r.pos - 1
if s < 0:
- r.error("cannot transpose at start of buffer")
+ r.debug("cannot transpose at start of buffer")
else:
if s == len(b):
s -= 1
@@ -419,7 +419,7 @@ def do(self) -> None:
del b[r.pos]
changed_from = r.pos if changed_from is None else
min(changed_from, r.pos)
else:
- self.reader.error("can't backspace at start")
+ self.reader.debug("can't backspace at start")
if changed_from is not None:
r.invalidate_buffer(changed_from)
@@ -445,7 +445,7 @@ def do(self) -> None:
del b[r.pos]
changed_from = r.pos if changed_from is None else
min(changed_from, r.pos)
else:
- self.reader.error("end of buffer")
+ self.reader.debug("end of buffer")
if changed_from is not None:
r.invalidate_buffer(changed_from)
@@ -466,15 +466,13 @@ def do(self) -> None:
class invalid_key(Command):
def do(self) -> None:
- pending = self.reader.console.getpending()
- s = "".join(self.event) + pending.data
- self.reader.error("`%r' not bound" % s)
+ self.reader.console.getpending()
+ self.reader.error("no command is bound to this key")
class invalid_command(Command):
def do(self) -> None:
- s = self.event_name
- self.reader.error("command `%s' not known" % s)
+ self.reader.error(f"command {self.event_name!r} is not implemented")
class show_history(Command):
diff --git a/Lib/_pyrepl/layout.py b/Lib/_pyrepl/layout.py
index cff0821cb95f72b..7cbd582bf9fb384 100644
--- a/Lib/_pyrepl/layout.py
+++ b/Lib/_pyrepl/layout.py
@@ -140,6 +140,23 @@ class LayoutResult:
line_end_offsets: tuple[int, ...]
+def wrapped_row_end(
+ widths: tuple[int, ...],
+ start: int,
+ available_width: int,
+) -> int:
+ """Return index of *widths* to split on to fit new row within
*available_width*.
+
+ If the first width does not fit, split after it anyway ("force progress").
+ """
+ end = start
+ column = 0
+ while end < len(widths) and column + widths[end] <= available_width:
+ column += widths[end]
+ end += 1
+ return end if end > start else start + 1
+
+
def layout_content_lines(
lines: tuple[ContentLine, ...],
width: int,
@@ -207,16 +224,12 @@ def layout_content_lines(
total = len(body)
while True:
# Find how many characters fit on this row.
- index_to_wrap_before = 0
- column = 0
- for char_width in body_widths[start:]:
- if column + char_width + current_prompt_width >= width:
- break
- index_to_wrap_before += 1
- column += char_width
-
- if index_to_wrap_before == 0 and start < total:
- index_to_wrap_before = 1 # force progress
+ end = wrapped_row_end(
+ body_widths,
+ start,
+ width - current_prompt_width - 1,
+ )
+ index_to_wrap_before = end - start
at_line_end = (start + index_to_wrap_before) >= total
if at_line_end:
@@ -230,7 +243,6 @@ def layout_content_lines(
suffix_width = 1
buffer_advance = index_to_wrap_before
- end = start + index_to_wrap_before
row_fragments = body[start:end]
row_widths = body_widths[start:end]
line_end_offsets.append(offset)
diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py
index b5686409c37cdb8..d552214f322d76d 100644
--- a/Lib/_pyrepl/reader.py
+++ b/Lib/_pyrepl/reader.py
@@ -35,6 +35,7 @@
process_prompt as build_prompt_content,
)
from .layout import LayoutMap, LayoutResult, LayoutRow, WrappedRow,
layout_content_lines
+from .layout import wrapped_row_end
from .render import RenderCell, RenderLine, RenderedScreen, ScreenOverlay
from .utils import ANSI_ESCAPE_SEQUENCE, ColorSpan, THEME, StyleRef, gen_colors
from .trace import trace
@@ -287,6 +288,7 @@ class Reader:
ps3: str = "|.. "
ps4: str = R"\__ "
kill_ring: list[list[str]] = field(default_factory=list)
+ error_prefix: str = "! "
msg: str = ""
arg: int | None = None
finished: bool = False
@@ -585,15 +587,16 @@ def _render_message_lines(self) -> tuple[RenderLine, ...]:
width = self.console.width
render_lines: list[RenderLine] = []
for message_line in self.msg.split("\n"):
- # If self.msg is larger than console width, make it fit.
- # TODO: try to split between words?
- if not message_line:
- render_lines.append(RenderLine.from_rendered_text(""))
+ line = RenderLine.from_rendered_text(message_line)
+ if not line.cells:
+ render_lines.append(line)
continue
- for offset in range(0, len(message_line), width):
- render_lines.append(
- RenderLine.from_rendered_text(message_line[offset : offset
+ width])
- )
+ widths = tuple(cell.width for cell in line.cells)
+ start = 0
+ while start < len(line.cells):
+ end = wrapped_row_end(widths, start, width)
+
render_lines.append(RenderLine.from_cells(line.cells[start:end]))
+ start = end
return tuple(render_lines)
def get_screen_overlays(self) -> tuple[ScreenOverlay, ...]:
@@ -874,10 +877,19 @@ def finish(self) -> None:
"""Called when a command signals that we're finished."""
pass
+ def debug(self, msg: str) -> None:
+ # Uncomment for debugging:
+ # self.error(f"[debug] {msg}")
+ pass
+
def error(self, msg: str = "none") -> None:
- self.msg = "! " + msg + " "
- self.invalidate_message()
+ error_prefix = self.error_prefix
+ if self.can_colorize:
+ t = THEME()
+ error_prefix = f"{t.error}{error_prefix}{t.reset}"
self.console.beep()
+ self.msg = error_prefix + msg
+ self.invalidate_message()
def update_screen(self) -> None:
if self.invalidation.is_cursor_only:
diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py
index a8af6cb425d0c99..4232a868a34e9ad 100644
--- a/Lib/_pyrepl/readline.py
+++ b/Lib/_pyrepl/readline.py
@@ -120,9 +120,6 @@ def __post_init__(self) -> None:
self.commands["backspace_dedent"] = backspace_dedent
self.commands["backspace-dedent"] = backspace_dedent
- def error(self, msg: str = "none") -> None:
- pass # don't show error messages by default
-
def get_stem(self) -> str:
b = self.buffer
p = self.pos - 1
@@ -344,7 +341,7 @@ def do(self) -> None:
del b[r.pos : r.pos + repeat]
r.invalidate_buffer(r.pos)
else:
- self.reader.error("can't backspace at start")
+ self.reader.debug("can't backspace at start")
# ____________________________________________________________
diff --git a/Lib/test/test_pyrepl/test_reader.py
b/Lib/test/test_pyrepl/test_reader.py
index 0b32ead357c2c0d..fd9900d9a32b3d2 100644
--- a/Lib/test/test_pyrepl/test_reader.py
+++ b/Lib/test/test_pyrepl/test_reader.py
@@ -4,7 +4,8 @@
from textwrap import dedent
from unittest import TestCase
from unittest.mock import MagicMock
-from test.support import force_colorized_test_class,
force_not_colorized_test_class
+from test.support import force_colorized, force_colorized_test_class
+from test.support import force_not_colorized_test_class
from .support import handle_all_events, handle_events_narrow_console
from .support import ScreenEqualMixin, code_to_events
@@ -72,6 +73,31 @@ def test_calc_screen_wrap_wide_characters(self):
reader, _ = handle_events_narrow_console(events)
self.assert_screen_equal(reader, f"{8*"a"}\\\n樂")
+ def test_messages_wrap_on_tight_screens(self):
+ console = prepare_console((), width=3)
+ reader = prepare_reader(console)
+
+ reader.msg = "\x1b[31mabcdef\x1b[0m"
+ lines = reader._render_message_lines()
+ self.assertEqual(
+ [line.text for line in lines],
+ ["\x1b[31mabc\x1b[0m", "\x1b[31mdef\x1b[0m"],
+ )
+
+ reader.msg = "ab樂c"
+ console.width = 4
+ lines = reader._render_message_lines()
+ self.assertEqual([line.text for line in lines], ["ab樂", "c"])
+ self.assertEqual([line.width for line in lines], [4, 1])
+
+ reader.msg = "ab\N{COMBINING ACUTE ACCENT}cd"
+ console.width = 3
+ lines = reader._render_message_lines()
+ self.assertEqual(
+ [line.text for line in lines],
+ ["ab\N{COMBINING ACUTE ACCENT}c", "d"],
+ )
+
def test_calc_screen_wrap_three_lines(self):
events = code_to_events(20 * "a")
reader, _ = handle_events_narrow_console(events)
@@ -431,6 +457,109 @@ def test_setpos_from_xy_for_non_printing_char(self):
reader.setpos_from_xy(8, 0)
self.assertEqual(reader.pos, 7)
+
+@force_not_colorized_test_class
+class TestErrorMessages(TestCase):
+ @force_colorized
+ def test_colorized_error_message(self):
+ console = prepare_console(code_to_events("\x19"))
+ reader = prepare_reader(console)
+
+ reader.handle1()
+
+ theme = default_theme.syntax
+ self.assertEqual(
+ reader.msg,
+ f"{theme.error}! {theme.reset}nothing to yank",
+ )
+
+ def _test_error_message(
+ self,
+ keys: str,
+ expected: str,
+ *,
+ command: str | None = None,
+ binding: str | None = None,
+ **reader_kwargs,
+ ):
+ console = prepare_console(code_to_events(keys))
+ reader = prepare_reader(console, **reader_kwargs)
+ if command is not None:
+ reader.bind(binding or keys, command)
+
+ while not reader.msg:
+ reader.handle1()
+
+ self.assertEqual(reader.msg, f"! {expected}")
+ self.assertEqual(reader.screen[-1], f"! {expected}")
+ console.beep.assert_called_once()
+
+ def test_invalid_key_message(self):
+ self._test_error_message("\x1bo", "no command is bound to this key")
+
+ def test_unimplemented_command_message(self):
+ self._test_error_message(
+ "x",
+ "command 'missing-command' is not implemented",
+ command="missing-command",
+ )
+
+ def test_nothing_to_yank_message(self):
+ self._test_error_message("\x19", "nothing to yank")
+
+ def test_yank_pop_with_empty_kill_ring_message(self):
+ self._test_error_message("\x1by", "nothing to yank")
+
+ def test_yank_pop_after_other_command_message(self):
+ self._test_error_message(
+ "x\x01\x0b\x1by",
+ "previous command was not a yank",
+ )
+
+ def test_start_of_history_message(self):
+ self._test_error_message("\x10", "start of history list")
+
+ def test_end_of_history_message(self):
+ self._test_error_message("\x0e", "end of history list")
+
+ def test_yank_arg_at_beginning_of_history_message(self):
+ self._test_error_message(
+ "\x1b.\x1b.\x1b.",
+ "beginning of history list",
+ history=["one"],
+ historyi=1,
+ )
+
+ def test_yank_arg_with_no_argument_message(self):
+ self._test_error_message(
+ "\x1b.",
+ "no such arg",
+ history=[""],
+ historyi=1,
+ )
+
+ def test_empty_incremental_search_backspace_message(self):
+ self._test_error_message(
+ "x",
+ "nothing to rubout",
+ command="isearch-backspace",
+ )
+
+ def test_history_search_not_found_message(self):
+ self._test_error_message(
+ "missingx",
+ "not found",
+ command="history-search-backward",
+ binding="x",
+ )
+
+ def test_incremental_search_not_found_message(self):
+ self._test_error_message("\x12x", "not found")
+
+ def test_no_completion_matches_message(self):
+ self._test_error_message("does_not_exist\t", "no matches")
+
+
@force_colorized_test_class
class TestReaderInColor(ScreenEqualMixin, TestCase):
def test_syntax_highlighting_basic(self):
diff --git
a/Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
b/Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
new file mode 100644
index 000000000000000..d00d072fb2da08b
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
@@ -0,0 +1,3 @@
+The :term:`REPL` now displays error messages when editing commands cannot be
+performed, such as when tab completion finds no matches or a history search
+fails. Patch by Bartosz Sławecki.
_______________________________________________
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]