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
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
===========

Expand Down
1 change: 1 addition & 0 deletions Lib/_colorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 10 additions & 12 deletions Lib/_pyrepl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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):
Expand Down
34 changes: 23 additions & 11 deletions Lib/_pyrepl/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
32 changes: 22 additions & 10 deletions Lib/_pyrepl/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, ...]:
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I am misreading this flow but if I am not wrong, the error() now embeds the escape sequences into self.msg. The message renderer at _render_message_lines slices by string length, not visible width. Then, len(message_line) counts the ANSI bytes (\x1b[1;31m ... \x1b[0m), so on narrow terminals (or long error messages) the slice boundary can land mid-escape, producing a truncated \x1b[ on one line and stray m on the next leaving the whole line un-colored or visually corrupted.

@johnslavik johnslavik Jul 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wired it to use the same wrapping method as layout_content_lines. Should be fine now!

Screenshot 2026-07-20 at 23 48 15

(Taking this to extremes, we don't handle splitting prompt string if terminal is too narrow. Technically anyone can set their own prompt string, so maybe we should?)

self.invalidate_message()

def update_screen(self) -> None:
if self.invalidation.is_cursor_only:
Expand Down
5 changes: 1 addition & 4 deletions Lib/_pyrepl/readline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")


# ____________________________________________________________
Expand Down
131 changes: 130 additions & 1 deletion Lib/test/test_pyrepl/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading