From 678eefd3fa83f9dc6b81180ae48ca08faf4a773b Mon Sep 17 00:00:00 2001 From: Imgyu Kim Date: Fri, 20 Mar 2026 00:28:01 +0900 Subject: [PATCH 1/2] Fix ctrl-w (unix-word-rubout) to use whitespace word boundaries (#146044) The unix-word-rubout command (ctrl-w) was using syntax_table-based word boundaries (bow()), which treats punctuation as word separators. This differs from bash/readline's unix-word-rubout which uses only whitespace as word boundaries. Add bow_whitespace() method that uses whitespace-only boundaries, and use it in unix_word_rubout instead of bow(). The existing bow() method (used by backward-kill-word/M-Backspace) is unchanged. Example: with 'foo.bar baz' and cursor at end: - Before (bow): ctrl-w deletes 'baz', then 'bar', then 'foo' - After (bow_whitespace): ctrl-w deletes 'baz', then 'foo.bar' --- Lib/_pyrepl/commands.py | 2 +- Lib/_pyrepl/reader.py | 16 +++++++++++ Lib/test/test_pyrepl/test_reader.py | 44 +++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 10127e58897a58..e603dd00d8ba49 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -169,7 +169,7 @@ class unix_word_rubout(KillCommand): def do(self) -> None: r = self.reader for i in range(r.get_arg()): - self.kill_range(r.bow(), r.pos) + self.kill_range(r.bow_whitespace(), r.pos) class kill_word(KillCommand): diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 9ab92f64d1ef63..27838834980478 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -417,6 +417,22 @@ def bow(self, p: int | None = None) -> int: p -= 1 return p + 1 + def bow_whitespace(self, p: int | None = None) -> int: + """Return the 0-based index of the whitespace-delimited word break + preceding p most immediately. + + p defaults to self.pos; only whitespace is considered a word + boundary, matching the behavior of unix-word-rubout in bash/readline.""" + if p is None: + p = self.pos + b = self.buffer + p -= 1 + while p >= 0 and b[p] in (" ", "\n"): + p -= 1 + while p >= 0 and b[p] not in (" ", "\n"): + p -= 1 + return p + 1 + def eow(self, p: int | None = None) -> int: """Return the 0-based index of the word break following p most immediately. diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index b1b6ae16a1e592..c9d6184135378d 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -558,3 +558,47 @@ def test_control_characters(self): reader, _ = handle_all_events(events) self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) + + +class TestBowWhitespace(TestCase): + def test_bow_whitespace_stops_at_whitespace(self): + # GH#146044 + # unix-word-rubout (ctrl-w) should use whitespace boundaries, + # not punctuation boundaries like bow() does + reader = prepare_reader(prepare_console()) + reader.buffer = list("foo.bar baz") + reader.pos = len(reader.buffer) # cursor at end + + # bow_whitespace from end should jump to start of "baz" + result = reader.bow_whitespace() + self.assertEqual(result, 8) # index of 'b' in "baz" + + def test_bow_whitespace_includes_punctuation_in_word(self): + # GH#146044 + reader = prepare_reader(prepare_console()) + reader.buffer = list("foo.bar(baz) qux") + reader.pos = 12 # cursor after ")" + + # bow_whitespace should treat "foo.bar(baz)" as one word + result = reader.bow_whitespace() + self.assertEqual(result, 0) + + def test_bow_stops_at_punctuation(self): + # Verify existing bow() still uses syntax_table (punctuation boundary) + reader = prepare_reader(prepare_console()) + reader.buffer = list("foo.bar baz") + reader.pos = len(reader.buffer) + + result = reader.bow() + self.assertEqual(result, 8) # same — "baz" is all word chars + + def test_bow_vs_bow_whitespace_difference(self): + # The key difference: bow() stops at '.', bow_whitespace() does not + reader = prepare_reader(prepare_console()) + reader.buffer = list("foo.bar") + reader.pos = len(reader.buffer) + + # bow() stops at '.' → returns index of 'b' in "bar" + self.assertEqual(reader.bow(), 4) + # bow_whitespace() treats entire "foo.bar" as one word + self.assertEqual(reader.bow_whitespace(), 0) From f7f0f0b52af1df5ad99837cda54f0cefc8bf6aa5 Mon Sep 17 00:00:00 2001 From: Imgyu Kim Date: Fri, 20 Mar 2026 00:47:20 +0900 Subject: [PATCH 2/2] gh-145865: Fix CoverageResults.__init__ to copy the counts dict Fix typo where self.counter was assigned instead of self.counts, causing the counts dict to not be copied. This made CoverageResults.update() mutate the caller's original dict. The fix changes self.counter = self.counts.copy() to self.counts = self.counts.copy(), matching the pattern used for calledfuncs and callers on the following lines. Also removes the dead self.counter attribute which was never read anywhere in the codebase. --- Lib/test/test_trace.py | 28 ++++++++++++++++++++++++++++ Lib/trace.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_trace.py b/Lib/test/test_trace.py index 19eee19bdea6d5..0f882cae7dc6aa 100644 --- a/Lib/test/test_trace.py +++ b/Lib/test/test_trace.py @@ -590,5 +590,33 @@ def test_no_source_file(self): self.assertIn(f"{filename}({firstlineno + 4})", out[4]) +class TestCoverageResultsInit(unittest.TestCase): + def test_counts_dict_is_copied(self): + # gh-145865: CoverageResults.__init__ should copy the counts dict + # to avoid mutating the caller's dict on update() + from trace import CoverageResults + + counts = {} + cr = CoverageResults(counts=counts) + cr.update(CoverageResults(counts={("file.py", 1): 5})) + self.assertEqual(counts, {}) # original must not be mutated + + def test_calledfuncs_dict_is_copied(self): + from trace import CoverageResults + + calledfuncs = {} + cr = CoverageResults(calledfuncs=calledfuncs) + cr.update(CoverageResults(calledfuncs={("file.py", "mod", "func"): 1})) + self.assertEqual(calledfuncs, {}) + + def test_callers_dict_is_copied(self): + from trace import CoverageResults + + callers = {} + cr = CoverageResults(callers=callers) + cr.update(CoverageResults(callers={(("a.py", "m", "f"), ("b.py", "m", "g")): 1})) + self.assertEqual(callers, {}) + + if __name__ == '__main__': unittest.main() diff --git a/Lib/trace.py b/Lib/trace.py index cd3a6d30661da3..15894921d3e434 100644 --- a/Lib/trace.py +++ b/Lib/trace.py @@ -155,7 +155,7 @@ def __init__(self, counts=None, calledfuncs=None, infile=None, self.counts = counts if self.counts is None: self.counts = {} - self.counter = self.counts.copy() # map (filename, lineno) to count + self.counts = self.counts.copy() # map (filename, lineno) to count self.calledfuncs = calledfuncs if self.calledfuncs is None: self.calledfuncs = {}