Skip to content

Commit 1c4408a

Browse files
antocunipablogsal
andauthored
gh-130472: Integrate fancycompleter with the new repl, to get colored tab completions (#130473)
Co-authored-by: Pablo Galindo <pablogsal@gmail.com>
1 parent 1f36a51 commit 1c4408a

File tree

8 files changed

+595
-7
lines changed

8 files changed

+595
-7
lines changed

Doc/using/cmdline.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,13 @@ conflict.
13381338

13391339
.. versionadded:: 3.13
13401340

1341+
.. envvar:: PYTHON_BASIC_COMPLETER
1342+
1343+
If this variable is set to any value, PyREPL will use :mod:`rlcompleter` to
1344+
implement tab completion, instead of the default one which uses colors.
1345+
1346+
.. versionadded:: 3.15
1347+
13411348
.. envvar:: PYTHON_HISTORY
13421349

13431350
This environment variable can be used to set the location of a

Lib/_colorize.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
class ANSIColors:
1717
RESET = "\x1b[0m"
18-
1918
BLACK = "\x1b[30m"
2019
BLUE = "\x1b[34m"
2120
CYAN = "\x1b[36m"
@@ -200,6 +199,30 @@ class Difflib(ThemeSection):
200199
reset: str = ANSIColors.RESET
201200

202201

202+
@dataclass(frozen=True, kw_only=True)
203+
class FancyCompleter(ThemeSection):
204+
# functions and methods
205+
function: str = ANSIColors.BOLD_BLUE
206+
builtin_function_or_method: str = ANSIColors.BOLD_BLUE
207+
method: str = ANSIColors.BOLD_CYAN
208+
method_wrapper: str = ANSIColors.BOLD_CYAN
209+
wrapper_descriptor: str = ANSIColors.BOLD_CYAN
210+
method_descriptor: str = ANSIColors.BOLD_CYAN
211+
212+
# numbers
213+
int: str = ANSIColors.BOLD_YELLOW
214+
float: str = ANSIColors.BOLD_YELLOW
215+
complex: str = ANSIColors.BOLD_YELLOW
216+
bool: str = ANSIColors.BOLD_YELLOW
217+
218+
# others
219+
type: str = ANSIColors.BOLD_MAGENTA
220+
module: str = ANSIColors.CYAN
221+
NoneType: str = ANSIColors.GREY
222+
bytes: str = ANSIColors.BOLD_GREEN
223+
str: str = ANSIColors.BOLD_GREEN
224+
225+
203226
@dataclass(frozen=True, kw_only=True)
204227
class LiveProfiler(ThemeSection):
205228
"""Theme section for the live profiling TUI (Tachyon profiler).
@@ -354,6 +377,7 @@ class Theme:
354377
"""
355378
argparse: Argparse = field(default_factory=Argparse)
356379
difflib: Difflib = field(default_factory=Difflib)
380+
fancycompleter: FancyCompleter = field(default_factory=FancyCompleter)
357381
live_profiler: LiveProfiler = field(default_factory=LiveProfiler)
358382
syntax: Syntax = field(default_factory=Syntax)
359383
traceback: Traceback = field(default_factory=Traceback)
@@ -364,6 +388,7 @@ def copy_with(
364388
*,
365389
argparse: Argparse | None = None,
366390
difflib: Difflib | None = None,
391+
fancycompleter: FancyCompleter | None = None,
367392
live_profiler: LiveProfiler | None = None,
368393
syntax: Syntax | None = None,
369394
traceback: Traceback | None = None,
@@ -377,6 +402,7 @@ def copy_with(
377402
return type(self)(
378403
argparse=argparse or self.argparse,
379404
difflib=difflib or self.difflib,
405+
fancycompleter=fancycompleter or self.fancycompleter,
380406
live_profiler=live_profiler or self.live_profiler,
381407
syntax=syntax or self.syntax,
382408
traceback=traceback or self.traceback,
@@ -394,6 +420,7 @@ def no_colors(cls) -> Self:
394420
return cls(
395421
argparse=Argparse.no_colors(),
396422
difflib=Difflib.no_colors(),
423+
fancycompleter=FancyCompleter.no_colors(),
397424
live_profiler=LiveProfiler.no_colors(),
398425
syntax=Syntax.no_colors(),
399426
traceback=Traceback.no_colors(),

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: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Copyright 2010-2025 Antonio Cuni
2+
# Daniel Hahler
3+
#
4+
# All Rights Reserved
5+
"""Colorful tab completion for Python prompt"""
6+
from _colorize import ANSIColors, get_colors, get_theme
7+
import rlcompleter
8+
import keyword
9+
import types
10+
11+
class Completer(rlcompleter.Completer):
12+
"""
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.
15+
16+
Optionally, display the various completions in different colors
17+
depending on the type.
18+
"""
19+
def __init__(
20+
self,
21+
namespace=None,
22+
*,
23+
use_colors='auto',
24+
consider_getitems=True,
25+
):
26+
from _pyrepl import readline
27+
rlcompleter.Completer.__init__(self, namespace)
28+
if use_colors == 'auto':
29+
# use colors only if we can
30+
use_colors = get_colors().RED != ""
31+
self.use_colors = use_colors
32+
self.consider_getitems = consider_getitems
33+
34+
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().
39+
readline.parse_and_bind('set dont-escape-ctrl-chars on')
40+
self.theme = get_theme()
41+
else:
42+
self.theme = None
43+
44+
if self.consider_getitems:
45+
delims = readline.get_completer_delims()
46+
delims = delims.replace('[', '')
47+
delims = delims.replace(']', '')
48+
readline.set_completer_delims(delims)
49+
50+
def complete(self, text, state):
51+
# if you press <tab> at the beginning of a line, insert an actual
52+
# \t. Else, trigger completion.
53+
if text == "":
54+
return ('\t', None)[state]
55+
else:
56+
return rlcompleter.Completer.complete(self, text, state)
57+
58+
def _callable_postfix(self, val, word):
59+
# disable automatic insertion of '(' for global callables
60+
return word
61+
62+
def _callable_attr_postfix(self, val, word):
63+
return rlcompleter.Completer._callable_postfix(self, val, word)
64+
65+
def global_matches(self, text):
66+
names = rlcompleter.Completer.global_matches(self, text)
67+
prefix = commonprefix(names)
68+
if prefix and prefix != text:
69+
return [prefix]
70+
71+
names.sort()
72+
values = []
73+
for name in names:
74+
clean_name = name.rstrip(': ')
75+
if keyword.iskeyword(clean_name) or keyword.issoftkeyword(clean_name):
76+
values.append(None)
77+
else:
78+
try:
79+
values.append(eval(name, self.namespace))
80+
except Exception:
81+
values.append(None)
82+
if self.use_colors and names:
83+
return self.colorize_matches(names, values)
84+
return names
85+
86+
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):
114+
expr, attr = text.rsplit('.', 1)
115+
if '(' in expr or ')' in expr: # don't call functions
116+
return expr, attr, [], []
117+
try:
118+
thisobject = eval(expr, self.namespace)
119+
except Exception:
120+
return expr, attr, [], []
121+
122+
# get the content of the object, except __builtins__
123+
words = set(dir(thisobject)) - {'__builtins__'}
124+
125+
if hasattr(thisobject, '__class__'):
126+
words.add('__class__')
127+
words.update(rlcompleter.get_class_members(thisobject.__class__))
128+
names = []
129+
values = []
130+
n = len(attr)
131+
if attr == '':
132+
noprefix = '_'
133+
elif attr == '_':
134+
noprefix = '__'
135+
else:
136+
noprefix = None
137+
138+
# sort the words now to make sure to return completions in
139+
# alphabetical order. It's easier to do it now, else we would need to
140+
# sort 'names' later but make sure that 'values' in kept in sync,
141+
# which is annoying.
142+
words = sorted(words)
143+
while True:
144+
for word in words:
145+
if (
146+
word[:n] == attr
147+
and not (noprefix and word[:n+1] == noprefix)
148+
):
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+
164+
names.append(word)
165+
values.append(value)
166+
if names or not noprefix:
167+
break
168+
if noprefix == '_':
169+
noprefix = '__'
170+
else:
171+
noprefix = None
172+
173+
return expr, attr, names, values
174+
175+
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)
199+
200+
201+
def commonprefix(names):
202+
"""Return the common prefix of all 'names'"""
203+
if not names:
204+
return ''
205+
s1 = min(names)
206+
s2 = max(names)
207+
for i, c in enumerate(s1):
208+
if c != s2[i]:
209+
return s1[:i]
210+
return s1

Lib/_pyrepl/readline.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .completing_reader import CompletingReader
4141
from .console import Console as ConsoleType
4242
from ._module_completer import ModuleCompleter, make_default_module_completer
43+
from .fancycompleter import Completer as FancyCompleter
4344

4445
Console: type[ConsoleType]
4546
_error: tuple[type[Exception], ...] | type[Exception]
@@ -609,7 +610,12 @@ def _setup(namespace: Mapping[str, Any]) -> None:
609610
if not isinstance(namespace, dict):
610611
namespace = dict(namespace)
611612
_wrapper.config.module_completer = ModuleCompleter(namespace)
612-
_wrapper.config.readline_completer = RLCompleter(namespace).complete
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
618+
_wrapper.config.readline_completer = completer_cls(namespace).complete
613619

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

0 commit comments

Comments
 (0)