Skip to content

Commit cba06ba

Browse files
committed
gh-130472: Fix pyrepl fancycompleter edge cases
Keep pyrepl completion logic working on the uncolored completion text. The reader now strips ANSI escapes before comparing the typed stem, inserting a sole completion, computing the shared prefix, and filtering an open completion menu. This fixes colored completions that would stop refining correctly once more characters were typed. Restore readline's callable postfix behavior for attribute completions by routing single attribute matches through rlcompleter's callable postfix logic while keeping the full expr.attr stem for menu refinement. Global completion also treats soft keywords as keywords instead of trying to evaluate them. Avoid side effects while probing attribute values for coloring by not forcing property access and by preserving lazy module imports. Also make the fake color-sorting escape prefix round-trip cleanly once the match index grows past three digits. Only honor PYTHON_BASIC_COMPLETER when the environment is enabled, so pyrepl setup now respects -E / sys.flags.ignore_environment. Add regression tests for the reader behavior, callable attribute completion, property and lazy-import safety, large color-sort prefixes, and the -E setup path.
1 parent 9b9b37d commit cba06ba

File tree

5 files changed

+255
-57
lines changed

5 files changed

+255
-57
lines changed

Lib/_pyrepl/completing_reader.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,14 @@ def do(self) -> None:
178178
if not completions:
179179
r.error("no matches")
180180
elif len(completions) == 1:
181-
if completions_unchangable and len(completions[0]) == len(stem):
181+
completion = stripcolor(completions[0])
182+
if completions_unchangable and len(completion) == len(stem):
182183
r.msg = "[ sole completion ]"
183184
r.dirty = True
184-
r.insert(completions[0][len(stem):])
185+
r.insert(completion[len(stem):])
185186
else:
186-
p = prefix(completions, len(stem))
187+
clean_completions = [stripcolor(word) for word in completions]
188+
p = prefix(clean_completions, len(stem))
187189
if p:
188190
r.insert(p)
189191
if last_is_completer:
@@ -195,7 +197,7 @@ def do(self) -> None:
195197
r.dirty = True
196198
elif not r.cmpltn_menu_visible:
197199
r.cmpltn_message_visible = True
198-
if stem + p in completions:
200+
if stem + p in clean_completions:
199201
r.msg = "[ complete but not unique ]"
200202
r.dirty = True
201203
else:
@@ -215,7 +217,7 @@ def do(self) -> None:
215217
r.cmpltn_reset()
216218
else:
217219
completions = [w for w in r.cmpltn_menu_choices
218-
if w.startswith(stem)]
220+
if stripcolor(w).startswith(stem)]
219221
if completions:
220222
r.cmpltn_menu, r.cmpltn_menu_end = build_menu(
221223
r.console, completions, 0,

Lib/_pyrepl/fancycompleter.py

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
"""Colorful tab completion for Python prompt"""
66
from _colorize import ANSIColors, get_colors, get_theme
77
import rlcompleter
8-
import types
98
import keyword
9+
import types
1010

1111
class Completer(rlcompleter.Completer):
1212
"""
13-
When doing someting like a.b.<tab>, display only the attributes of
14-
b instead of the full a.b.attr string.
13+
When doing something like a.b.<tab>, keep the full a.b.attr completion
14+
stem so readline-style completion can keep refining the menu as you type.
1515
1616
Optionally, display the various completions in different colors
1717
depending on the type.
@@ -32,6 +32,10 @@ def __init__(
3232
self.consider_getitems = consider_getitems
3333

3434
if self.use_colors:
35+
# In GNU readline, this prevents escaping of ANSI control
36+
# characters in completion results. pyrepl's parse_and_bind()
37+
# is a no-op, but pyrepl handles ANSI sequences natively
38+
# via real_len()/stripcolor().
3539
readline.parse_and_bind('set dont-escape-ctrl-chars on')
3640
self.theme = get_theme()
3741
else:
@@ -55,6 +59,9 @@ def _callable_postfix(self, val, word):
5559
# disable automatic insertion of '(' for global callables
5660
return word
5761

62+
def _callable_attr_postfix(self, val, word):
63+
return rlcompleter.Completer._callable_postfix(self, val, word)
64+
5865
def global_matches(self, text):
5966
names = rlcompleter.Completer.global_matches(self, text)
6067
prefix = commonprefix(names)
@@ -65,25 +72,52 @@ def global_matches(self, text):
6572
values = []
6673
for name in names:
6774
clean_name = name.rstrip(': ')
68-
if clean_name in keyword.kwlist:
75+
if keyword.iskeyword(clean_name) or keyword.issoftkeyword(clean_name):
6976
values.append(None)
7077
else:
7178
try:
7279
values.append(eval(name, self.namespace))
73-
except Exception as exc:
80+
except Exception:
7481
values.append(None)
7582
if self.use_colors and names:
7683
return self.colorize_matches(names, values)
7784
return names
7885

7986
def attr_matches(self, text):
87+
try:
88+
expr, attr, names, values = self._attr_matches(text)
89+
except ValueError:
90+
return []
91+
92+
if not names:
93+
return []
94+
95+
if len(names) == 1:
96+
# No coloring: when returning a single completion, readline
97+
# inserts it directly into the prompt, so ANSI codes would
98+
# appear as literal characters.
99+
return [self._callable_attr_postfix(values[0], f'{expr}.{names[0]}')]
100+
101+
prefix = commonprefix(names)
102+
if prefix and prefix != attr:
103+
return [f'{expr}.{prefix}'] # autocomplete prefix
104+
105+
names = [f'{expr}.{name}' for name in names]
106+
if self.use_colors:
107+
return self.colorize_matches(names, values)
108+
109+
if prefix:
110+
names.append(' ')
111+
return names
112+
113+
def _attr_matches(self, text):
80114
expr, attr = text.rsplit('.', 1)
81115
if '(' in expr or ')' in expr: # don't call functions
82-
return []
116+
return expr, attr, [], []
83117
try:
84118
thisobject = eval(expr, self.namespace)
85119
except Exception:
86-
return []
120+
return expr, attr, [], []
87121

88122
# get the content of the object, except __builtins__
89123
words = set(dir(thisobject)) - {'__builtins__'}
@@ -112,55 +146,50 @@ def attr_matches(self, text):
112146
word[:n] == attr
113147
and not (noprefix and word[:n+1] == noprefix)
114148
):
115-
try:
116-
val = getattr(thisobject, word)
117-
except Exception:
118-
val = None # Include even if attribute not set
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)
119163

120164
names.append(word)
121-
values.append(val)
165+
values.append(value)
122166
if names or not noprefix:
123167
break
124168
if noprefix == '_':
125169
noprefix = '__'
126170
else:
127171
noprefix = None
128172

129-
if not names:
130-
return []
131-
132-
if len(names) == 1:
133-
return [f'{expr}.{names[0]}'] # only option, no coloring.
134-
135-
prefix = commonprefix(names)
136-
if prefix and prefix != attr:
137-
return [f'{expr}.{prefix}'] # autocomplete prefix
138-
139-
if self.use_colors:
140-
return self.colorize_matches(names, values)
141-
142-
if prefix:
143-
names.append(' ')
144-
return names
173+
return expr, attr, names, values
145174

146175
def colorize_matches(self, names, values):
147-
matches = [self.color_for_obj(i, name, obj)
176+
matches = [self._color_for_obj(i, name, obj)
148177
for i, (name, obj)
149178
in enumerate(zip(names, values))]
150179
# We add a space at the end to prevent the automatic completion of the
151180
# common prefix, which is the ANSI escape sequence.
152181
matches.append(' ')
153182
return matches
154183

155-
def color_for_obj(self, i, name, value):
184+
def _color_for_obj(self, i, name, value):
156185
t = type(value)
157-
color = self.color_by_type(t)
158-
# hack: prepend an (increasing) fake escape sequence,
159-
# so that readline can sort the matches correctly.
160-
N = f"\x1b[{i:03d};00m"
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"
161190
return f"{N}{color}{name}{ANSIColors.RESET}"
162191

163-
def color_by_type(self, t):
192+
def _color_by_type(self, t):
164193
typename = t.__name__
165194
# this is needed e.g. to turn method-wrapper into method_wrapper,
166195
# because if we want _colorize.FancyCompleter to be "dataclassable"

Lib/_pyrepl/readline.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,11 @@ def _setup(namespace: Mapping[str, Any]) -> None:
610610
if not isinstance(namespace, dict):
611611
namespace = dict(namespace)
612612
_wrapper.config.module_completer = ModuleCompleter(namespace)
613-
completer_cls = RLCompleter if os.getenv("PYTHON_BASIC_COMPLETER") else FancyCompleter
613+
use_basic_completer = (
614+
not sys.flags.ignore_environment
615+
and os.getenv("PYTHON_BASIC_COMPLETER")
616+
)
617+
completer_cls = RLCompleter if use_basic_completer else FancyCompleter
614618
_wrapper.config.readline_completer = completer_cls(namespace).complete
615619

616620
# this is not really what readline.c does. Better than nothing I guess

0 commit comments

Comments
 (0)