From 86977f93a5b5213c2c8477c033ca7640f64e10be Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 7 Apr 2026 19:13:48 +0200 Subject: [PATCH 1/8] Enable error overlays --- Lib/_colorize.py | 1 + Lib/_pyrepl/reader.py | 7 ++++++- Lib/_pyrepl/readline.py | 3 --- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 8361ddbea89716b..fd0ed2f93b2d6d3 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -334,6 +334,7 @@ class LiveProfiler(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/reader.py b/Lib/_pyrepl/reader.py index f35a99fb06a3f9e..3c1ce72f8342332 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -196,6 +196,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 dirty: bool = False @@ -641,7 +642,11 @@ def finish(self) -> None: pass def error(self, msg: str = "none") -> None: - self.msg = "! " + msg + " " + error_prefix = self.error_prefix + if self.can_colorize: + t = THEME() + error_prefix = f"{t.error}{error_prefix}{t.reset}" + self.msg = error_prefix + msg self.dirty = True self.console.beep() diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 8d3be37b4adeec1..2cce542a73d28e3 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -124,9 +124,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 From 9415f6b7ed96f6053ddd23b554dd0d9a1e91a632 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 7 Apr 2026 19:49:10 +0200 Subject: [PATCH 2/8] Fix `down` command error reporting bug --- Lib/_pyrepl/commands.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 10127e58897a580..a52ac8889018be0 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -289,8 +289,10 @@ def do(self) -> None: r.select_item(r.historyi + 1) r.pos = r.eol(0) return - r.pos = len(b) - r.error("end of buffer") + if r.pos == len(b): + r.error("end of buffer") + else: + r.pos = len(b) return if ( From 62b357797ba3e1c6a28cd25fd323d0baf0737150 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 7 Apr 2026 19:54:23 +0200 Subject: [PATCH 3/8] Fix `test_down_arrow_end_of_input` --- Lib/test/test_pyrepl/test_pyrepl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 8a3cae966a6e054..ce98e7b8acdb88d 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -212,13 +212,15 @@ def test_down_arrow_end_of_input(self): events = itertools.chain( code_to_events(code), [ + # Go left first to avoid end of buffer error. + Event(evt="key", data="left", raw=bytearray(b"\x1bOD")), Event(evt="key", data="down", raw=bytearray(b"\x1bOB")), ], ) reader, console = handle_all_events(events) self.assertEqual(reader.cxy, (0, 2)) - console.move_cursor.assert_called_once_with(0, 2) + console.move_cursor.assert_called_with(0, 2) def test_left_arrow_simple(self): events = itertools.chain( From 50188d301912e1ef08bc90106c7239a8242fd32f Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 7 Apr 2026 19:54:44 +0200 Subject: [PATCH 4/8] Fix `test_right_arrow_end_of_line` test --- Lib/test/test_pyrepl/test_pyrepl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index ce98e7b8acdb88d..026e8ba91361f50 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -238,13 +238,15 @@ def test_right_arrow_end_of_line(self): events = itertools.chain( code_to_events("11+11"), [ + # Go left first to avoid end of buffer error. + Event(evt="key", data="left", raw=bytearray(b"\x1bOD")), Event(evt="key", data="right", raw=bytearray(b"\x1bOC")), ], ) reader, console = handle_all_events(events) self.assertEqual(reader.cxy, (5, 0)) - console.move_cursor.assert_called_once_with(5, 0) + console.move_cursor.assert_called_with(5, 0) def test_cursor_position_simple_character(self): events = itertools.chain(code_to_events("k")) From 18bb80e8105d6ab981f42df3706882b305a52f1c Mon Sep 17 00:00:00 2001 From: johnslavik Date: Sun, 19 Jul 2026 12:38:55 +0200 Subject: [PATCH 5/8] Silence some errors --- Lib/_pyrepl/commands.py | 18 ++++++++---------- Lib/_pyrepl/reader.py | 5 +++++ Lib/_pyrepl/readline.py | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index bbe884a83f83701..0ce3cba1de28ed5 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -265,7 +265,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 ( @@ -296,10 +296,8 @@ def do(self) -> None: r.select_item(r.historyi + 1) r.pos = r.eol(0) return - if r.pos == len(b): - r.error("end of buffer") - else: - r.pos = len(b) + r.pos = len(b) + r.debug("end of buffer") return if ( @@ -325,7 +323,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): @@ -337,7 +335,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): @@ -401,7 +399,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 @@ -424,7 +422,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) @@ -450,7 +448,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) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 4f8c787312d772f..d58d7a7c8a02764 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -875,6 +875,11 @@ 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: error_prefix = self.error_prefix if self.can_colorize: diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 6d8bab6b7718e93..561675c0e6f7908 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -336,7 +336,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") # ____________________________________________________________ From ff308b63d175471d89915098c8711c38a769db9e Mon Sep 17 00:00:00 2001 From: johnslavik Date: Mon, 20 Jul 2026 23:44:23 +0200 Subject: [PATCH 6/8] Wrap message lines using cells --- Lib/_pyrepl/layout.py | 34 +++++++++++++++++++---------- Lib/_pyrepl/reader.py | 18 ++++++++------- Lib/test/test_pyrepl/test_reader.py | 25 +++++++++++++++++++++ 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/Lib/_pyrepl/layout.py b/Lib/_pyrepl/layout.py index 6d854d1142dd9f1..89d2099fb1ce415 100644 --- a/Lib/_pyrepl/layout.py +++ b/Lib/_pyrepl/layout.py @@ -141,6 +141,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, @@ -208,16 +225,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: @@ -231,7 +244,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 39dfc06ebd3390c..7448329f3d42794 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -37,6 +37,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 @@ -588,15 +589,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, ...]: diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 0b32ead357c2c0d..fa3fbfaad207741 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -72,6 +72,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) From 922d3cbf656ebe05db105f4182a6fa86f1eaa2ef Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 21 Jul 2026 00:50:19 +0200 Subject: [PATCH 7/8] Polish error messages and add coverage --- Lib/_pyrepl/commands.py | 8 +-- Lib/test/test_pyrepl/test_pyrepl.py | 8 +-- Lib/test/test_pyrepl/test_reader.py | 106 +++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 0ce3cba1de28ed5..abc0710421ff63c 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -469,15 +469,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/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 13e68aaaba8416e..4240a3c31749599 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -214,15 +214,13 @@ def test_down_arrow_end_of_input(self): events = itertools.chain( code_to_events(code), [ - # Go left first to avoid end of buffer error. - Event(evt="key", data="left", raw=bytearray(b"\x1bOD")), Event(evt="key", data="down", raw=bytearray(b"\x1bOB")), ], ) reader, console = handle_all_events(events) self.assertEqual(reader.cxy, (0, 2)) - console.move_cursor.assert_called_with(0, 2) + console.move_cursor.assert_called_once_with(0, 2) def test_left_arrow_simple(self): events = itertools.chain( @@ -240,15 +238,13 @@ def test_right_arrow_end_of_line(self): events = itertools.chain( code_to_events("11+11"), [ - # Go left first to avoid end of buffer error. - Event(evt="key", data="left", raw=bytearray(b"\x1bOD")), Event(evt="key", data="right", raw=bytearray(b"\x1bOC")), ], ) reader, console = handle_all_events(events) self.assertEqual(reader.cxy, (5, 0)) - console.move_cursor.assert_called_with(5, 0) + console.move_cursor.assert_called_once_with(5, 0) def test_cursor_position_simple_character(self): events = itertools.chain(code_to_events("k")) diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index fa3fbfaad207741..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 @@ -456,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): From 8cb9bc0df795bf949f7a7984c81e79c5fdef6ed9 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 21 Jul 2026 00:09:38 +0200 Subject: [PATCH 8/8] Add release notes --- Doc/whatsnew/3.16.rst | 9 +++++++++ .../2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst | 3 +++ 2 files changed, 12 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index c06e6930ac78bff..a689202c788155b 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -82,6 +82,15 @@ Other language changes (Contributed by Łukasz Langa in :gh:`102960`.) +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/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.