From 8466abcc81ad852f444a0d324b08867d70e92f0c Mon Sep 17 00:00:00 2001 From: TTW Date: Thu, 9 Oct 2025 11:44:32 +0800 Subject: [PATCH 1/8] fix(Lib/rlcompleter): prevent suggesting non-existent attributes --- Lib/rlcompleter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/rlcompleter.py b/Lib/rlcompleter.py index 23eb0020f42e8a0..c12aaf8fbb004c0 100644 --- a/Lib/rlcompleter.py +++ b/Lib/rlcompleter.py @@ -191,7 +191,8 @@ def attr_matches(self, text): if (value := getattr(thisobject, word, None)) is not None: matches.append(self._callable_postfix(value, match)) else: - matches.append(match) + if hasattr(thisobject, word): + matches.append(match) if matches or not noprefix: break if noprefix == '_': From 1655dc62578c40daa5c7f947f707d4787b8d035f Mon Sep 17 00:00:00 2001 From: TTW Date: Thu, 9 Oct 2025 12:14:26 +0800 Subject: [PATCH 2/8] docs(NEWS.d): gh-issue-139819 --- .../Library/2025-10-09-12-13-29.gh-issue-139819.YxUDyH.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-10-09-12-13-29.gh-issue-139819.YxUDyH.rst diff --git a/Misc/NEWS.d/next/Library/2025-10-09-12-13-29.gh-issue-139819.YxUDyH.rst b/Misc/NEWS.d/next/Library/2025-10-09-12-13-29.gh-issue-139819.YxUDyH.rst new file mode 100644 index 000000000000000..ab3a8c6ed16ce4a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-09-12-13-29.gh-issue-139819.YxUDyH.rst @@ -0,0 +1,3 @@ +:mod:`rlcompleter`: Avoid suggesting attributes that are not accessible on +instances (e.g., Enum members showing ``__name__``). Patch by Peter +(ttw225). From 394432e72c9e6787750638d512bdb7604a1468e5 Mon Sep 17 00:00:00 2001 From: TTW Date: Thu, 9 Oct 2025 18:05:13 +0800 Subject: [PATCH 3/8] refactor(Lib/rlcompleter.py): Simplify conditional expression --- Lib/rlcompleter.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/rlcompleter.py b/Lib/rlcompleter.py index c12aaf8fbb004c0..1a0c8ef000c0cd9 100644 --- a/Lib/rlcompleter.py +++ b/Lib/rlcompleter.py @@ -190,9 +190,8 @@ def attr_matches(self, text): continue if (value := getattr(thisobject, word, None)) is not None: matches.append(self._callable_postfix(value, match)) - else: - if hasattr(thisobject, word): - matches.append(match) + elif hasattr(thisobject, word): + matches.append(match) if matches or not noprefix: break if noprefix == '_': From d567f83898f01ec8944fabd44c515a13c355678f Mon Sep 17 00:00:00 2001 From: TTW Date: Thu, 9 Oct 2025 18:27:04 +0800 Subject: [PATCH 4/8] test(test_rlcompleter): adjust test_excessive_getattr call count expectations --- Lib/test/test_rlcompleter.py | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_rlcompleter.py b/Lib/test/test_rlcompleter.py index a8914953ce9eb48..81e9feec8173e71 100644 --- a/Lib/test/test_rlcompleter.py +++ b/Lib/test/test_rlcompleter.py @@ -100,25 +100,44 @@ def create_expected_for_none(): if x.startswith('s')]) def test_excessive_getattr(self): - """Ensure getattr() is invoked no more than once per attribute""" + """Ensure getattr() is invoked appropriately per attribute""" # note the special case for @property methods below; that is why # we use __dir__ and __getattr__ in class Foo to create a "magic" # class attribute 'bar'. This forces `getattr` to call __getattr__ # (which is doesn't necessarily do). - class Foo: + # Test 1: Attribute returns None + class FooReturnsNone: calls = 0 - bar = '' + bar = None def __getattribute__(self, name): if name == 'bar': self.calls += 1 return None return super().__getattribute__(name) - f = Foo() - completer = rlcompleter.Completer(dict(f=f)) - self.assertEqual(completer.complete('f.b', 0), 'f.bar') - self.assertEqual(f.calls, 1) + f1 = FooReturnsNone() + completer1 = rlcompleter.Completer(dict(f=f1)) + self.assertEqual(completer1.complete('f.b', 0), 'f.bar') + # With the hasattr() check, getattr() is called twice: + # once in getattr(thisobject, word, None) and once in hasattr(thisobject, word) + self.assertEqual(f1.calls, 2) + + # Test 2: Attribute returns non-None value + class FooReturnsValue: + calls = 0 + bar = '' + def __getattribute__(self, name): + if name == 'bar': + self.calls += 1 + return '' + return super().__getattribute__(name) + + f2 = FooReturnsValue() + completer2 = rlcompleter.Completer(dict(f=f2)) + self.assertEqual(completer2.complete('f.b', 0), 'f.bar') + # getattr() only called once in getattr(thisobject, word, None) + self.assertEqual(f2.calls, 1) def test_property_method_not_called(self): class Foo: From ad6ff8eba4e18df69a5f43c920e96550a34da24d Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 10 Oct 2025 00:31:05 +0800 Subject: [PATCH 5/8] fix(Lib/rlcompleter): handle unassigned __slots__ attributes in completion --- Lib/rlcompleter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/rlcompleter.py b/Lib/rlcompleter.py index 1a0c8ef000c0cd9..7a5ffb6c19b498f 100644 --- a/Lib/rlcompleter.py +++ b/Lib/rlcompleter.py @@ -192,6 +192,8 @@ def attr_matches(self, text): matches.append(self._callable_postfix(value, match)) elif hasattr(thisobject, word): matches.append(match) + elif word in getattr(type(thisobject), '__slots__', ()): + matches.append(match) if matches or not noprefix: break if noprefix == '_': From 8d98babad7415132abb3f6144b08257e0ced3985 Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 10 Oct 2025 00:53:49 +0800 Subject: [PATCH 6/8] refactor(Lib/rlcompleter): use sentinel to properly handle None-valued attributes --- Lib/rlcompleter.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/rlcompleter.py b/Lib/rlcompleter.py index 7a5ffb6c19b498f..521f41a869de387 100644 --- a/Lib/rlcompleter.py +++ b/Lib/rlcompleter.py @@ -39,6 +39,9 @@ __all__ = ["Completer"] +# Sentinel object to distinguish "missing" from "present but None" +_SENTINEL = object() + class Completer: def __init__(self, namespace = None): """Create a new completer for the command line. @@ -188,10 +191,8 @@ def attr_matches(self, text): # property method, which is not desirable. matches.append(match) continue - if (value := getattr(thisobject, word, None)) is not None: + if (value := getattr(thisobject, word, _SENTINEL)) is not _SENTINEL: matches.append(self._callable_postfix(value, match)) - elif hasattr(thisobject, word): - matches.append(match) elif word in getattr(type(thisobject), '__slots__', ()): matches.append(match) if matches or not noprefix: From 14bd61e61fb972a958df49f1c5d2deebcae7c99f Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 10 Oct 2025 00:55:45 +0800 Subject: [PATCH 7/8] test(test_rlcompleter): update test_excessive_getattr for sentinel approach --- Lib/test/test_rlcompleter.py | 43 +++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_rlcompleter.py b/Lib/test/test_rlcompleter.py index 81e9feec8173e71..751983bcd259c43 100644 --- a/Lib/test/test_rlcompleter.py +++ b/Lib/test/test_rlcompleter.py @@ -100,7 +100,7 @@ def create_expected_for_none(): if x.startswith('s')]) def test_excessive_getattr(self): - """Ensure getattr() is invoked appropriately per attribute""" + """Ensure getattr() is invoked no more than once per attribute""" # note the special case for @property methods below; that is why # we use __dir__ and __getattr__ in class Foo to create a "magic" @@ -119,9 +119,7 @@ def __getattribute__(self, name): f1 = FooReturnsNone() completer1 = rlcompleter.Completer(dict(f=f1)) self.assertEqual(completer1.complete('f.b', 0), 'f.bar') - # With the hasattr() check, getattr() is called twice: - # once in getattr(thisobject, word, None) and once in hasattr(thisobject, word) - self.assertEqual(f1.calls, 2) + self.assertEqual(f1.calls, 1) # Test 2: Attribute returns non-None value class FooReturnsValue: @@ -136,7 +134,6 @@ def __getattribute__(self, name): f2 = FooReturnsValue() completer2 = rlcompleter.Completer(dict(f=f2)) self.assertEqual(completer2.complete('f.b', 0), 'f.bar') - # getattr() only called once in getattr(thisobject, word, None) self.assertEqual(f2.calls, 1) def test_property_method_not_called(self): @@ -163,6 +160,42 @@ class Foo: completer = rlcompleter.Completer(dict(f=Foo())) self.assertEqual(completer.complete('f.', 0), 'f.bar') + def test_enum_member_completion(self): + """Test that Enum members don't show non-existent attributes""" + from enum import Enum + + class Color(Enum): + RED = 1 + GREEN = 2 + BLUE = 3 + + completer = rlcompleter.Completer() + + # Test using complete method + i = 0 + all_matches = [] + while True: + match = completer.complete('Color.RED.__', i) + if match is None: + break + all_matches.append(match) + i += 1 + + # If no matches found, skip the test (environment issue) + if not all_matches: + self.skipTest("No matches found in test environment") + + # These should NOT be in the matches + self.assertNotIn('Color.RED.__name__', all_matches) + self.assertNotIn('Color.RED.__qualname__', all_matches) + self.assertNotIn('Color.RED.__members__', all_matches) + self.assertNotIn('Color.RED.__abstractmethods__', all_matches) + + # But these should be in the matches (they exist on the instance) + self.assertIn('Color.RED.__class__', all_matches) + self.assertIn('Color.RED.__doc__', all_matches) + self.assertIn('Color.RED.__eq__', all_matches) + @unittest.mock.patch('rlcompleter._readline_available', False) def test_complete(self): completer = rlcompleter.Completer() From 76e32a3482543f1b2d67f158e44ab93bb85a6d30 Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 10 Oct 2025 01:09:04 +0800 Subject: [PATCH 8/8] test(test_rlcompleter): remove unintended test_enum_member_completion in rlcompleter tests --- Lib/test/test_rlcompleter.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/Lib/test/test_rlcompleter.py b/Lib/test/test_rlcompleter.py index 751983bcd259c43..283a3b4e9d3baf8 100644 --- a/Lib/test/test_rlcompleter.py +++ b/Lib/test/test_rlcompleter.py @@ -160,41 +160,6 @@ class Foo: completer = rlcompleter.Completer(dict(f=Foo())) self.assertEqual(completer.complete('f.', 0), 'f.bar') - def test_enum_member_completion(self): - """Test that Enum members don't show non-existent attributes""" - from enum import Enum - - class Color(Enum): - RED = 1 - GREEN = 2 - BLUE = 3 - - completer = rlcompleter.Completer() - - # Test using complete method - i = 0 - all_matches = [] - while True: - match = completer.complete('Color.RED.__', i) - if match is None: - break - all_matches.append(match) - i += 1 - - # If no matches found, skip the test (environment issue) - if not all_matches: - self.skipTest("No matches found in test environment") - - # These should NOT be in the matches - self.assertNotIn('Color.RED.__name__', all_matches) - self.assertNotIn('Color.RED.__qualname__', all_matches) - self.assertNotIn('Color.RED.__members__', all_matches) - self.assertNotIn('Color.RED.__abstractmethods__', all_matches) - - # But these should be in the matches (they exist on the instance) - self.assertIn('Color.RED.__class__', all_matches) - self.assertIn('Color.RED.__doc__', all_matches) - self.assertIn('Color.RED.__eq__', all_matches) @unittest.mock.patch('rlcompleter._readline_available', False) def test_complete(self):