Skip to content

Commit ed99680

Browse files
authored
gh-130472: Use fancycompleter in import completions (#148188)
1 parent 2995d45 commit ed99680

6 files changed

Lines changed: 190 additions & 62 deletions

File tree

Lib/_pyrepl/_module_completer.py

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from dataclasses import dataclass
1414
from itertools import chain
1515
from tokenize import TokenInfo
16+
from .fancycompleter import safe_getattr
1617

1718
TYPE_CHECKING = False
1819

@@ -71,41 +72,69 @@ def __init__(self, namespace: Mapping[str, Any] | None = None) -> None:
7172
self._curr_sys_path: list[str] = sys.path[:]
7273
self._stdlib_path = os.path.dirname(importlib.__path__[0])
7374

74-
def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None:
75+
def get_completions(
76+
self, line: str, *, include_values: bool = True
77+
) -> tuple[list[str], list[Any], CompletionAction | None] | None:
7578
"""Return the next possible import completions for 'line'.
7679
7780
For attributes completion, if the module to complete from is not
7881
imported, also return an action (prompt + callback to run if the
7982
user press TAB again) to import the module.
83+
84+
If *include_values* is false, the returned values list is empty and
85+
attribute values are not resolved.
8086
"""
8187
result = ImportParser(line).parse()
8288
if not result:
8389
return None
8490
try:
85-
return self.complete(*result)
91+
return self.complete(*result, include_values=include_values)
8692
except Exception:
8793
# Some unexpected error occurred, make it look like
8894
# no completions are available
89-
return [], None
90-
91-
def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]:
95+
return [], [], None
96+
97+
def complete(
98+
self,
99+
from_name: str | None,
100+
name: str | None,
101+
*,
102+
include_values: bool = True,
103+
) -> tuple[list[str], list[Any], CompletionAction | None]:
92104
if from_name is None:
93105
# import x.y.z<tab>
94106
assert name is not None
95107
path, prefix = self.get_path_and_prefix(name)
96108
modules = self.find_modules(path, prefix)
97-
return [self.format_completion(path, module) for module in modules], None
109+
names = [self.format_completion(path, module) for module in modules]
110+
# These are always modules, use dummy values to get the right color
111+
values = [sys] * len(names) if include_values else []
112+
return names, values, None
98113

99114
if name is None:
100115
# from x.y.z<tab>
101116
path, prefix = self.get_path_and_prefix(from_name)
102117
modules = self.find_modules(path, prefix)
103-
return [self.format_completion(path, module) for module in modules], None
118+
names = [self.format_completion(path, module) for module in modules]
119+
# These are always modules, use dummy values to get the right color
120+
values = [sys] * len(names) if include_values else []
121+
return names, values, None
104122

105123
# from x.y import z<tab>
106124
submodules = self.find_modules(from_name, name)
107-
attributes, action = self.find_attributes(from_name, name)
108-
return sorted({*submodules, *attributes}), action
125+
attr_names, attr_module, action = self._find_attributes(from_name, name)
126+
all_names = sorted({*submodules, *attr_names})
127+
if not include_values:
128+
return all_names, [], action
129+
130+
# Build values list matching the sorted order:
131+
# submodules use `sys` as a dummy value so they get the 'module' color,
132+
# attributes use their actual value.
133+
attr_map = {}
134+
if attr_module is not None:
135+
attr_map = {n: safe_getattr(attr_module, n) for n in attr_names}
136+
all_values = [attr_map[n] if n in attr_map else sys for n in all_names]
137+
return all_names, all_values, action
109138

110139
def find_modules(self, path: str, prefix: str) -> list[str]:
111140
"""Find all modules under 'path' that start with 'prefix'."""
@@ -166,31 +195,43 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool:
166195
return (isinstance(module_info.module_finder, FileFinder)
167196
and module_info.module_finder.path == self._stdlib_path)
168197

169-
def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
198+
def find_attributes(
199+
self, path: str, prefix: str
200+
) -> tuple[list[str], list[Any], CompletionAction | None]:
170201
"""Find all attributes of module 'path' that start with 'prefix'."""
171-
attributes, action = self._find_attributes(path, prefix)
172-
# Filter out invalid attribute names
173-
# (for example those containing dashes that cannot be imported with 'import')
174-
return [attr for attr in attributes if attr.isidentifier()], action
202+
attributes, module, action = self._find_attributes(path, prefix)
203+
if module is not None:
204+
values = [safe_getattr(module, attr) for attr in attributes]
205+
else:
206+
values = []
207+
return attributes, values, action
175208

176-
def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
209+
def _find_attributes(
210+
self, path: str, prefix: str
211+
) -> tuple[list[str], ModuleType | None, CompletionAction | None]:
177212
path = self._resolve_relative_path(path) # type: ignore[assignment]
178213
if path is None:
179-
return [], None
214+
return [], None, None
180215

