Skip to content

Commit 4bb5d39

Browse files
committed
wip
1 parent b07becb commit 4bb5d39

5 files changed

Lines changed: 93 additions & 63 deletions

File tree

Lib/_pyrepl/_module_completer.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def __init__(self, namespace: Mapping[str, Any] | None = None) -> None:
7171
self._curr_sys_path: list[str] = sys.path[:]
7272
self._stdlib_path = os.path.dirname(importlib.__path__[0])
7373

74-
def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None:
74+
def get_completions(self, line: str) -> tuple[list[str], list[Any], CompletionAction | None] | None:
7575
"""Return the next possible import completions for 'line'.
7676
7777
For attributes completion, if the module to complete from is not
@@ -86,26 +86,32 @@ def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None
8686
except Exception:
8787
# Some unexpected error occurred, make it look like
8888
# no completions are available
89-
return [], None
89+
return [], [], None
9090

91-
def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]:
91+
def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], list[Any], CompletionAction | None]:
9292
if from_name is None:
9393
# import x.y.z<tab>
9494
assert name is not None
9595
path, prefix = self.get_path_and_prefix(name)
9696
modules = self.find_modules(path, prefix)
97-
return [self.format_completion(path, module) for module in modules], None
97+
names = [self.format_completion(path, module) for module in modules]
98+
return names, [None] * len(names), None
9899

99100
if name is None:
100101
# from x.y.z<tab>
101102
path, prefix = self.get_path_and_prefix(from_name)
102103
modules = self.find_modules(path, prefix)
103-
return [self.format_completion(path, module) for module in modules], None
104+
names = [self.format_completion(path, module) for module in modules]
105+
return names, [None] * len(names), None
104106

105107
# from x.y import z<tab>
106108
submodules = self.find_modules(from_name, name)
107-
attributes, action = self.find_attributes(from_name, name)
108-
return sorted({*submodules, *attributes}), action
109+
attr_names, attr_values, action = self.find_attributes(from_name, name)
110+
all_names = sorted({*submodules, *attr_names})
111+
# Build values list matching the sorted order
112+
attr_map = dict(zip(attr_names, attr_values))
113+
all_values = [attr_map.get(n) for n in all_names]
114+
return all_names, all_values, action
109115

110116
def find_modules(self, path: str, prefix: str) -> list[str]:
111117
"""Find all modules under 'path' that start with 'prefix'."""
@@ -166,31 +172,43 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool:
166172
return (isinstance(module_info.module_finder, FileFinder)
167173
and module_info.module_finder.path == self._stdlib_path)
168174

169-
def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
175+
def find_attributes(self, path: str, prefix: str) -> tuple[list[str], list[Any], CompletionAction | None]:
170176
"""Find all attributes of module 'path' that start with 'prefix'."""
171-
attributes, action = self._find_attributes(path, prefix)
177+
attributes, values, action = self._find_attributes(path, prefix)
172178
# Filter out invalid attribute names
173179
# (for example those containing dashes that cannot be imported with 'import')
174-
return [attr for attr in attributes if attr.isidentifier()], action
175-
176-
def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
180+
filtered = [(attr, val) for attr, val in zip(attributes, values)
181+
if attr.isidentifier()]
182+
if filtered:
183+
attrs, vals = zip(*filtered)
184+
return list(attrs), list(vals), action
185+
return [], [], action
186+
187+
def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], list[Any], CompletionAction | None]:
177188
path = self._resolve_relative_path(path) # type: ignore[assignment]
178189
if path is None:
179-
return [], None
190+
return [], [], None
180191

181192
imported_module = sys.modules.get(path)
182193
if not imported_module:
183194
if path in self._failed_imports: # Do not propose to import again
184-
return [], None
195+
return [], [], None
185196
imported_module = self._maybe_import_module(path)
186197
if not imported_module:
187-
return [], self._get_import_completion_action(path)
198+
return [], [], self._get_import_completion_action(path)
188199
try:
189200
module_attributes = dir(imported_module)
190201
except Exception:
191202
module_attributes = []
192-
return [attr_name for attr_name in module_attributes
193-
if self.is_suggestion_match(attr_name, prefix)], None
203+
from .fancycompleter import safe_getattr
204+
names = []
205+
values = []
206+
for attr_name in module_attributes:
207+
if not self.is_suggestion_match(attr_name, prefix):
208+
continue
209+
names.append(attr_name)
210+
values.append(safe_getattr(imported_module, attr_name))
211+
return names, values, None
194212

195213
def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
196214
if prefix:

Lib/_pyrepl/fancycompleter.py

Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,41 @@
88
import keyword
99
import types
1010

11+
def safe_getattr(obj, name):
12+
"""Get attribute value safely, avoiding properties and lazy imports."""
13+
if isinstance(getattr(type(obj), name, None), property):
14+
return None
15+
if (isinstance(obj, types.ModuleType)
16+
and isinstance(obj.__dict__.get(name), types.LazyImportType)):
17+
return obj.__dict__.get(name)
18+
return getattr(obj, name, None)
19+
20+
21+
def colorize_completions(names, values, theme):
22+
"""Colorize completion names based on their value types."""
23+
matches = [_color_for_obj(i, name, obj, theme)
24+
for i, (name, obj)
25+
in enumerate(zip(names, values))]
26+
# We add a space at the end to prevent the automatic completion of the
27+
# common prefix, which is the ANSI escape sequence.
28+
matches.append(' ')
29+
return matches
30+
31+
32+
def _color_for_obj(i, name, value, theme):
33+
t = type(value)
34+
typename = t.__name__
35+
# this is needed e.g. to turn method-wrapper into method_wrapper,
36+
# because if we want _colorize.FancyCompleter to be "dataclassable"
37+
# our keys need to be valid identifiers.
38+
typename = typename.replace('-', '_').replace('.', '_')
39+
color = getattr(theme.fancycompleter, typename, ANSIColors.RESET)
40+
# Encode the match index into a fake escape sequence that
41+
# stripcolor() can still remove once i reaches four digits.
42+
N = f"\x1b[{i // 100:03d};{i % 100:02d}m"
43+
return f"{N}{color}{name}{ANSIColors.RESET}"
44+
45+
1146
class Completer(rlcompleter.Completer):
1247
"""
1348
When doing something like a.b.<tab>, keep the full a.b.attr completion
@@ -146,21 +181,7 @@ def _attr_matches(self, text):
146181
word[:n] == attr
147182
and not (noprefix and word[:n+1] == noprefix)
148183
):
149-
# Mirror rlcompleter's safeguards so completion does not
150-
# call properties or reify lazy module attributes.
151-
if isinstance(getattr(type(thisobject), word, None), property):
152-
value = None
153-
elif (
154-
isinstance(thisobject, types.ModuleType)
155-
and isinstance(
156-
thisobject.__dict__.get(word),
157-
types.LazyImportType,
158-
)
159-
):
160-
value = thisobject.__dict__.get(word)
161-
else:
162-
value = getattr(thisobject, word, None)
163-
184+
value = safe_getattr(thisobject, word)
164185
names.append(word)
165186
values.append(value)
166187
if names or not noprefix:
@@ -173,29 +194,7 @@ def _attr_matches(self, text):
173194
return expr, attr, names, values
174195

