From 4646a491230a8a4b791bc9c524a1708df8713020 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 1 Oct 2025 12:55:31 +0800 Subject: [PATCH 1/4] fix: default REPL where prompts containing newlines would be dup Signed-off-by: yihong0618 --- Lib/_pyrepl/reader.py | 6 ++--- Lib/test/test_pyrepl/test_reader.py | 27 +++++++++++++++++++ ...-10-01-12-42-59.gh-issue-127068.4pMR9v.rst | 2 ++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-10-01-12-42-59.gh-issue-127068.4pMR9v.rst diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 0ebd9162eca4bb..9a7d95aa8c362b 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -297,13 +297,13 @@ def calc_screen(self) -> list[str]: if self.last_refresh_cache.valid(self): offset, num_common_lines = self.last_refresh_cache.get_cached_location(self) - screen = self.last_refresh_cache.screen + screen = self.last_refresh_cache.screen.copy() del screen[num_common_lines:] - screeninfo = self.last_refresh_cache.screeninfo + screeninfo = self.last_refresh_cache.screeninfo.copy() del screeninfo[num_common_lines:] - last_refresh_line_end_offsets = self.last_refresh_cache.line_end_offsets + last_refresh_line_end_offsets = self.last_refresh_cache.line_end_offsets.copy() del last_refresh_line_end_offsets[num_common_lines:] pos = self.pos diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index b1b6ae16a1e592..40b6379f3fc175 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -358,6 +358,33 @@ def test_setpos_from_xy_for_non_printing_char(self): reader.setpos_from_xy(8, 0) self.assertEqual(reader.pos, 7) + def test_prompt_with_newline_no_duplicate(self): + # gh-127068: prompts with newlines should not duplicate + # The bug is that screen = cache.screen creates a reference, not a copy, + from unittest.mock import MagicMock + + console = MagicMock() + console.height = 100 + console.width = 80 + + reader = prepare_reader(console) + + screen = reader.calc_screen() + reader.last_refresh_cache.update_cache(reader, screen, reader.screeninfo) + + cache_screen_before = reader.last_refresh_cache.screen + original_cache_content = cache_screen_before.copy() + + reader.insert("h") + screen = reader.calc_screen() + + self.assertEqual( + cache_screen_before, original_cache_content, + f"Cache was modified during calc_screen! " + f"Original: {original_cache_content}, Now: {cache_screen_before}" + ) + + @force_colorized_test_class class TestReaderInColor(ScreenEqualMixin, TestCase): def test_syntax_highlighting_basic(self): diff --git a/Misc/NEWS.d/next/Library/2025-10-01-12-42-59.gh-issue-127068.4pMR9v.rst b/Misc/NEWS.d/next/Library/2025-10-01-12-42-59.gh-issue-127068.4pMR9v.rst new file mode 100644 index 00000000000000..5bd50287823de2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-01-12-42-59.gh-issue-127068.4pMR9v.rst @@ -0,0 +1,2 @@ +Fix an issue in default REPL where prompts containing newlines would be duplicated +on each keypress. From 618d5d95a633a2b0a3cb9a6c0c3f4bbc8bb95658 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 1 Oct 2025 18:47:57 +0800 Subject: [PATCH 2/4] fix: address comments Signed-off-by: yihong0618 Co-authored-by: Keming --- Lib/_pyrepl/reader.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 9a7d95aa8c362b..e6def00ed62f04 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -303,9 +303,6 @@ def calc_screen(self) -> list[str]: screeninfo = self.last_refresh_cache.screeninfo.copy() del screeninfo[num_common_lines:] - last_refresh_line_end_offsets = self.last_refresh_cache.line_end_offsets.copy() - del last_refresh_line_end_offsets[num_common_lines:] - pos = self.pos pos -= offset @@ -338,7 +335,6 @@ def calc_screen(self) -> list[str]: prompt = self.get_prompt(ln, line_len >= pos >= 0) while "\n" in prompt: pre_prompt, _, prompt = prompt.partition("\n") - last_refresh_line_end_offsets.append(offset) screen.append(pre_prompt) screeninfo.append((0, [])) pos -= line_len + 1 @@ -347,7 +343,6 @@ def calc_screen(self) -> list[str]: wrapcount = (sum(char_widths) + prompt_len) // self.console.width if wrapcount == 0 or not char_widths: offset += line_len + 1 # Takes all of the line plus the newline - last_refresh_line_end_offsets.append(offset) screen.append(prompt + "".join(chars)) screeninfo.append((prompt_len, char_widths)) else: @@ -369,7 +364,6 @@ def calc_screen(self) -> list[str]: offset += index_to_wrap_before + 1 # Takes the newline post = "" after = [] - last_refresh_line_end_offsets.append(offset) render = pre + "".join(chars[:index_to_wrap_before]) + post render_widths = char_widths[:index_to_wrap_before] + after screen.append(render) From 49b84a62f9ab32438d04f6fa5d21db66ec7d8c60 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 1 Oct 2025 21:28:13 +0800 Subject: [PATCH 3/4] fix: drop test not right assert and add commnet Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/test_windows_console.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_windows_console.py b/Lib/test/test_pyrepl/test_windows_console.py index f9607e02c604ff..274201d80a2888 100644 --- a/Lib/test/test_pyrepl/test_windows_console.py +++ b/Lib/test/test_pyrepl/test_windows_console.py @@ -354,8 +354,7 @@ def test_multiline_ctrl_z(self): Event(evt="key", data='\x1a', raw=bytearray(b'\x1a')), ], ) - reader, con = self.handle_events_narrow(events) - self.assertEqual(reader.cxy, (2, 3)) + reader, con = self.handle_events_narrow(events) # make sure can handele it. con.restore() From ae6875fb89064ea5c6f12e3122bb780c7330d875 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Thu, 2 Oct 2025 13:09:26 +0800 Subject: [PATCH 4/4] fix: address commnets Signed-off-by: yihong0618 --- Lib/_pyrepl/reader.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index e6def00ed62f04..e79e9ff98d9458 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -297,11 +297,9 @@ def calc_screen(self) -> list[str]: if self.last_refresh_cache.valid(self): offset, num_common_lines = self.last_refresh_cache.get_cached_location(self) - screen = self.last_refresh_cache.screen.copy() - del screen[num_common_lines:] - - screeninfo = self.last_refresh_cache.screeninfo.copy() - del screeninfo[num_common_lines:] + # Use slicing instead of del to avoid modifying the cached lists. + screen = self.last_refresh_cache.screen[:num_common_lines] + screeninfo = self.last_refresh_cache.screeninfo[:num_common_lines] pos = self.pos pos -= offset