181216
imported_module = sys.modules.get(path)
182217
if not imported_module:
183218
if path in self._failed_imports: # Do not propose to import again
184-
return [], None
219+
return [], None, None
185220
imported_module = self._maybe_import_module(path)
186221
if not imported_module:
187-
return [], self._get_import_completion_action(path)
222+
return [], None, self._get_import_completion_action(path)
188223
try:
189224
module_attributes = dir(imported_module)
190225
except Exception:
191226
module_attributes = []
192-
return [attr_name for attr_name in module_attributes
193-
if self.is_suggestion_match(attr_name, prefix)], None
227+
# Filter out invalid attribute names, such as dashes that cannot be
228+
# imported with 'import'.
229+
names = [
230+
attr_name for attr_name in module_attributes
231+
if (self.is_suggestion_match(attr_name, prefix)
232+
and attr_name.isidentifier())
233+
]
234+
return names, imported_module, None
194235

195236
def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
196237
if prefix:

Lib/_pyrepl/fancycompleter.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,53 @@
33
#
44
# All Rights Reserved
55
"""Colorful tab completion for Python prompt"""
6+
from __future__ import annotations
7+
68
from _colorize import ANSIColors, get_colors, get_theme
79
import rlcompleter
810
import keyword
911
import types
1012

13+
TYPE_CHECKING = False
14+
15+
if TYPE_CHECKING:
16+
from typing import Any
17+
from _colorize import Theme
18+
19+
20+
def safe_getattr(obj, name):
21+
# Mirror rlcompleter's safeguards so completion does not
22+
# call properties or reify lazy module attributes.
23+
if isinstance(getattr(type(obj), name, None), property):
24+
return None
25+
if (isinstance(obj, types.ModuleType)
26+
and isinstance(obj.__dict__.get(name), types.LazyImportType)
27+
):
28+
return obj.__dict__.get(name)
29+
return getattr(obj, name, None)
30+
31+
32+
def colorize_matches(names: list[str], values: list[Any], theme: Theme) -> list[str]:
33+
return [
34+
_color_for_obj(name, obj, theme)
35+
for name, obj in zip(names, values)
36+
]
37+
38+
def _color_for_obj(name: str, value: Any, theme: Theme) -> str:
39+
t = type(value)
40+
color = _color_by_type(t, theme)
41+
return f"{color}{name}{ANSIColors.RESET}"
42+
43+
44+
def _color_by_type(t, theme):
45+
typename = t.__name__
46+
# this is needed e.g. to turn method-wrapper into method_wrapper,
47+
# because if we want _colorize.FancyCompleter to be "dataclassable"
48+
# our keys need to be valid identifiers.
49+
typename = typename.replace('-', '_').replace('.', '_')
50+
return getattr(theme.fancycompleter, typename, ANSIColors.RESET)
51+
52+
1153
class Completer(rlcompleter.Completer):
1254
"""
1355
When doing something like a.b.<tab>, keep the full a.b.attr completion
@@ -143,21 +185,7 @@ def _attr_matches(self, text):
143185
word[:n] == attr
144186
and not (noprefix and word[:n+1] == noprefix)
145187
):
146-
# Mirror rlcompleter's safeguards so completion does not
147-
# call properties or reify lazy module attributes.
148-
if isinstance(getattr(type(thisobject), word, None), property):
149-
value = None
150-
elif (
151-
isinstance(thisobject, types.ModuleType)
152-
and isinstance(
153-
thisobject.__dict__.get(word),
154-
types.LazyImportType,
155-
)
156-
):
157-
value = thisobject.__dict__.get(word)
158-
else:
159-
value = getattr(thisobject, word, None)
160-
188+
value = safe_getattr(thisobject, word)
161189
names.append(word)
162190
values.append(value)
163191
if names or not noprefix:
@@ -170,23 +198,7 @@ def _attr_matches(self, text):
170198
return expr, attr, names, values
171199

172200
def colorize_matches(self, names, values):
173-
return [
174-
self._color_for_obj(name, obj)
175-
for name, obj in zip(names, values)
176-
]
177-
178-
def _color_for_obj(self, name, value):
179-
t = type(value)
180-
color = self._color_by_type(t)
181-
return f"{color}{name}{ANSIColors.RESET}"
182-
183-
def _color_by_type(self, t):
184-
typename = t.__name__
185-
# this is needed e.g. to turn method-wrapper into method_wrapper,
186-
# because if we want _colorize.FancyCompleter to be "dataclassable"
187-
# our keys need to be valid identifiers.
188-
typename = typename.replace('-', '_').replace('.', '_')
189-
return getattr(self.theme.fancycompleter, typename, ANSIColors.RESET)
201+
return colorize_matches(names, values, self.theme)
190202

191203

192204
def commonprefix(names):

Lib/_pyrepl/readline.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from .completing_reader import CompletingReader, stripcolor
4141
from .console import Console as ConsoleType
4242
from ._module_completer import ModuleCompleter, make_default_module_completer
43-
from .fancycompleter import Completer as FancyCompleter
43+
from .fancycompleter import Completer as FancyCompleter, colorize_matches
4444

4545
Console: type[ConsoleType]
4646
_error: tuple[type[Exception], ...] | type[Exception]
@@ -104,6 +104,7 @@ class ReadlineConfig:
104104
readline_completer: Completer | None = None
105105
completer_delims: frozenset[str] = frozenset(" \t\n`~!@#$%^&*()-=+[{]}\\|;:'\",<>/?")
106106
module_completer: ModuleCompleter = field(default_factory=make_default_module_completer)
107+
colorize_completions: Callable[[list[str], list[Any]], list[str]] | None = None
107108

108109
@dataclass(kw_only=True)
109110
class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader):
@@ -169,8 +170,17 @@ def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None
169170
return result, None
170171

171172
def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | None:
172-
line = self.get_line()
173-
return self.config.module_completer.get_completions(line)
173+
line = stripcolor(self.get_line())
174+
colorize_completions = self.config.colorize_completions
175+
result = self.config.module_completer.get_completions(
176+
line, include_values=bool(colorize_completions)
177+
)
178+
if result is None:
179+
return None
180+
names, values, action = result
181+
if colorize_completions:
182+
names = colorize_completions(names, values)
183+
return names, action
174184

175185
def get_trimmed_history(self, maxlength: int) -> list[str]:
176186
if maxlength >= 0:
@@ -616,13 +626,19 @@ def _setup(namespace: Mapping[str, Any]) -> None:
616626
# set up namespace in rlcompleter, which requires it to be a bona fide dict
617627
if not isinstance(namespace, dict):
618628
namespace = dict(namespace)
619-
_wrapper.config.module_completer = ModuleCompleter(namespace)
620629
use_basic_completer = (
621630
not sys.flags.ignore_environment
622631
and os.getenv("PYTHON_BASIC_COMPLETER")
623632
)
624633
completer_cls = RLCompleter if use_basic_completer else FancyCompleter
625-
_wrapper.config.readline_completer = completer_cls(namespace).complete
634+
completer = completer_cls(namespace)
635+
_wrapper.config.readline_completer = completer.complete
636+
if isinstance(completer, FancyCompleter) and completer.use_colors:
637+
theme = completer.theme
638+
def _colorize(names: list[str], values: list[object]) -> list[str]:
639+
return colorize_matches(names, values, theme)
640+
_wrapper.config.colorize_completions = _colorize
641+
_wrapper.config.module_completer = ModuleCompleter(namespace)
626642

627643
# this is not really what readline.c does. Better than nothing I guess
628644
import builtins

Lib/test/test_pyrepl/test_fancycompleter.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import importlib
2+
import inspect
23
import os
34
import types
45
import unittest
56

67
from _colorize import ANSIColors, get_theme
78
from _pyrepl.completing_reader import stripcolor
8-
from _pyrepl.fancycompleter import Completer, commonprefix
9+
from _pyrepl.fancycompleter import (
10+
Completer,
11+
colorize_matches,
12+
commonprefix,
13+
_color_for_obj,
14+
)
915
from test.support.import_helper import ready_to_import
1016

1117
class MockPatch:
@@ -36,6 +42,11 @@ def test_commonprefix(self):
3642
self.assertEqual(commonprefix(['isalpha', 'isdigit']), 'is')
3743
self.assertEqual(commonprefix([]), '')
3844

45+
def test_colorize_matches_signature(self):
46+
signature = inspect.signature(colorize_matches)
47+
48+
self.assertEqual(list(signature.parameters), ["names", "values", "theme"])
49+
3950
def test_complete_attribute(self):
4051
compl = Completer({'a': None}, use_colors=False)
4152
self.assertEqual(compl.attr_matches('a.'), ['a.__'])
@@ -168,8 +179,8 @@ def test_complete_global_colored(self):
168179
self.assertEqual(compl.global_matches('nothing'), [])
169180

170181
def test_colorized_match_is_stripped(self):
171-
compl = Completer({'a': 42}, use_colors=True)
172-
match = compl._color_for_obj('spam', 1)
182+
theme = get_theme()
183+
match = _color_for_obj('spam', 1, theme)
173184
self.assertEqual(stripcolor(match), 'spam')
174185

175186
def test_complete_with_indexer(self):

0 commit comments

Comments
 (0)