From 6e80edb1f3f7b89c77f6b9f26cb6fba252d0592d Mon Sep 17 00:00:00 2001 From: Tan Long Date: Thu, 8 May 2025 23:24:48 +0800 Subject: [PATCH 1/7] Refactor `_should_auto_indent()` --- Lib/_pyrepl/readline.py | 44 +++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 560a9db192169e..46354c315c6eea 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -254,19 +254,37 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: # check if last character before "pos" is a colon, ignoring # whitespaces and comments. last_char = None - while pos > 0: - pos -= 1 - if last_char is None: - if buffer[pos] not in " \t\n#": # ignore whitespaces and comments - last_char = buffer[pos] - else: - # even if we found a non-whitespace character before - # original pos, we keep going back until newline is reached - # to make sure we ignore comments - if buffer[pos] == "\n": - break - if buffer[pos] == "#": - last_char = None + # A stack to keep track of string delimiters (quotes). Push a quote when + # entering a string, and pop it when the string ends. When the stack is + # empty, we're not inside a string. If encounter a '#' while not inside a + # string, it's a comment start; otherwise, it's just a '#' character within + # a string. + in_string: list[str] = [] + in_comment = False + i = -1 + while i < pos - 1: + i += 1 + char = buffer[i] + + # update last_char + if char == "#": + if in_string: + last_char = char # '#' inside a string is just a character + else: + in_comment = True + elif char == "\n": + # newline ends a comment + in_comment = False + elif char not in " \t" and not in_comment and not in_string: + # update last_char with non-whitespace chars outside comments and strings + last_char = char + + # update stack + if char in "\"'" and (i == 0 or buffer[i - 1] != "\\"): + if in_string and in_string[-1] == char: + in_string.pop() + else: + in_string.append(char) return last_char == ":" From e4ef49de7a0a3d0ed271e672d3b9f07b429a9006 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Thu, 8 May 2025 23:25:32 +0800 Subject: [PATCH 2/7] Add tests --- Lib/test/test_pyrepl/test_pyrepl.py | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index fc8114891d12dd..5a5cb9f67184f2 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -605,6 +605,43 @@ def test_auto_indent_ignore_comments(self): output = multiline_input(reader) self.assertEqual(output, output_code) + def test_auto_indent_noncomment_hash(self): + # fmt: off + events = code_to_events( + "if ' ' == '#':\n" + "pass\n\n" + ) + + output_code = ( + "if ' ' == '#':\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + + def test_auto_indent_multiline_string(self): + # fmt: off + events = code_to_events( + "s = '''\n" + "Note:\n" + "'''\n\n" + ) + + output_code = ( + "s = '''\n" + "Note:\n" + "'''" + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + class TestPyReplOutput(ScreenEqualMixin, TestCase): def prepare_reader(self, events): From 2113f88222152a305ea67a1324f1c484907f9899 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sat, 10 May 2025 04:55:03 +0800 Subject: [PATCH 3/7] Don't indent if cursor line is already indented --- Lib/_pyrepl/readline.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 46354c315c6eea..2e943a33f0d0ce 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -261,6 +261,11 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: # a string. in_string: list[str] = [] in_comment = False + char_line_indent_start = None + char_line_indent = 0 + lastchar_line_indent = 0 + cursor_line_indent = 0 + i = -1 while i < pos - 1: i += 1 @@ -275,9 +280,18 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: elif char == "\n": # newline ends a comment in_comment = False - elif char not in " \t" and not in_comment and not in_string: - # update last_char with non-whitespace chars outside comments and strings - last_char = char + if i < pos - 1 and buffer[i + 1] in " \t": + char_line_indent_start = i + 1 + else: + char_line_indent_start = None # clear last line's line_indent_start + char_line_indent = 0 + elif char not in " \t": + if char_line_indent_start is not None: + char_line_indent = i - char_line_indent_start + if not in_comment and not in_string: + # update last_char with non-whitespace chars outside comments and strings + last_char = char + lastchar_line_indent = char_line_indent # update stack if char in "\"'" and (i == 0 or buffer[i - 1] != "\\"): @@ -285,7 +299,8 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: in_string.pop() else: in_string.append(char) - return last_char == ":" + cursor_line_indent = char_line_indent + return last_char == ":" and cursor_line_indent <= lastchar_line_indent class maybe_accept(commands.Command): From dee83020f76a1684629a48cb2e88229c542f742f Mon Sep 17 00:00:00 2001 From: Tan Long Date: Fri, 9 May 2025 22:12:48 +0800 Subject: [PATCH 4/7] Add test --- Lib/test/test_pyrepl/test_pyrepl.py | 42 ++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 5a5cb9f67184f2..ec5a30945f95e5 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -572,6 +572,46 @@ def test_auto_indent_with_comment(self): output = multiline_input(reader) self.assertEqual(output, output_code) + # fmt: off + events = code_to_events( + "def f():\n" + "# foo\n" + "pass\n\n" + ) + + output_code = ( + "def f():\n" + " # foo\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + + # fmt: off + events = itertools.chain( + code_to_events("def f():\n"), + [ + Event(evt="key", data="backspace", raw=b"\x08"), + ], + code_to_events("# foo\npass\n\n") + ) + + output_code = ( + "def f():\n" + "# foo\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + def test_auto_indent_with_multicomment(self): # fmt: off events = code_to_events( @@ -605,7 +645,7 @@ def test_auto_indent_ignore_comments(self): output = multiline_input(reader) self.assertEqual(output, output_code) - def test_auto_indent_noncomment_hash(self): + def test_auto_indent_hashtag(self): # fmt: off events = code_to_events( "if ' ' == '#':\n" From be762c5df1d780f6a5c320b605db0d8d6a5bf677 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Tue, 28 Oct 2025 23:10:23 +0800 Subject: [PATCH 5/7] Add test_dont_indent_already_indented --- Lib/_pyrepl/readline.py | 21 ++++++++++----------- Lib/test/test_pyrepl/test_pyrepl.py | 24 ++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 7ba1d8d4d3fa14..b8c16748307449 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -256,12 +256,11 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: # check if last character before "pos" is a colon, ignoring # whitespaces and comments. last_char = None - # A stack to keep track of string delimiters (quotes). Push a quote when - # entering a string, and pop it when the string ends. When the stack is - # empty, we're not inside a string. If encounter a '#' while not inside a - # string, it's a comment start; otherwise, it's just a '#' character within - # a string. - in_string: list[str] = [] + # A stack to keep track of string delimiters. Push a quote when entering a + # string, pop it when the string ends. If the stack is empty, we're not + # inside a string. When see a '#', it's a comment start if we're not inside + # a string; otherwise, it's just a '#' character within a string. + str_delims: list[str] = [] in_comment = False char_line_indent_start = None char_line_indent = 0 @@ -275,7 +274,7 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: # update last_char if char == "#": - if in_string: + if str_delims: last_char = char # '#' inside a string is just a character else: in_comment = True @@ -290,17 +289,17 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: elif char not in " \t": if char_line_indent_start is not None: char_line_indent = i - char_line_indent_start - if not in_comment and not in_string: + if not in_comment and not str_delims: # update last_char with non-whitespace chars outside comments and strings last_char = char lastchar_line_indent = char_line_indent # update stack if char in "\"'" and (i == 0 or buffer[i - 1] != "\\"): - if in_string and in_string[-1] == char: - in_string.pop() + if str_delims and str_delims[-1] == char: + str_delims.pop() else: - in_string.append(char) + str_delims.append(char) cursor_line_indent = char_line_indent return last_char == ":" and cursor_line_indent <= lastchar_line_indent diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 09a069f29ad057..2a135aa08f5f43 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -666,7 +666,7 @@ def test_auto_indent_ignore_comments(self): output = multiline_input(reader) self.assertEqual(output, output_code) - def test_auto_indent_hashtag(self): + def test_dont_indent_hashtag(self): # fmt: off events = code_to_events( "if ' ' == '#':\n" @@ -684,7 +684,7 @@ def test_auto_indent_hashtag(self): output = multiline_input(reader) self.assertEqual(output, output_code) - def test_auto_indent_multiline_string(self): + def test_dont_indent_in_multiline_string(self): # fmt: off events = code_to_events( "s = '''\n" @@ -703,6 +703,26 @@ def test_auto_indent_multiline_string(self): output = multiline_input(reader) self.assertEqual(output, output_code) + def test_dont_indent_already_indented(self): + # fmt: off + events = code_to_events( + "def f():\n" + "# foo\n" + "pass\n\n" + ) + + output_code = ( + "def f():\n" + " # foo\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + class TestPyReplOutput(ScreenEqualMixin, TestCase): def prepare_reader(self, events): From 20603d31cb0be2aeac9838e619fc300c27138dff Mon Sep 17 00:00:00 2001 From: Tan Long Date: Tue, 28 Oct 2025 23:14:29 +0800 Subject: [PATCH 6/7] blurb --- .../next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst diff --git a/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst b/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst new file mode 100644 index 00000000000000..33d1591cc85b42 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst @@ -0,0 +1 @@ +Enhance auto-indent in :mod:!`_pyrepl`. From df35507bad47b4546962c7d4e2371deeae584423 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Tue, 28 Oct 2025 23:57:25 +0800 Subject: [PATCH 7/7] fix Sphinx syntax error --- .../next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst b/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst index 33d1591cc85b42..fd2bb3f0d98df6 100644 --- a/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst +++ b/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst @@ -1 +1 @@ -Enhance auto-indent in :mod:!`_pyrepl`. +Enhance auto-indent in :mod:`!_pyrepl`.