175196
def colorize_matches(self, names, values):
176-
matches = [self._color_for_obj(i, name, obj)
177-
for i, (name, obj)
178-
in enumerate(zip(names, values))]
179-
# We add a space at the end to prevent the automatic completion of the
180-
# common prefix, which is the ANSI escape sequence.
181-
matches.append(' ')
182-
return matches
183-
184-
def _color_for_obj(self, i, name, value):
185-
t = type(value)
186-
color = self._color_by_type(t)
187-
# Encode the match index into a fake escape sequence that
188-
# stripcolor() can still remove once i reaches four digits.
189-
N = f"\x1b[{i // 100:03d};{i % 100:02d}m"
190-
return f"{N}{color}{name}{ANSIColors.RESET}"
191-
192-
def _color_by_type(self, t):
193-
typename = t.__name__
194-
# this is needed e.g. to turn method-wrapper into method_wrapper,
195-
# because if we want _colorize.FancyCompleter to be "dataclassable"
196-
# our keys need to be valid identifiers.
197-
typename = typename.replace('-', '_').replace('.', '_')
198-
return getattr(self.theme.fancycompleter, typename, ANSIColors.RESET)
197+
return colorize_completions(names, values, self.theme)
199198

200199

201200
def commonprefix(names):

Lib/_pyrepl/readline.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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[object]], list[str]] | None = None
107108

