cpython/Lib/test/test_pyrepl/test_fancycompleter.py
Pablo Galindo Salgado f8293faf37
gh-130472: Remove readline-only hacks from PyREPL completions (#148161)
PyREPL was still carrying over two readline-specific tricks from the
fancy completer: a synthetic CSI prefix to influence sorting and a fake
blank completion entry to suppress readline's prefix insertion. Those
workarounds are not appropriate in PyREPL because the reader already
owns completion ordering and menu rendering, so the fake entries leaked
into the UI as real terminal attributes and empty menu cells.

Sort completion candidates in ReadlineAlikeReader by their visible text
with stripcolor(), and let the fancy completer return only real matches.
That keeps colored completions stable without emitting bogus escape
sequences, removes the empty completion slot, and adds regression tests
for both the low-level completer output and the reader integration.
2026-04-06 14:57:25 +00:00

239 lines
8.6 KiB
Python

import importlib
import os
import types
import unittest
from _colorize import ANSIColors, get_theme
from _pyrepl.completing_reader import stripcolor
from _pyrepl.fancycompleter import Completer, commonprefix
from test.support.import_helper import ready_to_import
class MockPatch:
def __init__(self):
self.original_values = {}
def setattr(self, obj, name, value):
if obj not in self.original_values:
self.original_values[obj] = {}
if name not in self.original_values[obj]:
self.original_values[obj][name] = getattr(obj, name)
setattr(obj, name, value)
def restore_all(self):
for obj, attrs in self.original_values.items():
for name, value in attrs.items():
setattr(obj, name, value)
class FancyCompleterTests(unittest.TestCase):
def setUp(self):
self.mock_patch = MockPatch()
def tearDown(self):
self.mock_patch.restore_all()
def test_commonprefix(self):
self.assertEqual(commonprefix(['isalpha', 'isdigit', 'foo']), '')
self.assertEqual(commonprefix(['isalpha', 'isdigit']), 'is')
self.assertEqual(commonprefix([]), '')
def test_complete_attribute(self):
compl = Completer({'a': None}, use_colors=False)
self.assertEqual(compl.attr_matches('a.'), ['a.__'])
matches = compl.attr_matches('a.__')
self.assertNotIn('__class__', matches)
self.assertIn('a.__class__', matches)
match = compl.attr_matches('a.__class')
self.assertEqual(len(match), 1)
self.assertTrue(match[0].startswith('a.__class__'))
def test_complete_attribute_prefix(self):
class C(object):
attr = 1
_attr = 2
__attr__attr = 3
compl = Completer({'a': C}, use_colors=False)
self.assertEqual(compl.attr_matches('a.'), ['a.attr', 'a.mro'])
self.assertEqual(
compl.attr_matches('a._'),
['a._C__attr__attr', 'a._attr'],
)
matches = compl.attr_matches('a.__')
self.assertNotIn('__class__', matches)
self.assertIn('a.__class__', matches)
match = compl.attr_matches('a.__class')
self.assertEqual(len(match), 1)
self.assertTrue(match[0].startswith('a.__class__'))
compl = Completer({'a': None}, use_colors=False)
self.assertEqual(compl.attr_matches('a._'), ['a.__'])
def test_complete_attribute_colored(self):
theme = get_theme()
compl = Completer({'a': 42}, use_colors=True)
matches = compl.attr_matches('a.__')
self.assertGreater(len(matches), 2)
expected_color = theme.fancycompleter.type
expected_part = f'{expected_color}a.__class__{ANSIColors.RESET}'
for match in matches:
if expected_part in match:
break
else:
self.assertFalse(True, matches)
self.assertNotIn(' ', matches)
def test_preserves_callable_postfix_for_single_attribute_match(self):
compl = Completer({'os': os}, use_colors=False)
self.assertEqual(compl.attr_matches('os.getpid'), ['os.getpid()'])
def test_property_method_not_called(self):
class Foo:
property_called = False
@property
def bar(self):
self.property_called = True
return 1
foo = Foo()
compl = Completer({'foo': foo}, use_colors=False)
self.assertEqual(compl.attr_matches('foo.b'), ['foo.bar'])
self.assertFalse(foo.property_called)
def test_excessive_getattr(self):
class Foo:
calls = 0
bar = ''
def __getattribute__(self, name):
if name == 'bar':
self.calls += 1
return None
return super().__getattribute__(name)
foo = Foo()
compl = Completer({'foo': foo}, use_colors=False)
self.assertEqual(compl.complete('foo.b', 0), 'foo.bar')
self.assertEqual(foo.calls, 1)
def test_uncreated_attr(self):
class Foo:
__slots__ = ('bar',)
compl = Completer({'foo': Foo()}, use_colors=False)
self.assertEqual(compl.complete('foo.', 0), 'foo.bar')
def test_module_attributes_do_not_reify_lazy_imports(self):
with ready_to_import("test_pyrepl_lazy_mod", "lazy import json\n") as (name, _):
module = importlib.import_module(name)
self.assertIs(type(module.__dict__["json"]), types.LazyImportType)
compl = Completer({name: module}, use_colors=False)
self.assertEqual(compl.attr_matches(f"{name}.j"), [f"{name}.json"])
self.assertIs(type(module.__dict__["json"]), types.LazyImportType)
def test_complete_colored_single_match(self):
"""No coloring, via commonprefix."""
compl = Completer({'foobar': 42}, use_colors=True)
matches = compl.global_matches('foob')
self.assertEqual(matches, ['foobar'])
def test_does_not_color_single_match(self):
class obj:
msgs = []
compl = Completer({'obj': obj}, use_colors=True)
matches = compl.attr_matches('obj.msgs')
self.assertEqual(matches, ['obj.msgs'])
def test_complete_global(self):
compl = Completer({'foobar': 1, 'foobazzz': 2}, use_colors=False)
self.assertEqual(compl.global_matches('foo'), ['fooba'])
matches = compl.global_matches('fooba')
self.assertEqual(set(matches), set(['foobar', 'foobazzz']))
self.assertEqual(compl.global_matches('foobaz'), ['foobazzz'])
self.assertEqual(compl.global_matches('nothing'), [])
def test_complete_global_colored(self):
theme = get_theme()
compl = Completer({'foobar': 1, 'foobazzz': 2}, use_colors=True)
self.assertEqual(compl.global_matches('foo'), ['fooba'])
matches = compl.global_matches('fooba')
int_color = theme.fancycompleter.int
self.assertEqual(matches, [
f'{int_color}foobar{ANSIColors.RESET}',
f'{int_color}foobazzz{ANSIColors.RESET}',
])
self.assertEqual(compl.global_matches('foobaz'), ['foobazzz'])
self.assertEqual(compl.global_matches('nothing'), [])
def test_colorized_match_is_stripped(self):
compl = Completer({'a': 42}, use_colors=True)
match = compl._color_for_obj('spam', 1)
self.assertEqual(stripcolor(match), 'spam')
def test_complete_with_indexer(self):
compl = Completer({'lst': [None, 2, 3]}, use_colors=False)
self.assertEqual(compl.attr_matches('lst[0].'), ['lst[0].__'])
matches = compl.attr_matches('lst[0].__')
self.assertNotIn('__class__', matches)
self.assertIn('lst[0].__class__', matches)
match = compl.attr_matches('lst[0].__class')
self.assertEqual(len(match), 1)
self.assertTrue(match[0].startswith('lst[0].__class__'))
def test_autocomplete(self):
class A:
aaa = None
abc_1 = None
abc_2 = None
abc_3 = None
bbb = None
compl = Completer({'A': A}, use_colors=False)
#
# In this case, we want to display all attributes which start with
# 'a'.
matches = compl.attr_matches('A.a')
self.assertEqual(
sorted(matches),
['A.aaa', 'A.abc_1', 'A.abc_2', 'A.abc_3'],
)
#
# If there is an actual common prefix, we return just it, so that readline
# will insert it into place
matches = compl.attr_matches('A.ab')
self.assertEqual(matches, ['A.abc_'])
#
# Finally, at the next tab, we display again all the completions
# available for this common prefix.
matches = compl.attr_matches('A.abc_')
self.assertEqual(
sorted(matches),
['A.abc_1', 'A.abc_2', 'A.abc_3'],
)
def test_complete_exception(self):
compl = Completer({}, use_colors=False)
self.assertEqual(compl.attr_matches('xxx.'), [])
def test_complete_invalid_attr(self):
compl = Completer({'str': str}, use_colors=False)
self.assertEqual(compl.attr_matches('str.xx'), [])
def test_complete_function_skipped(self):
compl = Completer({'str': str}, use_colors=False)
self.assertEqual(compl.attr_matches('str.split().'), [])
def test_unicode_in___dir__(self):
class Foo(object):
def __dir__(self):
return ['hello', 'world']
compl = Completer({'a': Foo()}, use_colors=False)
matches = compl.attr_matches('a.')
self.assertEqual(matches, ['a.hello', 'a.world'])
self.assertIs(type(matches[0]), str)
if __name__ == "__main__":
unittest.main()