From 89dde8f11d9a3e09100b1b94da0e8fa6a41f8a6e Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 21:30:33 +0200 Subject: [PATCH 1/7] Extract safe_getattr into a separate function --- Lib/_pyrepl/fancycompleter.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 7a639afd74ef3c..80cae216b4e0ff 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -8,6 +8,19 @@ import keyword import types + +def safe_getattr(obj, name): + # Mirror rlcompleter's safeguards so completion does not + # call properties or reify lazy module attributes. + if isinstance(getattr(type(obj), name, None), property): + return None + if (isinstance(obj, types.ModuleType) + and isinstance(obj.__dict__.get(name), types.LazyImportType) + ): + return obj.__dict__.get(name) + return getattr(obj, name, None) + + class Completer(rlcompleter.Completer): """ When doing something like a.b., keep the full a.b.attr completion @@ -143,21 +156,7 @@ def _attr_matches(self, text): word[:n] == attr and not (noprefix and word[:n+1] == noprefix) ): - # Mirror rlcompleter's safeguards so completion does not - # call properties or reify lazy module attributes. - if isinstance(getattr(type(thisobject), word, None), property): - value = None - elif ( - isinstance(thisobject, types.ModuleType) - and isinstance( - thisobject.__dict__.get(word), - types.LazyImportType, - ) - ): - value = thisobject.__dict__.get(word) - else: - value = getattr(thisobject, word, None) - + value = safe_getattr(thisobject, word) names.append(word) values.append(value) if names or not noprefix: From 2b05c476b73f429df88ec59bf77aa918f1a93d9b Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 21:36:38 +0200 Subject: [PATCH 2/7] Extract colorize methods into reusable functions --- Lib/_pyrepl/fancycompleter.py | 39 ++++++++++++--------- Lib/test/test_pyrepl/test_fancycompleter.py | 8 ++--- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 80cae216b4e0ff..352e5f31b2c2df 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -21,6 +21,27 @@ def safe_getattr(obj, name): return getattr(obj, name, None) +def colorize_matches(names, values, theme): + return [ + _color_for_obj(name, obj, theme) + for name, obj in zip(names, values) + ] + +def _color_for_obj(name, value, theme): + t = type(value) + color = _color_by_type(t, theme) + return f"{color}{name}{ANSIColors.RESET}" + + +def _color_by_type(t, theme): + typename = t.__name__ + # this is needed e.g. to turn method-wrapper into method_wrapper, + # because if we want _colorize.FancyCompleter to be "dataclassable" + # our keys need to be valid identifiers. + typename = typename.replace('-', '_').replace('.', '_') + return getattr(theme.fancycompleter, typename, ANSIColors.RESET) + + class Completer(rlcompleter.Completer): """ When doing something like a.b., keep the full a.b.attr completion @@ -169,23 +190,7 @@ def _attr_matches(self, text): return expr, attr, names, values def colorize_matches(self, names, values): - return [ - self._color_for_obj(name, obj) - for name, obj in zip(names, values) - ] - - def _color_for_obj(self, name, value): - t = type(value) - color = self._color_by_type(t) - return f"{color}{name}{ANSIColors.RESET}" - - def _color_by_type(self, t): - typename = t.__name__ - # this is needed e.g. to turn method-wrapper into method_wrapper, - # because if we want _colorize.FancyCompleter to be "dataclassable" - # our keys need to be valid identifiers. - typename = typename.replace('-', '_').replace('.', '_') - return getattr(self.theme.fancycompleter, typename, ANSIColors.RESET) + return colorize_matches(names, values, self.theme) def commonprefix(names): diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index d2646cd3050428..fad0f824e1866c 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -5,7 +5,7 @@ from _colorize import ANSIColors, get_theme from _pyrepl.completing_reader import stripcolor -from _pyrepl.fancycompleter import Completer, commonprefix +from _pyrepl.fancycompleter import Completer, commonprefix, _color_for_obj from test.support.import_helper import ready_to_import class MockPatch: @@ -167,9 +167,9 @@ def test_complete_global_colored(self): 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) + def test_large_color_sort_prefix_is_stripped(self): + theme = get_theme() + match = _color_for_obj('spam', 1, theme) self.assertEqual(stripcolor(match), 'spam') def test_complete_with_indexer(self): From 8e75eb6e47165a373e6531f37a7cb22e0baf35e1 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 22:20:40 +0200 Subject: [PATCH 3/7] Colorize import completions - Make module completer return both names and values (dummy `sys` module in case of module completions) - Colorize completions using `colorize_matches` from FancyCompleter --- Lib/_pyrepl/_module_completer.py | 61 +++++++++++++++++++++-------- Lib/_pyrepl/readline.py | 22 ++++++++--- Lib/test/test_pyrepl/test_pyrepl.py | 2 +- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index a22b0297b24ea0..f1c07c25dede73 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from itertools import chain from tokenize import TokenInfo +from .fancycompleter import safe_getattr TYPE_CHECKING = False @@ -71,7 +72,7 @@ def __init__(self, namespace: Mapping[str, Any] | None = None) -> None: self._curr_sys_path: list[str] = sys.path[:] self._stdlib_path = os.path.dirname(importlib.__path__[0]) - def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None: + def get_completions(self, line: str) -> tuple[list[str], list[Any], CompletionAction | None] | None: """Return the next possible import completions for 'line'. For attributes completion, if the module to complete from is not @@ -86,26 +87,40 @@ def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None except Exception: # Some unexpected error occurred, make it look like # no completions are available - return [], None + return [], [], None - def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]: + def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], list[Any], CompletionAction | None]: if from_name is None: # import x.y.z assert name is not None path, prefix = self.get_path_and_prefix(name) modules = self.find_modules(path, prefix) - return [self.format_completion(path, module) for module in modules], None + names = [self.format_completion(path, module) for module in modules] + # These are always modules, use dummy values to get the right color + values = [sys] * len(names) + return names, values, None if name is None: # from x.y.z path, prefix = self.get_path_and_prefix(from_name) modules = self.find_modules(path, prefix) - return [self.format_completion(path, module) for module in modules], None + names = [self.format_completion(path, module) for module in modules] + # These are always modules, use dummy values to get the right color + values = [sys] * len(names) + return names, values, None # from x.y import z submodules = self.find_modules(from_name, name) - attributes, action = self.find_attributes(from_name, name) - return sorted({*submodules, *attributes}), action + attr_names, attr_values, action = self.find_attributes(from_name, name) + all_names = sorted({*submodules, *attr_names}) + # Build values list matching the sorted order: + # submodules use `sys` as a dummy value so they get the 'module' color, + # attributes use their actual value. + submodule_set = set(submodules) + attr_map = dict(zip(attr_names, attr_values)) + all_values = [attr_map.get(n) if n not in submodule_set else sys + for n in all_names] + return all_names, all_values, action def find_modules(self, path: str, prefix: str) -> list[str]: """Find all modules under 'path' that start with 'prefix'.""" @@ -166,31 +181,43 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool: return (isinstance(module_info.module_finder, FileFinder) and module_info.module_finder.path == self._stdlib_path) - def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]: + def find_attributes(self, path: str, prefix: str) -> tuple[list[str], list[Any], CompletionAction | None]: """Find all attributes of module 'path' that start with 'prefix'.""" - attributes, action = self._find_attributes(path, prefix) + attributes, values, action = self._find_attributes(path, prefix) # Filter out invalid attribute names # (for example those containing dashes that cannot be imported with 'import') - return [attr for attr in attributes if attr.isidentifier()], action - - def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]: + filtered_names = [] + filtered_values = [] + for attr, val in zip(attributes, values): + if attr.isidentifier(): + filtered_names.append(attr) + filtered_values.append(val) + return filtered_names, filtered_values, action + + def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], list[Any], CompletionAction | None]: path = self._resolve_relative_path(path) # type: ignore[assignment] if path is None: - return [], None + return [], [], None imported_module = sys.modules.get(path) if not imported_module: if path in self._failed_imports: # Do not propose to import again - return [], None + return [], [], None imported_module = self._maybe_import_module(path) if not imported_module: - return [], self._get_import_completion_action(path) + return [], [], self._get_import_completion_action(path) try: module_attributes = dir(imported_module) except Exception: module_attributes = [] - return [attr_name for attr_name in module_attributes - if self.is_suggestion_match(attr_name, prefix)], None + names = [] + values = [] + for attr_name in module_attributes: + if not self.is_suggestion_match(attr_name, prefix): + continue + names.append(attr_name) + values.append(safe_getattr(imported_module, attr_name)) + return names, values, None def is_suggestion_match(self, module_name: str, prefix: str) -> bool: if prefix: diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 8d3be37b4adeec..d8a505488f3b51 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -40,7 +40,7 @@ from .completing_reader import CompletingReader, stripcolor from .console import Console as ConsoleType from ._module_completer import ModuleCompleter, make_default_module_completer -from .fancycompleter import Completer as FancyCompleter +from .fancycompleter import Completer as FancyCompleter, colorize_matches Console: type[ConsoleType] _error: tuple[type[Exception], ...] | type[Exception] @@ -104,6 +104,7 @@ class ReadlineConfig: readline_completer: Completer | None = None completer_delims: frozenset[str] = frozenset(" \t\n`~!@#$%^&*()-=+[{]}\\|;:'\",<>/?") module_completer: ModuleCompleter = field(default_factory=make_default_module_completer) + colorize_completions: Callable[[list[str], list[object]], list[str]] | None = None @dataclass(kw_only=True) class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader): @@ -169,8 +170,14 @@ def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None return result, None def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | None: - line = self.get_line() - return self.config.module_completer.get_completions(line) + line = stripcolor(self.get_line()) + result = self.config.module_completer.get_completions(line) + if result is None: + return None + names, values, action = result + if len(names) > 1 and self.config.colorize_completions: + names = self.config.colorize_completions(names, values) + return names, action def get_trimmed_history(self, maxlength: int) -> list[str]: if maxlength >= 0: @@ -609,13 +616,18 @@ def _setup(namespace: Mapping[str, Any]) -> None: # set up namespace in rlcompleter, which requires it to be a bona fide dict if not isinstance(namespace, dict): namespace = dict(namespace) - _wrapper.config.module_completer = ModuleCompleter(namespace) use_basic_completer = ( not sys.flags.ignore_environment and os.getenv("PYTHON_BASIC_COMPLETER") ) completer_cls = RLCompleter if use_basic_completer else FancyCompleter - _wrapper.config.readline_completer = completer_cls(namespace).complete + completer = completer_cls(namespace) + _wrapper.config.readline_completer = completer.complete + if getattr(completer, 'use_colors', False): + def _colorize(names: list[str], values: list[object]) -> list[str]: + return colorize_matches(names, values, completer.theme) + _wrapper.config.colorize_completions = _colorize + _wrapper.config.module_completer = ModuleCompleter(namespace) # this is not really what readline.c does. Better than nothing I guess import builtins diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 8a3cae966a6e05..c05b0550971a8c 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1629,7 +1629,7 @@ def test_suggestions_and_messages(self) -> None: result = completer.get_completions(code) self.assertEqual(result is None, expected is None) if result: - compl, act = result + compl, _values, act = result self.assertEqual(compl, expected[0]) self.assertEqual(act is None, expected[1] is None) if act: From 3b20cf344600fdec874277ed4d90b6047e4afa9d Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 22:29:15 +0200 Subject: [PATCH 4/7] Add tests --- Lib/test/test_pyrepl/test_pyrepl.py | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index c05b0550971a8c..30487091572f35 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1641,6 +1641,40 @@ def test_suggestions_and_messages(self) -> None: new_imports = sys.modules.keys() - _imported self.assertSetEqual(new_imports, expected_imports) + def test_colorize_import_completions(self) -> None: + from _colorize import get_theme + from _pyrepl.fancycompleter import colorize_matches + from _pyrepl.completing_reader import stripcolor + + theme = get_theme() + colorize = lambda names, values: colorize_matches(names, values, theme) + config = ReadlineConfig(colorize_completions=colorize) + + reader = ReadlineAlikeReader( + console=FakeConsole(events=[]), + config=config, + ) + + # Multiple completions should be colorized (contain ANSI codes) + reader.buffer = list("from collections import d") + reader.pos = len(reader.buffer) + result = reader.get_module_completions() + self.assertIsNotNone(result) + names, action = result + self.assertTrue(len(names) > 1) + # Colorized names contain ANSI escape sequences + self.assertTrue(any(name != stripcolor(name) for name in names + if name.strip())) + + # Single completion should NOT be colorized + reader.buffer = list("from collections import Order") + reader.pos = len(reader.buffer) + result = reader.get_module_completions() + self.assertIsNotNone(result) + names, action = result + self.assertEqual(len(names), 1) + self.assertEqual(names[0], stripcolor(names[0])) + # Audit hook used to check for stdlib modules import side-effects # Defined globally to avoid adding one hook per test run (refleak) From 9e9d9d8f41bfc57dd432a51ed83d75cad268cee4 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 22:37:08 +0200 Subject: [PATCH 5/7] Simplify logic --- Lib/_pyrepl/readline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index d8a505488f3b51..7e7a35509c43a2 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -175,7 +175,7 @@ def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | if result is None: return None names, values, action = result - if len(names) > 1 and self.config.colorize_completions: + if self.config.colorize_completions: names = self.config.colorize_completions(names, values) return names, action From a1576a0ac80b40d3cc00f487b44c1012def46d21 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 22:55:48 +0200 Subject: [PATCH 6/7] Improve tests --- Lib/test/test_pyrepl/test_pyrepl.py | 46 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 30487091572f35..5c59e7aa967f3c 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -35,6 +35,7 @@ multiline_input, code_to_events, ) +from _colorize import ANSIColors, get_theme from _pyrepl.console import Event from _pyrepl.completing_reader import stripcolor from _pyrepl._module_completer import ( @@ -42,7 +43,7 @@ ModuleCompleter, HARDCODED_SUBMODULES, ) -from _pyrepl.fancycompleter import Completer as FancyCompleter +from _pyrepl.fancycompleter import Completer as FancyCompleter, colorize_matches import _pyrepl.readline as pyrepl_readline from _pyrepl.readline import ( ReadlineAlikeReader, @@ -1642,38 +1643,37 @@ def test_suggestions_and_messages(self) -> None: self.assertSetEqual(new_imports, expected_imports) def test_colorize_import_completions(self) -> None: - from _colorize import get_theme - from _pyrepl.fancycompleter import colorize_matches - from _pyrepl.completing_reader import stripcolor - theme = get_theme() + type_color = theme.fancycompleter.type + module_color = theme.fancycompleter.module + R = ANSIColors.RESET + colorize = lambda names, values: colorize_matches(names, values, theme) config = ReadlineConfig(colorize_completions=colorize) - reader = ReadlineAlikeReader( console=FakeConsole(events=[]), config=config, ) - # Multiple completions should be colorized (contain ANSI codes) - reader.buffer = list("from collections import d") + # "from collections import de" -> defaultdict (type) and deque (type) + reader.buffer = list("from collections import de") reader.pos = len(reader.buffer) - result = reader.get_module_completions() - self.assertIsNotNone(result) - names, action = result - self.assertTrue(len(names) > 1) - # Colorized names contain ANSI escape sequences - self.assertTrue(any(name != stripcolor(name) for name in names - if name.strip())) - - # Single completion should NOT be colorized - reader.buffer = list("from collections import Order") + names, action = reader.get_module_completions() + self.assertEqual(names, [ + f"{type_color}defaultdict{R}", + f"{type_color}deque{R}", + ]) + self.assertIsNone(action) + + # "from importlib.m" has submodule completions colored as modules + reader.buffer = list("from importlib.m") reader.pos = len(reader.buffer) - result = reader.get_module_completions() - self.assertIsNotNone(result) - names, action = result - self.assertEqual(len(names), 1) - self.assertEqual(names[0], stripcolor(names[0])) + names, action = reader.get_module_completions() + self.assertEqual(names, [ + f"{module_color}importlib.machinery{R}", + f"{module_color}importlib.metadata{R}", + ]) + self.assertIsNone(action) # Audit hook used to check for stdlib modules import side-effects From 7c959923b66c652a49416c5286d013ec8af4f75e Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Mon, 6 Apr 2026 23:00:27 +0200 Subject: [PATCH 7/7] Restore original test name --- Lib/test/test_pyrepl/test_fancycompleter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index fad0f824e1866c..e65f9e158e746e 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -167,7 +167,7 @@ def test_complete_global_colored(self): self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) - def test_large_color_sort_prefix_is_stripped(self): + def test_colorized_match_is_stripped(self): theme = get_theme() match = _color_for_obj('spam', 1, theme) self.assertEqual(stripcolor(match), 'spam')