From 7a95c50f75f031d6c44a8ec4d6d7beb230c077a9 Mon Sep 17 00:00:00 2001 From: deepwzh Date: Sun, 11 May 2025 06:47:12 +0000 Subject: [PATCH 1/4] gh-131430: Fix crashes on empty DELETE_WORD_BACKWARDS (^W) followed by CLEAR_TO_START (^K) --- Lib/_pyrepl/commands.py | 2 +- Lib/test/test_pyrepl/test_reader.py | 54 +++++++++++++++++++ ...-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst | 3 ++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 2354fbb2ec2c1e..c9482fcc9dfef4 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -66,7 +66,7 @@ def kill_range(self, start: int, end: int) -> None: b = r.buffer text = b[start:end] del b[start:end] - if is_kill(r.last_command): + if is_kill(r.last_command) and r.kill_ring: if start < r.pos: r.kill_ring[-1] = text + r.kill_ring[-1] else: diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 57526f88f9384b..f83303c3e1a05e 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -4,6 +4,7 @@ from textwrap import dedent from unittest import TestCase from unittest.mock import MagicMock +from _pyrepl.readline import multiline_input from test.support import force_colorized_test_class, force_not_colorized_test_class from .support import handle_all_events, handle_events_narrow_console @@ -358,6 +359,59 @@ def test_setpos_from_xy_for_non_printing_char(self): reader.setpos_from_xy(8, 0) self.assertEqual(reader.pos, 7) + def test_empty_line_control_w_k(self): + """Test that Control-W followed by Control-K on an empty line doesn't crash.""" + events = itertools.chain( + [ + Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W + Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K + ], + ) + reader, _ = handle_all_events(events) + self.assert_screen_equal(reader, "", clean=True) + self.assertEqual(reader.pos, 0) + + def test_control_w_delete_word(self): + """Test Control-W delete word""" + def test_with_text(text: str, expected: list[str], before_pos: int, after_pos: int): + events = itertools.chain( + code_to_events(text) if len(text) else [], + [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - before_pos), # Move cursor to specified position + [ + Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W + ], + ) + reader, _ = handle_all_events(events) + self.assertEqual(reader.screen, expected) + self.assertEqual(reader.pos, after_pos) + + test_with_text("", [], 0, 0) + test_with_text("a", [""], 1, 0) + test_with_text("abc", [""], 3, 0) + test_with_text("abc def", ["def"], 4, 0) + test_with_text("abc def", ["abc "], 7, 4) + test_with_text("def xxx():xxx\n ", ["def xxx():"], 18, 10) + + def test_control_k_delete_to_eol(self): + """Test Control-K delete from cursor to end of line""" + def test_with_text(text: str, pos: int, expected: list[str]): + events = itertools.chain( + code_to_events(text) if len(text) else [], + [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - pos), # Move cursor to specified position + [ + Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K + ], + ) + reader, _ = handle_all_events(events) + self.assertEqual(reader.screen, expected) + self.assertEqual(reader.pos, pos) + + test_with_text("", 0, [""]) + test_with_text("a", 0, [""]) + test_with_text("abc", 1, ["a"]) + test_with_text("abc def", 4, ["abc "]) + test_with_text("def xxx():xxx\n pass", 10, ["def xxx():", " pass"]) + @force_colorized_test_class class TestReaderInColor(ScreenEqualMixin, TestCase): def test_syntax_highlighting_basic(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst new file mode 100644 index 00000000000000..c4a6410eeed429 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst @@ -0,0 +1,3 @@ +Fix crashes on an empty DELETE_WORD_BACKWARDS (^W) followed by +CLEAR_TO_START (^K). Added comprehensive test cases for both Control-W and +Control-K functionality. From 0e527bad4bd05909b42645dc1dc12cbfd83da31d Mon Sep 17 00:00:00 2001 From: DeepWzh Date: Mon, 12 May 2025 13:23:53 +0800 Subject: [PATCH 2/4] optimize and streamline the writing of pyrepl test code Co-authored-by: Tomas R. --- Lib/test/test_pyrepl/test_reader.py | 12 +++++------- .../2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst | 5 ++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index f83303c3e1a05e..f96ec61b3ca7de 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -361,12 +361,10 @@ def test_setpos_from_xy_for_non_printing_char(self): def test_empty_line_control_w_k(self): """Test that Control-W followed by Control-K on an empty line doesn't crash.""" - events = itertools.chain( - [ - Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W - Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K - ], - ) + events = [ + Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W + Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K + ] reader, _ = handle_all_events(events) self.assert_screen_equal(reader, "", clean=True) self.assertEqual(reader.pos, 0) @@ -375,7 +373,7 @@ def test_control_w_delete_word(self): """Test Control-W delete word""" def test_with_text(text: str, expected: list[str], before_pos: int, after_pos: int): events = itertools.chain( - code_to_events(text) if len(text) else [], + code_to_events(text), [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - before_pos), # Move cursor to specified position [ Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst index c4a6410eeed429..0d9621e7aeed6e 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst @@ -1,3 +1,2 @@ -Fix crashes on an empty DELETE_WORD_BACKWARDS (^W) followed by -CLEAR_TO_START (^K). Added comprehensive test cases for both Control-W and -Control-K functionality. +Fix PyREPL crash on an empty DELETE_WORD_BACKWARDS (^W) followed by +CLEAR_TO_START (^K). From a1e2e3845c955e9e9e86f47fc95383a494ab8498 Mon Sep 17 00:00:00 2001 From: deepwzh Date: Mon, 12 May 2025 05:30:18 +0000 Subject: [PATCH 3/4] optimize pyrepl test cases using subtests --- Lib/test/test_pyrepl/test_reader.py | 74 ++++++++++++++++------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index f96ec61b3ca7de..c8be8844aac1ff 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -371,44 +371,50 @@ def test_empty_line_control_w_k(self): def test_control_w_delete_word(self): """Test Control-W delete word""" - def test_with_text(text: str, expected: list[str], before_pos: int, after_pos: int): - events = itertools.chain( - code_to_events(text), - [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - before_pos), # Move cursor to specified position - [ - Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W - ], - ) - reader, _ = handle_all_events(events) - self.assertEqual(reader.screen, expected) - self.assertEqual(reader.pos, after_pos) + cases = ( + ("", 0, 0, []), + ("a", 1, 0, [""]), + ("abc", 3, 0, [""]), + ("abc def", 4, 0, ["def"]), + ("abc def", 7, 4, ["abc "]), + ("def xxx():xxx\n ", 18, 10, ["def xxx():"]), + ) - test_with_text("", [], 0, 0) - test_with_text("a", [""], 1, 0) - test_with_text("abc", [""], 3, 0) - test_with_text("abc def", ["def"], 4, 0) - test_with_text("abc def", ["abc "], 7, 4) - test_with_text("def xxx():xxx\n ", ["def xxx():"], 18, 10) + for text, before_pos, after_pos, expected in cases: + with self.subTest(text=text, before_pos=before_pos): + events = itertools.chain( + code_to_events(text), + [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - before_pos), # Move cursor to specified position + [ + Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W + ], + ) + reader, _ = handle_all_events(events) + self.assertEqual(reader.screen, expected) + self.assertEqual(reader.pos, after_pos) def test_control_k_delete_to_eol(self): """Test Control-K delete from cursor to end of line""" - def test_with_text(text: str, pos: int, expected: list[str]): - events = itertools.chain( - code_to_events(text) if len(text) else [], - [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - pos), # Move cursor to specified position - [ - Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K - ], - ) - reader, _ = handle_all_events(events) - self.assertEqual(reader.screen, expected) - self.assertEqual(reader.pos, pos) - - test_with_text("", 0, [""]) - test_with_text("a", 0, [""]) - test_with_text("abc", 1, ["a"]) - test_with_text("abc def", 4, ["abc "]) - test_with_text("def xxx():xxx\n pass", 10, ["def xxx():", " pass"]) + cases = ( + ("", 0, [""]), + ("a", 0, [""]), + ("abc", 1, ["a"]), + ("abc def", 4, ["abc "]), + ("def xxx():xxx\n pass", 10, ["def xxx():", " pass"]), + ) + + for text, pos, expected in cases: + with self.subTest(text=text, pos=pos): + events = itertools.chain( + code_to_events(text), + [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - pos), # Move cursor to specified position + [ + Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K + ], + ) + reader, _ = handle_all_events(events) + self.assertEqual(reader.screen, expected) + self.assertEqual(reader.pos, pos) @force_colorized_test_class class TestReaderInColor(ScreenEqualMixin, TestCase): From f95e9c3525f16334c0a12e200033ed0bf85a3af2 Mon Sep 17 00:00:00 2001 From: DeepWzh Date: Mon, 12 May 2025 14:13:06 +0800 Subject: [PATCH 4/4] modify the cursor movement commands in test_reader to use \x1bOD --- Lib/test/test_pyrepl/test_reader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index c8be8844aac1ff..c6262d0ef93376 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -384,7 +384,7 @@ def test_control_w_delete_word(self): with self.subTest(text=text, before_pos=before_pos): events = itertools.chain( code_to_events(text), - [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - before_pos), # Move cursor to specified position + [Event(evt="key", data="left", raw=bytearray(b"\x1bOD"))] * (len(text) - before_pos), # Move cursor to specified position [ Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W ], @@ -407,7 +407,7 @@ def test_control_k_delete_to_eol(self): with self.subTest(text=text, pos=pos): events = itertools.chain( code_to_events(text), - [Event(evt="key", data="left", raw=bytearray(b"\x1b[D"))] * (len(text) - pos), # Move cursor to specified position + [Event(evt="key", data="left", raw=bytearray(b"\x1bOD"))] * (len(text) - pos), # Move cursor to specified position [ Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K ],