Skip to content

Commit f5bd077

Browse files
authored
gh-148228: Enable error overlays in the REPL (GH-148229)
1 parent 88d922a commit f5bd077

8 files changed

Lines changed: 199 additions & 38 deletions

File tree

‎Doc/whatsnew/3.16.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ Other language changes
108108
(Contributed by Kumar Aditya in :gh:`157361`.)
109109

110110

111+
Default interactive shell
112+
=========================
113+
114+
* The :term:`REPL` now displays error messages when editing commands cannot be
115+
performed, such as when tab completion finds no matches or a history search
116+
fails.
117+
(Contributed by Bartosz Sławecki in :gh:`148228`.)
118+
119+
111120
New modules
112121
===========
113122

‎Lib/_colorize.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ class Pickletools(ThemeSection):
402402
@dataclass(frozen=True, kw_only=True)
403403
class Syntax(ThemeSection):
404404
prompt: str = ANSIColors.BOLD_MAGENTA
405+
error: str = ANSIColors.BOLD_RED
405406
keyword: str = ANSIColors.BOLD_BLUE
406407
keyword_constant: str = ANSIColors.BOLD_BLUE
407408
builtin: str = ANSIColors.CYAN

‎Lib/_pyrepl/commands.py‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ def do(self) -> None:
262262
r.select_item(r.historyi - 1)
263263
return
264264
r.pos = 0
265-
r.error("start of buffer")
265+
r.debug("start of buffer")
266266
return
267267

