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]