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/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 e79fbfa6bb0b386..abc0710421ff63c 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 ( @@ -297,7 +297,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 ( @@ -323,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): @@ -335,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): @@ -399,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 @@ -422,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) @@ -448,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) @@ -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/_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 832e67b534f2969..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 @@ -289,6 +290,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 @@ -587,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, ...]: @@ -876,10 +879,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 e4370b0d1462eaf..49bc364dae0ef97 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -125,9 +125,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 @@ -349,7 +346,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.