268268
if (
@@ -294,7 +294,7 @@ def do(self) -> None:
294294
r.pos = r.eol(0)
295295
return
296296
r.pos = len(b)
297-
r.error("end of buffer")
297+
r.debug("end of buffer")
298298
return
299299

300300
if (
@@ -320,7 +320,7 @@ def do(self) -> None:
320320
if p >= 0:
321321
r.pos = p
322322
else:
323-
self.reader.error("start of buffer")
323+
self.reader.debug("start of buffer")
324324

325325

326326
class right(MotionCommand):
@@ -332,7 +332,7 @@ def do(self) -> None:
332332
if p <= len(b):
333333
r.pos = p
334334
else:
335-
self.reader.error("end of buffer")
335+
self.reader.debug("end of buffer")
336336

337337

338338
class beginning_of_line(MotionCommand):
@@ -396,7 +396,7 @@ def do(self) -> None:
396396
b = r.buffer
397397
s = r.pos - 1
398398
if s < 0:
399-
r.error("cannot transpose at start of buffer")
399+
r.debug("cannot transpose at start of buffer")
400400
else:
401401
if s == len(b):
402402
s -= 1
@@ -419,7 +419,7 @@ def do(self) -> None:
419419
del b[r.pos]
420420
changed_from = r.pos if changed_from is None else min(changed_from, r.pos)
421421
else:
422-
self.reader.error("can't backspace at start")
422+
self.reader.debug("can't backspace at start")
423423
if changed_from is not None:
424424
r.invalidate_buffer(changed_from)
425425

@@ -445,7 +445,7 @@ def do(self) -> None:
445445
del b[r.pos]
446446
changed_from = r.pos if changed_from is None else min(changed_from, r.pos)
447447
else:
448-
self.reader.error("end of buffer")
448+
self.reader.debug("end of buffer")
449449
if changed_from is not None:
450450
r.invalidate_buffer(changed_from)
451451

@@ -466,15 +466,13 @@ def do(self) -> None:
466466

467467
class invalid_key(Command):
468468
def do(self) -> None:
469-
pending = self.reader.console.getpending()
470-
s = "".join(self.event) + pending.data
471-
self.reader.error("`%r' not bound" % s)
469+
self.reader.console.getpending()
470+
self.reader.error("no command is bound to this key")
472471

473472

474473
class invalid_command(Command):
475474
def do(self) -> None:
476-
s = self.event_name
477-
self.reader.error("command `%s' not known" % s)
475+
self.reader.error(f"command {self.event_name!r} is not implemented")
478476

479477

480478
class show_history(Command):

‎Lib/_pyrepl/layout.py‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ class LayoutResult:
140140
line_end_offsets: tuple[int, ...]
141141

142142

143+
def wrapped_row_end(
144+
widths: tuple[int, ...],
145+
start: int,
146+
available_width: int,
147+
) -> int:
148+
"""Return index of *widths* to split on to fit new row within *available_width*.
149+
150+
If the first width does not fit, split after it anyway ("force progress").
151+
"""
152+
end = start
153+
column = 0
154+
while end < len(widths) and column + widths[end] <= available_width:
155+
column += widths[end]
156+
end += 1
157+
return end if end > start else start + 1
158+
159+
143160
def layout_content_lines(
144161
lines: tuple[ContentLine, ...],
145162
width: int,
@@ -207,16 +224,12 @@ def layout_content_lines(
207224
total = len(body)
208225
while True:
209226
# Find how many characters fit on this row.
210-
index_to_wrap_before = 0
211-
column = 0
212-
for char_width in body_widths[start:]:
213-
if column + char_width + current_prompt_width >= width:
214-
break
215-
index_to_wrap_before += 1
216-
column += char_width
217-
218-
if index_to_wrap_before == 0 and start < total:
219-
index_to_wrap_before = 1 # force progress
227+
end = wrapped_row_end(
228+
body_widths,
229+
start,
230+
width - current_prompt_width - 1,
231+
)
232+
index_to_wrap_before = end - start
220233

221234
at_line_end = (start + index_to_wrap_before) >= total
222235
if at_line_end:
@@ -230,7 +243,6 @@ def layout_content_lines(
230243
suffix_width = 1
231244
buffer_advance = index_to_wrap_before
232245

233-
end = start + index_to_wrap_before
234246
row_fragments = body[start:end]
235247
row_widths = body_widths[start:end]
236248
line_end_offsets.append(offset)

‎Lib/_pyrepl/reader.py‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
process_prompt as build_prompt_content,
3636
)
3737
from .layout import LayoutMap, LayoutResult, LayoutRow, WrappedRow, layout_content_lines
38+
from .layout import wrapped_row_end
3839
from .render import RenderCell, RenderLine, RenderedScreen, ScreenOverlay
3940
from .utils import ANSI_ESCAPE_SEQUENCE, ColorSpan, THEME, StyleRef, gen_colors
4041
from .trace import trace
@@ -287,6 +288,7 @@ class Reader:
287288
ps3: str = "|.. "
288289
ps4: str = R"\__ "
289290
kill_ring: list[list[str]] = field(default_factory=list)
291+
error_prefix: str = "! "
290292
msg: str = ""
291293
arg: int | None = None
292294
finished: bool = False
@@ -585,15 +587,16 @@ def _render_message_lines(self) -> tuple[RenderLine, ...]:
585587
width = self.console.width
586588
render_lines: list[RenderLine] = []
587589
for message_line in self.msg.split("\n"):
588-
# If self.msg is larger than console width, make it fit.
589-
# TODO: try to split between words?
590-
if not message_line:
591-
render_lines.append(RenderLine.from_rendered_text(""))
590+
line = RenderLine.from_rendered_text(message_line)
591+
if not line.cells:
592+
render_lines.append(line)
592593
continue
593-
for offset in range(0, len(message_line), width):
594-
render_lines.append(
595-
RenderLine.from_rendered_text(message_line[offset : offset + width])
596-
)
594+
widths = tuple(cell.width for cell in line.cells)
595+
start = 0
596+
while start < len(line.cells):
597+
end = wrapped_row_end(widths, start, width)
598+
render_lines.append(RenderLine.from_cells(line.cells[start:end]))
599+
start = end
597600
return tuple(render_lines)
598601

599602
def get_screen_overlays(self) -> tuple[ScreenOverlay, ...]:
@@ -874,10 +877,19 @@ def finish(self) -> None:
874877
"""Called when a command signals that we're finished."""
875878
pass
876879

880+
def debug(self, msg: str) -> None:
881+
# Uncomment for debugging:
882+
# self.error(f"[debug] {msg}")
883+
pass
884+
877885
def error(self, msg: str = "none") -> None:
878-
self.msg = "! " + msg + " "
879-
self.invalidate_message()
886+
error_prefix = self.error_prefix
887+
if self.can_colorize:
888+
t = THEME()
889+
error_prefix = f"{t.error}{error_prefix}{t.reset}"
880890
self.console.beep()
891+
self.msg = error_prefix + msg
892+
self.invalidate_message()
881893

882894
def update_screen(self) -> None:
883895
if self.invalidation.is_cursor_only:

‎Lib/_pyrepl/readline.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,6 @@ def __post_init__(self) -> None:
120120
self.commands["backspace_dedent"] = backspace_dedent
121121
self.commands["backspace-dedent"] = backspace_dedent
122122

123-
def error(self, msg: str = "none") -> None:
124-
pass # don't show error messages by default
125-
126123
def get_stem(self) -> str:
127124
b = self.buffer
128125
p = self.pos - 1
@@ -344,7 +341,7 @@ def do(self) -> None:
344341
del b[r.pos : r.pos + repeat]
345342
r.invalidate_buffer(r.pos)
346343
else:
347-
self.reader.error("can't backspace at start")
344+
self.reader.debug("can't backspace at start")
348345

349346

350347
# ____________________________________________________________

‎Lib/test/test_pyrepl/test_reader.py‎

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from textwrap import dedent
55
from unittest import TestCase
66
from unittest.mock import MagicMock
7-
from test.support import force_colorized_test_class, force_not_colorized_test_class
7+
from test.support import force_colorized, force_colorized_test_class
8+
from test.support import force_not_colorized_test_class
89

910
from .support import handle_all_events, handle_events_narrow_console
1011
from .support import ScreenEqualMixin, code_to_events
@@ -72,6 +73,31 @@ def test_calc_screen_wrap_wide_characters(self):
7273
reader, _ = handle_events_narrow_console(events)
7374
self.assert_screen_equal(reader, f"{8*"a"}\\\n樂")
7475

76+
def test_messages_wrap_on_tight_screens(self):
77+
console = prepare_console((), width=3)
78+
reader = prepare_reader(console)
79+
80+
reader.msg = "\x1b[31mabcdef\x1b[0m"
81+
lines = reader._render_message_lines()
82+
self.assertEqual(
83+
[line.text for line in lines],
84+
["\x1b[31mabc\x1b[0m", "\x1b[31mdef\x1b[0m"],
85+
)
86+
87+
reader.msg = "ab樂c"
88+
console.width = 4
89+
lines = reader._render_message_lines()
90+
self.assertEqual([line.text for line in lines], ["ab樂", "c"])
91+
self.assertEqual([line.width for line in lines], [4, 1])
92+
93+
reader.msg = "ab\N{COMBINING ACUTE ACCENT}cd"
94+
console.width = 3
95+
lines = reader._render_message_lines()
96+
self.assertEqual(
97+
[line.text for line in lines],
98+
["ab\N{COMBINING ACUTE ACCENT}c", "d"],
99+
)
100+
75101
def test_calc_screen_wrap_three_lines(self):
76102
events = code_to_events(20 * "a")
77103
reader, _ = handle_events_narrow_console(events)
@@ -431,6 +457,109 @@ def test_setpos_from_xy_for_non_printing_char(self):
431457
reader.setpos_from_xy(8, 0)
432458
self.assertEqual(reader.pos, 7)
433459

460+
461+
@force_not_colorized_test_class
462+
class TestErrorMessages(TestCase):
463+
@force_colorized
464+
def test_colorized_error_message(self):
465+
console = prepare_console(code_to_events("\x19"))
466+
reader = prepare_reader(console)
467+
468+
reader.handle1()
469+
470+
theme = default_theme.syntax
471+
self.assertEqual(
472+
reader.msg,
473+
f"{theme.error}! {theme.reset}nothing to yank",
474+
)
475+
476+
def _test_error_message(
477+
self,
478+
keys: str,
479+
expected: str,
480+
*,
481+
command: str | None = None,
482+
binding: str | None = None,
483+
**reader_kwargs,
484+
):
485+
console = prepare_console(code_to_events(keys))
486+
reader = prepare_reader(console, **reader_kwargs)
487+
if command is not None:
488+
reader.bind(binding or keys, command)
489+
490+
while not reader.msg:
491+
reader.handle1()
492+
493+
self.assertEqual(reader.msg, f"! {expected}")
494+
self.assertEqual(reader.screen[-1], f"! {expected}")
495+
console.beep.assert_called_once()
496+
497+
def test_invalid_key_message(self):
498+
self._test_error_message("\x1bo", "no command is bound to this key")
499+
500+
def test_unimplemented_command_message(self):
501+
self._test_error_message(
502+
"x",
503+
"command 'missing-command' is not implemented",
504+
command="missing-command",
505+
)
506+
507+
def test_nothing_to_yank_message(self):
508+
self._test_error_message("\x19", "nothing to yank")
509+
510+
def test_yank_pop_with_empty_kill_ring_message(self):
511+
self._test_error_message("\x1by", "nothing to yank")
512+
513+
def test_yank_pop_after_other_command_message(self):
514+
self._test_error_message(
515+
"x\x01\x0b\x1by",
516+
"previous command was not a yank",
517+
)
518+
519+
def test_start_of_history_message(self):
520+
self._test_error_message("\x10", "start of history list")
521+
522+
def test_end_of_history_message(self):
523+
self._test_error_message("\x0e", "end of history list")
524+
525+
def test_yank_arg_at_beginning_of_history_message(self):
526+
self._test_error_message(
527+
"\x1b.\x1b.\x1b.",
528+
"beginning of history list",
529+
history=["one"],
530+
historyi=1,
531+
)
532+
533+
def test_yank_arg_with_no_argument_message(self):
534+
self._test_error_message(
535+
"\x1b.",
536+
"no such arg",
537+
history=[""],
538+
historyi=1,
539+
)
540+
541+
def test_empty_incremental_search_backspace_message(self):
542+
self._test_error_message(
543+
"x",
544+
"nothing to rubout",
545+
command="isearch-backspace",
546+
)
547+
548+
def test_history_search_not_found_message(self):
549+
self._test_error_message(
550+
"missingx",
551+
"not found",
552+
command="history-search-backward",
553+
binding="x",
554+
)
555+
556+
def test_incremental_search_not_found_message(self):
557+
self._test_error_message("\x12x", "not found")
558+
559+
def test_no_completion_matches_message(self):
560+
self._test_error_message("does_not_exist\t", "no matches")
561+
562+
434563
@force_colorized_test_class
435564
class TestReaderInColor(ScreenEqualMixin, TestCase):
436565
def test_syntax_highlighting_basic(self):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The :term:`REPL` now displays error messages when editing commands cannot be
2+
performed, such as when tab completion finds no matches or a history search
3+
fails. Patch by Bartosz Sławecki.

0 commit comments

Comments
 (0)