https://github.com/python/cpython/commit/ed99680487b347997061ebd0138d49e601b20de8
commit: ed99680487b347997061ebd0138d49e601b20de8
branch: main
author: Tomas R. <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-05-05T01:36:43Z
summary:

gh-130472: Use fancycompleter in import completions (#148188)

files:
A Misc/NEWS.d/next/Library/2026-04-08-21-39-01.gh-issue-130472.4Bk6qH.rst
M Lib/_pyrepl/_module_completer.py
M Lib/_pyrepl/fancycompleter.py
M Lib/_pyrepl/readline.py
M Lib/test/test_pyrepl/test_fancycompleter.py
M Lib/test/test_pyrepl/test_pyrepl.py

diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py
index a22b0297b24ea0..17bf5cdc819542 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,41 +72,69 @@ 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, *, include_values: bool = True
+    ) -> 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
         imported, also return an action (prompt + callback to run if the
         user press TAB again) to import the module.
+
+        If *include_values* is false, the returned values list is empty and
+        attribute values are not resolved.
         """
         result = ImportParser(line).parse()
         if not result:
             return None
         try:
-            return self.complete(*result)
+            return self.complete(*result, include_values=include_values)
         except Exception:
             # Some unexpected error occurred, make it look like
             # no completions are available
-            return [], None
-
-    def complete(self, from_name: str | None, name: str | None) -> 
tuple[list[str], CompletionAction | None]:
+            return [], [], None
+
+    def complete(
+        self,
+        from_name: str | None,
+        name: str | None,
+        *,
+        include_values: bool = True,
+    ) -> tuple[list[str], list[Any], CompletionAction | None]:
         if from_name is None:
             # import x.y.z<tab>
             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) if include_values else []
+            return names, values, None
 
         if name is None:
             # from x.y.z<tab>
             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) if include_values else []
+            return names, values, None
 
         # from x.y import z<tab>
         submodules = self.find_modules(from_name, name)
-        attributes, action = self.find_attributes(from_name, name)
-        return sorted({*submodules, *attributes}), action
+        attr_names, attr_module, action = self._find_attributes(from_name, 
name)
+        all_names = sorted({*submodules, *attr_names})
+        if not include_values:
+            return all_names, [], action
+
+        # 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.
+        attr_map = {}
+        if attr_module is not None:
+            attr_map = {n: safe_getattr(attr_module, n) for n in attr_names}
+        all_values = [attr_map[n] if n in attr_map 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 +195,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)
-        # 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
+        attributes, module, action = self._find_attributes(path, prefix)
+        if module is not None:
+            values = [safe_getattr(module, attr) for attr in attributes]
+        else:
+            values = []
+        return attributes, values, action
 
-    def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], 
CompletionAction | None]:
+    def _find_attributes(
+        self, path: str, prefix: str
+    ) -> tuple[list[str], ModuleType | None, CompletionAction | None]:
         path = self._resolve_relative_path(path)  # type: ignore[assignment]
         if path is None:
-            return [], None
+            return [], None, 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, None
             imported_module = self._maybe_import_module(path)
         if not imported_module:
-            return [], self._get_import_completion_action(path)
+            return [], None, 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
+        # Filter out invalid attribute names, such as dashes that cannot be
+        # imported with 'import'.
+        names = [
+            attr_name for attr_name in module_attributes
+            if (self.is_suggestion_match(attr_name, prefix)
+                and attr_name.isidentifier())
+        ]
+        return names, imported_module, None
 
     def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
         if prefix:
diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py
index 7a639afd74ef3c..ac4f0afdbc721c 100644
--- a/Lib/_pyrepl/fancycompleter.py
+++ b/Lib/_pyrepl/fancycompleter.py
@@ -3,11 +3,53 @@
 #
 #                        All Rights Reserved
 """Colorful tab completion for Python prompt"""
+from __future__ import annotations
+
 from _colorize import ANSIColors, get_colors, get_theme
 import rlcompleter
 import keyword
 import types
 
+TYPE_CHECKING = False
+
+if TYPE_CHECKING:
+    from typing import Any
+    from _colorize import Theme
+
+
+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)
+
+
+def colorize_matches(names: list[str], values: list[Any], theme: Theme) -> 
list[str]:
+    return [
+        _color_for_obj(name, obj, theme)
+        for name, obj in zip(names, values)
+    ]
+
+def _color_for_obj(name: str, value: Any, theme: Theme) -> str:
+    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.<tab>, keep the full a.b.attr completion
@@ -143,21 +185,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:
@@ -170,23 +198,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/_pyrepl/readline.py b/Lib/_pyrepl/readline.py
index f8f1727d2a1d1f..e4370b0d1462ea 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[Any]], list[str]] | None = 
None
 
 @dataclass(kw_only=True)
 class ReadlineAlikeReader(historical_reader.HistoricalReader, 
CompletingReader):
@@ -169,8 +170,17 @@ 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())
+        colorize_completions = self.config.colorize_completions
+        result = self.config.module_completer.get_completions(
+            line, include_values=bool(colorize_completions)
+        )
+        if result is None:
+            return None
+        names, values, action = result
+        if colorize_completions:
+            names = colorize_completions(names, values)
+        return names, action
 
     def get_trimmed_history(self, maxlength: int) -> list[str]:
         if maxlength >= 0:
@@ -616,13 +626,19 @@ 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 isinstance(completer, FancyCompleter) and completer.use_colors:
+        theme = completer.theme
+        def _colorize(names: list[str], values: list[object]) -> list[str]:
+            return colorize_matches(names, values, 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_fancycompleter.py 
b/Lib/test/test_pyrepl/test_fancycompleter.py
index d2646cd3050428..0ffc1ed97b557a 100644
--- a/Lib/test/test_pyrepl/test_fancycompleter.py
+++ b/Lib/test/test_pyrepl/test_fancycompleter.py
@@ -1,11 +1,17 @@
 import importlib
+import inspect
 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 _pyrepl.fancycompleter import (
+    Completer,
+    colorize_matches,
+    commonprefix,
+    _color_for_obj,
+)
 from test.support.import_helper import ready_to_import
 
 class MockPatch:
@@ -36,6 +42,11 @@ def test_commonprefix(self):
         self.assertEqual(commonprefix(['isalpha', 'isdigit']), 'is')
         self.assertEqual(commonprefix([]), '')
 
+    def test_colorize_matches_signature(self):
+        signature = inspect.signature(colorize_matches)
+
+        self.assertEqual(list(signature.parameters), ["names", "values", 
"theme"])
+
     def test_complete_attribute(self):
         compl = Completer({'a': None}, use_colors=False)
         self.assertEqual(compl.attr_matches('a.'), ['a.__'])
@@ -168,8 +179,8 @@ def test_complete_global_colored(self):
         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)
+        theme = get_theme()
+        match = _color_for_obj('spam', 1, theme)
         self.assertEqual(stripcolor(match), 'spam')
 
     def test_complete_with_indexer(self):
diff --git a/Lib/test/test_pyrepl/test_pyrepl.py 
b/Lib/test/test_pyrepl/test_pyrepl.py
index 9d0a4ed5316a3f..4240a3c3174959 100644
--- a/Lib/test/test_pyrepl/test_pyrepl.py
+++ b/Lib/test/test_pyrepl/test_pyrepl.py
@@ -36,6 +36,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 (
@@ -43,7 +44,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,
@@ -1102,6 +1103,8 @@ def 
test_setup_ignores_basic_completer_env_when_env_is_disabled(self):
         class FakeFancyCompleter:
             def __init__(self, namespace):
                 self.namespace = namespace
+                self.use_colors = Mock()
+                self.theme = Mock()
 
             def complete(self, text, state):
                 return None
@@ -1704,7 +1707,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:
@@ -1716,6 +1719,50 @@ 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:
+        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,
+        )
+
+        # "from collections import de" -> defaultdict (type) and deque (type)
+        reader.buffer = list("from collections import de")
+        reader.pos = len(reader.buffer)
+        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)
+        names, action = reader.get_module_completions()
+        self.assertEqual(names, [
+            f"{module_color}importlib.machinery{R}",
+            f"{module_color}importlib.metadata{R}",
+        ])
+        self.assertIsNone(action)
+
+        # Make sure attributes take precedence over submodules when both exist
+        # Here we're using `unittest.main` which happens to be both a module 
and an attribute
+        reader.buffer = list("from unittest import m")
+        reader.pos = len(reader.buffer)
+        names, action = reader.get_module_completions()
+        self.assertEqual(names, [
+            f"{type_color}main{R}",  # Ensure that `main` is colored as an 
attribute (class in this case)
+            f"{module_color}mock{R}",
+        ])
+        self.assertIsNone(action)
+
 
 # Audit hook used to check for stdlib modules import side-effects
 # Defined globally to avoid adding one hook per test run (refleak)
diff --git 
a/Misc/NEWS.d/next/Library/2026-04-08-21-39-01.gh-issue-130472.4Bk6qH.rst 
b/Misc/NEWS.d/next/Library/2026-04-08-21-39-01.gh-issue-130472.4Bk6qH.rst
new file mode 100644
index 00000000000000..9384843b7c253b
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-04-08-21-39-01.gh-issue-130472.4Bk6qH.rst
@@ -0,0 +1 @@
+Integrate fancycompleter with import completions.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to