108109
@dataclass(kw_only=True)
109110
class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader):
@@ -169,8 +170,15 @@ 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+
from .completing_reader import stripcolor
174+
line = stripcolor(self.get_line())
175+
result = self.config.module_completer.get_completions(line)
176+
if result is None:
177+
return None
178+
names, values, action = result
179+
if names and self.config.colorize_completions:
180+
names = self.config.colorize_completions(names, values)
181+
return names, action
174182

175183
def get_trimmed_history(self, maxlength: int) -> list[str]:
176184
if maxlength >= 0:
@@ -609,13 +617,18 @@ def _setup(namespace: Mapping[str, Any]) -> None:
609617
# set up namespace in rlcompleter, which requires it to be a bona fide dict
610618
if not isinstance(namespace, dict):
611619
namespace = dict(namespace)
612-
_wrapper.config.module_completer = ModuleCompleter(namespace)
613620
use_basic_completer = (
614621
not sys.flags.ignore_environment
615622
and os.getenv("PYTHON_BASIC_COMPLETER")
616623
)
617624
completer_cls = RLCompleter if use_basic_completer else FancyCompleter
618-
_wrapper.config.readline_completer = completer_cls(namespace).complete
625+
completer = completer_cls(namespace)
626+
_wrapper.config.readline_completer = completer.complete
627+
if getattr(completer, 'use_colors', False):
628+
from .fancycompleter import colorize_completions as _colorize
629+
theme = completer.theme
630+
_wrapper.config.colorize_completions = lambda names, values: _colorize(names, values, theme)
631+
_wrapper.config.module_completer = ModuleCompleter(namespace)
619632

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

Lib/test/test_pyrepl/test_fancycompleter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from _colorize import ANSIColors, get_theme
77
from _pyrepl.completing_reader import stripcolor
8-
from _pyrepl.fancycompleter import Completer, commonprefix
8+
from _pyrepl.fancycompleter import Completer, commonprefix, _color_for_obj
99
from test.support.import_helper import ready_to_import
1010

1111
class MockPatch:
@@ -173,8 +173,8 @@ def test_complete_global_colored(self):
173173
self.assertEqual(compl.global_matches('nothing'), [])
174174

175175
def test_large_color_sort_prefix_is_stripped(self):
176-
compl = Completer({'a': 42}, use_colors=True)
177-
match = compl._color_for_obj(1000, 'spam', 1)
176+
theme = get_theme()
177+
match = _color_for_obj(1000, 'spam', 1, theme)
178178
self.assertEqual(stripcolor(match), 'spam')
179179

180180
def test_complete_with_indexer(self):

Lib/test/test_pyrepl/test_pyrepl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1607,7 +1607,7 @@ def test_suggestions_and_messages(self) -> None:
16071607
result = completer.get_completions(code)
16081608
self.assertEqual(result is None, expected is None)
16091609
if result:
1610-
compl, act = result
1610+
compl, _values, act = result
16111611
self.assertEqual(compl, expected[0])
16121612
self.assertEqual(act is None, expected[1] is None)
16131613
if act:

0 commit comments

Comments
 (0)