From d7eff96a750e06ac9e0d51ce6b92b9be5cd864fc Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 16:34:07 +0100 Subject: [PATCH 01/49] import fancycompleter from https://github.com/pdbpp/fancycompleter/commit/67e3ec128cf8d44be6e48e775234c07f4b23064e --- Lib/_pyrepl/fancycompleter.py | 545 ++++++++++++++++++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 Lib/_pyrepl/fancycompleter.py diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py new file mode 100644 index 00000000000000..f6319ca23d9674 --- /dev/null +++ b/Lib/_pyrepl/fancycompleter.py @@ -0,0 +1,545 @@ +""" +fancycompleter: colorful TAB completion for Python prompt +""" +from __future__ import with_statement +from __future__ import print_function + +import rlcompleter +import sys +import types +import os.path +from itertools import count + +PY3K = sys.version_info[0] >= 3 + +# python3 compatibility +# --------------------- +try: + from itertools import izip +except ImportError: + izip = zip + +try: + from types import ClassType +except ImportError: + ClassType = type + +try: + unicode +except NameError: + unicode = str + +# ---------------------- + + +class LazyVersion(object): + + def __init__(self, pkg): + self.pkg = pkg + self.__version = None + + @property + def version(self): + if self.__version is None: + self.__version = self._load_version() + return self.__version + + def _load_version(self): + try: + from pkg_resources import get_distribution, DistributionNotFound + except ImportError: + return 'N/A' + # + try: + return get_distribution(self.pkg).version + except DistributionNotFound: + # package is not installed + return 'N/A' + + def __repr__(self): + return self.version + + def __eq__(self, other): + return self.version == other + + def __ne__(self, other): + return not self == other + + +__version__ = LazyVersion(__name__) + +# ---------------------- + + +class Color: + black = '30' + darkred = '31' + darkgreen = '32' + brown = '33' + darkblue = '34' + purple = '35' + teal = '36' + lightgray = '37' + darkgray = '30;01' + red = '31;01' + green = '32;01' + yellow = '33;01' + blue = '34;01' + fuchsia = '35;01' + turquoise = '36;01' + white = '37;01' + + @classmethod + def set(cls, color, string): + try: + color = getattr(cls, color) + except AttributeError: + pass + return '\x1b[%sm%s\x1b[00m' % (color, string) + + +class DefaultConfig: + + consider_getitems = True + prefer_pyrepl = True + use_colors = 'auto' + readline = None # set by setup() + using_pyrepl = False # overwritten by find_pyrepl + + color_by_type = { + types.BuiltinMethodType: Color.turquoise, + types.MethodType: Color.turquoise, + type((42).__add__): Color.turquoise, + type(int.__add__): Color.turquoise, + type(str.replace): Color.turquoise, + + types.FunctionType: Color.blue, + types.BuiltinFunctionType: Color.blue, + + ClassType: Color.fuchsia, + type: Color.fuchsia, + + types.ModuleType: Color.teal, + type(None): Color.lightgray, + str: Color.green, + unicode: Color.green, + int: Color.yellow, + float: Color.yellow, + complex: Color.yellow, + bool: Color.yellow, + } + # Fallback to look up colors by `isinstance` when not matched + # via color_by_type. + color_by_baseclass = [ + ((BaseException,), Color.red), + ] + + def find_pyrepl(self): + try: + import pyrepl.readline + import pyrepl.completing_reader + except ImportError: + return None + self.using_pyrepl = True + if hasattr(pyrepl.completing_reader, 'stripcolor'): + # modern version of pyrepl + return pyrepl.readline, True + else: + return pyrepl.readline, False + + def find_pyreadline(self): + try: + import readline + import pyreadline # noqa: F401 # XXX: needed really? + from pyreadline.modes import basemode + except ImportError: + return None + if hasattr(basemode, 'stripcolor'): + # modern version of pyreadline; see: + # https://github.com/pyreadline/pyreadline/pull/48 + return readline, True + else: + return readline, False + + def find_best_readline(self): + if self.prefer_pyrepl: + result = self.find_pyrepl() + if result: + return result + if sys.platform == 'win32': + result = self.find_pyreadline() + if result: + return result + import readline + return readline, False # by default readline does not support colors + + def setup(self): + self.readline, supports_color = self.find_best_readline() + if self.use_colors == 'auto': + self.use_colors = supports_color + + +def my_execfile(filename, mydict): + with open(filename) as f: + code = compile(f.read(), filename, 'exec') + exec(code, mydict) + + +class ConfigurableClass: + DefaultConfig = None + config_filename = None + + def get_config(self, Config): + if Config is not None: + return Config() + # try to load config from the ~/filename file + filename = '~/' + self.config_filename + rcfile = os.path.normpath(os.path.expanduser(filename)) + if not os.path.exists(rcfile): + return self.DefaultConfig() + + mydict = {} + try: + my_execfile(rcfile, mydict) + except Exception as exc: + import traceback + + sys.stderr.write("** error when importing %s: %r **\n" % (filename, exc)) + traceback.print_tb(sys.exc_info()[2]) + return self.DefaultConfig() + + try: + Config = mydict["Config"] + except KeyError: + return self.DefaultConfig() + + try: + return Config() + except Exception as exc: + err = "error when setting up Config from %s: %s" % (filename, exc) + tb = sys.exc_info()[2] + if tb and tb.tb_next: + tb = tb.tb_next + err_fname = tb.tb_frame.f_code.co_filename + err_lnum = tb.tb_lineno + err += " (%s:%d)" % (err_fname, err_lnum,) + sys.stderr.write("** %s **\n" % err) + return self.DefaultConfig() + + +class Completer(rlcompleter.Completer, ConfigurableClass): + """ + When doing someting like a.b., display only the attributes of + b instead of the full a.b.attr string. + + Optionally, display the various completions in different colors + depending on the type. + """ + + DefaultConfig = DefaultConfig + config_filename = '.fancycompleterrc.py' + + def __init__(self, namespace=None, Config=None): + rlcompleter.Completer.__init__(self, namespace) + self.config = self.get_config(Config) + self.config.setup() + readline = self.config.readline + if hasattr(readline, '_setup'): + # this is needed to offer pyrepl a better chance to patch + # raw_input. Usually, it does at import time, but is we are under + # pytest with output captured, at import time we don't have a + # terminal and thus the raw_input hook is not installed + readline._setup() + if self.config.use_colors: + readline.parse_and_bind('set dont-escape-ctrl-chars on') + if self.config.consider_getitems: + delims = readline.get_completer_delims() + delims = delims.replace('[', '') + delims = delims.replace(']', '') + readline.set_completer_delims(delims) + + def complete(self, text, state): + """ + stolen from: + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496812 + """ + if text == "": + return ['\t', None][state] + else: + return rlcompleter.Completer.complete(self, text, state) + + def _callable_postfix(self, val, word): + # disable automatic insertion of '(' for global callables: + # this method exists only in Python 2.6+ + return word + + def global_matches(self, text): + import keyword + names = rlcompleter.Completer.global_matches(self, text) + prefix = commonprefix(names) + if prefix and prefix != text: + return [prefix] + + names.sort() + values = [] + for name in names: + clean_name = name.rstrip(': ') + if clean_name in keyword.kwlist: + values.append(None) + else: + try: + values.append(eval(name, self.namespace)) + except Exception as exc: + values.append(exc) + if self.config.use_colors and names: + return self.color_matches(names, values) + return names + + def attr_matches(self, text): + expr, attr = text.rsplit('.', 1) + if '(' in expr or ')' in expr: # don't call functions + return [] + try: + thisobject = eval(expr, self.namespace) + except Exception: + return [] + + # get the content of the object, except __builtins__ + words = set(dir(thisobject)) + words.discard("__builtins__") + + if hasattr(thisobject, '__class__'): + words.add('__class__') + words.update(rlcompleter.get_class_members(thisobject.__class__)) + names = [] + values = [] + n = len(attr) + if attr == '': + noprefix = '_' + elif attr == '_': + noprefix = '__' + else: + noprefix = None + words = sorted(words) + while True: + for word in words: + if (word[:n] == attr and + not (noprefix and word[:n+1] == noprefix)): + try: + val = getattr(thisobject, word) + except Exception: + val = None # Include even if attribute not set + + if not PY3K and isinstance(word, unicode): + # this is needed because pyrepl doesn't like unicode + # completions: as soon as it finds something which is not str, + # it stops. + word = word.encode('utf-8') + + names.append(word) + values.append(val) + if names or not noprefix: + break + if noprefix == '_': + noprefix = '__' + else: + noprefix = None + + if not names: + return [] + + if len(names) == 1: + return ['%s.%s' % (expr, names[0])] # only option, no coloring. + + prefix = commonprefix(names) + if prefix and prefix != attr: + return ['%s.%s' % (expr, prefix)] # autocomplete prefix + + if self.config.use_colors: + return self.color_matches(names, values) + + if prefix: + names += [' '] + return names + + def color_matches(self, names, values): + matches = [self.color_for_obj(i, name, obj) + for i, name, obj + in izip(count(), names, values)] + # We add a space at the end to prevent the automatic completion of the + # common prefix, which is the ANSI ESCAPE sequence. + return matches + [' '] + + def color_for_obj(self, i, name, value): + t = type(value) + color = self.config.color_by_type.get(t, None) + if color is None: + for x, _color in self.config.color_by_baseclass: + if isinstance(value, x): + color = _color + break + else: + color = '00' + # hack: prepend an (increasing) fake escape sequence, + # so that readline can sort the matches correctly. + return '\x1b[%03d;00m' % i + Color.set(color, name) + + +def commonprefix(names, base=''): + """ return the common prefix of all 'names' starting with 'base' + """ + if base: + names = [x for x in names if x.startswith(base)] + if not names: + return '' + s1 = min(names) + s2 = max(names) + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + return s1 + + +def has_leopard_libedit(config): + # Detect if we are using Leopard's libedit. + # Adapted from IPython's rlineimpl.py. + if config.using_pyrepl or sys.platform != 'darwin': + return False + + # Official Python docs state that 'libedit' is in the docstring for + # libedit readline. + return config.readline.__doc__ and 'libedit' in config.readline.__doc__ + + +def setup(): + """ + Install fancycompleter as the default completer for readline. + """ + completer = Completer() + readline = completer.config.readline + if has_leopard_libedit(completer.config): + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind('tab: complete') + readline.set_completer(completer.complete) + return completer + + +def interact_pyrepl(): + import sys + from pyrepl import readline + from pyrepl.simple_interact import run_multiline_interactive_console + sys.modules['readline'] = readline + run_multiline_interactive_console() + + +def setup_history(completer, persist_history): + import atexit + readline = completer.config.readline + # + if isinstance(persist_history, (str, unicode)): + filename = persist_history + else: + filename = '~/.history.py' + filename = os.path.expanduser(filename) + if os.path.isfile(filename): + readline.read_history_file(filename) + + def save_history(): + readline.write_history_file(filename) + atexit.register(save_history) + + +def interact(persist_history=None): + """ + Main entry point for fancycompleter: run an interactive Python session + after installing fancycompleter. + + This function is supposed to be called at the end of PYTHONSTARTUP: + + - if we are using pyrepl: install fancycompleter, run pyrepl multiline + prompt, and sys.exit(). The standard python prompt will never be + reached + + - if we are not using pyrepl: install fancycompleter and return. The + execution will continue as normal, and the standard python prompt will + be displayed. + + This is necessary because there is no way to tell the standard python + prompt to use the readline provided by pyrepl instead of the builtin one. + + By default, pyrepl is preferred and automatically used if found. + """ + import sys + completer = setup() + if persist_history: + setup_history(completer, persist_history) + if completer.config.using_pyrepl and '__pypy__' not in sys.builtin_module_names: + # if we are on PyPy, we don't need to run a "fake" interpeter, as the + # standard one is fake enough :-) + interact_pyrepl() + sys.exit() + + +class Installer(object): + """ + Helper to install fancycompleter in PYTHONSTARTUP + """ + + def __init__(self, basepath, force): + fname = os.path.join(basepath, 'python_startup.py') + self.filename = os.path.expanduser(fname) + self.force = force + + def check(self): + PYTHONSTARTUP = os.environ.get('PYTHONSTARTUP') + if PYTHONSTARTUP: + return 'PYTHONSTARTUP already defined: %s' % PYTHONSTARTUP + if os.path.exists(self.filename): + return '%s already exists' % self.filename + + def install(self): + import textwrap + error = self.check() + if error and not self.force: + print(error) + print('Use --force to overwrite.') + return False + with open(self.filename, 'w') as f: + f.write(textwrap.dedent(""" + import fancycompleter + fancycompleter.interact(persist_history=True) + """)) + self.set_env_var() + return True + + def set_env_var(self): + if sys.platform == 'win32': + os.system('SETX PYTHONSTARTUP "%s"' % self.filename) + print('%PYTHONSTARTUP% set to', self.filename) + else: + print('startup file written to', self.filename) + print('Append this line to your ~/.bashrc:') + print(' export PYTHONSTARTUP=%s' % self.filename) + + +if __name__ == '__main__': + def usage(): + print('Usage: python -m fancycompleter install [-f|--force]') + sys.exit(1) + + cmd = None + force = False + for item in sys.argv[1:]: + if item in ('install',): + cmd = item + elif item in ('-f', '--force'): + force = True + else: + usage() + # + if cmd == 'install': + installer = Installer('~', force) + installer.install() + else: + usage() From e22a210ace88f1266d822e61b044015a4f809f4f Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 16:45:15 +0100 Subject: [PATCH 02/49] add copyright notice --- Lib/_pyrepl/fancycompleter.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index f6319ca23d9674..f1da6916ab741c 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -1,5 +1,9 @@ +# Copyright 2010-2025 Antonio Cuni +# Daniel Hahler +# +# All Rights Reserved """ -fancycompleter: colorful TAB completion for Python prompt +Colorful TAB completion for Python prompt """ from __future__ import with_statement from __future__ import print_function From 374eff9de5d981efebc14927e30e87ab83e4ff6e Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 16:55:15 +0100 Subject: [PATCH 03/49] enable FancyCompleter by default, unless you set PYTHON_BASIC_COMPLETER --- Lib/_pyrepl/readline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..1148b840e77852 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -35,6 +35,7 @@ from site import gethistoryfile import sys from rlcompleter import Completer as RLCompleter +from .fancycompleter import Completer as FancyCompleter from . import commands, historical_reader from .completing_reader import CompletingReader @@ -609,7 +610,8 @@ def _setup(namespace: Mapping[str, Any]) -> None: if not isinstance(namespace, dict): namespace = dict(namespace) _wrapper.config.module_completer = ModuleCompleter(namespace) - _wrapper.config.readline_completer = RLCompleter(namespace).complete + completer_cls = RLCompleter if os.getenv("PYTHON_BASIC_COMPLETER") else FancyCompleter + _wrapper.config.readline_completer = completer_cls(namespace).complete # this is not really what readline.c does. Better than nothing I guess import builtins From 9eeca3ae94c9ed18322ea4bec5094f4588ea0b4c Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:03:54 +0100 Subject: [PATCH 04/49] WIP: kill a lot of code which is no longer necessary --- Lib/_pyrepl/fancycompleter.py | 145 ---------------------------------- 1 file changed, 145 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index f1da6916ab741c..9148da47dfec70 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -402,148 +402,3 @@ def commonprefix(names, base=''): if c != s2[i]: return s1[:i] return s1 - - -def has_leopard_libedit(config): - # Detect if we are using Leopard's libedit. - # Adapted from IPython's rlineimpl.py. - if config.using_pyrepl or sys.platform != 'darwin': - return False - - # Official Python docs state that 'libedit' is in the docstring for - # libedit readline. - return config.readline.__doc__ and 'libedit' in config.readline.__doc__ - - -def setup(): - """ - Install fancycompleter as the default completer for readline. - """ - completer = Completer() - readline = completer.config.readline - if has_leopard_libedit(completer.config): - readline.parse_and_bind("bind ^I rl_complete") - else: - readline.parse_and_bind('tab: complete') - readline.set_completer(completer.complete) - return completer - - -def interact_pyrepl(): - import sys - from pyrepl import readline - from pyrepl.simple_interact import run_multiline_interactive_console - sys.modules['readline'] = readline - run_multiline_interactive_console() - - -def setup_history(completer, persist_history): - import atexit - readline = completer.config.readline - # - if isinstance(persist_history, (str, unicode)): - filename = persist_history - else: - filename = '~/.history.py' - filename = os.path.expanduser(filename) - if os.path.isfile(filename): - readline.read_history_file(filename) - - def save_history(): - readline.write_history_file(filename) - atexit.register(save_history) - - -def interact(persist_history=None): - """ - Main entry point for fancycompleter: run an interactive Python session - after installing fancycompleter. - - This function is supposed to be called at the end of PYTHONSTARTUP: - - - if we are using pyrepl: install fancycompleter, run pyrepl multiline - prompt, and sys.exit(). The standard python prompt will never be - reached - - - if we are not using pyrepl: install fancycompleter and return. The - execution will continue as normal, and the standard python prompt will - be displayed. - - This is necessary because there is no way to tell the standard python - prompt to use the readline provided by pyrepl instead of the builtin one. - - By default, pyrepl is preferred and automatically used if found. - """ - import sys - completer = setup() - if persist_history: - setup_history(completer, persist_history) - if completer.config.using_pyrepl and '__pypy__' not in sys.builtin_module_names: - # if we are on PyPy, we don't need to run a "fake" interpeter, as the - # standard one is fake enough :-) - interact_pyrepl() - sys.exit() - - -class Installer(object): - """ - Helper to install fancycompleter in PYTHONSTARTUP - """ - - def __init__(self, basepath, force): - fname = os.path.join(basepath, 'python_startup.py') - self.filename = os.path.expanduser(fname) - self.force = force - - def check(self): - PYTHONSTARTUP = os.environ.get('PYTHONSTARTUP') - if PYTHONSTARTUP: - return 'PYTHONSTARTUP already defined: %s' % PYTHONSTARTUP - if os.path.exists(self.filename): - return '%s already exists' % self.filename - - def install(self): - import textwrap - error = self.check() - if error and not self.force: - print(error) - print('Use --force to overwrite.') - return False - with open(self.filename, 'w') as f: - f.write(textwrap.dedent(""" - import fancycompleter - fancycompleter.interact(persist_history=True) - """)) - self.set_env_var() - return True - - def set_env_var(self): - if sys.platform == 'win32': - os.system('SETX PYTHONSTARTUP "%s"' % self.filename) - print('%PYTHONSTARTUP% set to', self.filename) - else: - print('startup file written to', self.filename) - print('Append this line to your ~/.bashrc:') - print(' export PYTHONSTARTUP=%s' % self.filename) - - -if __name__ == '__main__': - def usage(): - print('Usage: python -m fancycompleter install [-f|--force]') - sys.exit(1) - - cmd = None - force = False - for item in sys.argv[1:]: - if item in ('install',): - cmd = item - elif item in ('-f', '--force'): - force = True - else: - usage() - # - if cmd == 'install': - installer = Installer('~', force) - installer.install() - else: - usage() From 1c5d27dc12424999ff55bd3ca989c4283d77bd89 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:08:26 +0100 Subject: [PATCH 05/49] force colors for now --- Lib/_pyrepl/fancycompleter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 9148da47dfec70..cfb907e8359031 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -180,7 +180,8 @@ def find_best_readline(self): def setup(self): self.readline, supports_color = self.find_best_readline() if self.use_colors == 'auto': - self.use_colors = supports_color + #self.use_colors = supports_color + self.use_colors = True def my_execfile(filename, mydict): From c621cf528fd6ba9adeac3801e04ca467576a2b0c Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:19:55 +0100 Subject: [PATCH 06/49] kill the logic to find a readline, we can always use _pyrepl.readline now --- Lib/_pyrepl/fancycompleter.py | 56 ++++++----------------------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index cfb907e8359031..e7d869dea7ccc9 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -8,6 +8,7 @@ from __future__ import with_statement from __future__ import print_function +from _pyrepl import readline import rlcompleter import sys import types @@ -105,10 +106,7 @@ def set(cls, color, string): class DefaultConfig: consider_getitems = True - prefer_pyrepl = True use_colors = 'auto' - readline = None # set by setup() - using_pyrepl = False # overwritten by find_pyrepl color_by_type = { types.BuiltinMethodType: Color.turquoise, @@ -138,50 +136,12 @@ class DefaultConfig: ((BaseException,), Color.red), ] - def find_pyrepl(self): - try: - import pyrepl.readline - import pyrepl.completing_reader - except ImportError: - return None - self.using_pyrepl = True - if hasattr(pyrepl.completing_reader, 'stripcolor'): - # modern version of pyrepl - return pyrepl.readline, True - else: - return pyrepl.readline, False - - def find_pyreadline(self): - try: - import readline - import pyreadline # noqa: F401 # XXX: needed really? - from pyreadline.modes import basemode - except ImportError: - return None - if hasattr(basemode, 'stripcolor'): - # modern version of pyreadline; see: - # https://github.com/pyreadline/pyreadline/pull/48 - return readline, True - else: - return readline, False - - def find_best_readline(self): - if self.prefer_pyrepl: - result = self.find_pyrepl() - if result: - return result - if sys.platform == 'win32': - result = self.find_pyreadline() - if result: - return result - import readline - return readline, False # by default readline does not support colors def setup(self): - self.readline, supports_color = self.find_best_readline() + import _colorize if self.use_colors == 'auto': - #self.use_colors = supports_color - self.use_colors = True + colors = _colorize.get_colors() + self.use_colors = colors.RED != "" def my_execfile(filename, mydict): @@ -242,19 +202,21 @@ class Completer(rlcompleter.Completer, ConfigurableClass): """ DefaultConfig = DefaultConfig - config_filename = '.fancycompleterrc.py' + config_filename = '.fancycompleterrc.py.xxx' def __init__(self, namespace=None, Config=None): rlcompleter.Completer.__init__(self, namespace) self.config = self.get_config(Config) self.config.setup() - readline = self.config.readline - if hasattr(readline, '_setup'): + + # XXX: double check what happens in this case once fancycompleter works + if False and hasattr(readline, '_setup'): # this is needed to offer pyrepl a better chance to patch # raw_input. Usually, it does at import time, but is we are under # pytest with output captured, at import time we don't have a # terminal and thus the raw_input hook is not installed readline._setup() + if self.config.use_colors: readline.parse_and_bind('set dont-escape-ctrl-chars on') if self.config.consider_getitems: From d839aa100e3777a4383dd0ba51447e140528f11b Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:22:36 +0100 Subject: [PATCH 07/49] kill LazyVersion --- Lib/_pyrepl/fancycompleter.py | 40 ----------------------------------- 1 file changed, 40 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index e7d869dea7ccc9..6769a0bbeb0ae7 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -36,46 +36,6 @@ # ---------------------- - -class LazyVersion(object): - - def __init__(self, pkg): - self.pkg = pkg - self.__version = None - - @property - def version(self): - if self.__version is None: - self.__version = self._load_version() - return self.__version - - def _load_version(self): - try: - from pkg_resources import get_distribution, DistributionNotFound - except ImportError: - return 'N/A' - # - try: - return get_distribution(self.pkg).version - except DistributionNotFound: - # package is not installed - return 'N/A' - - def __repr__(self): - return self.version - - def __eq__(self, other): - return self.version == other - - def __ne__(self, other): - return not self == other - - -__version__ = LazyVersion(__name__) - -# ---------------------- - - class Color: black = '30' darkred = '31' From f89b9ef1836861124402c91f2c4eeecadac042f2 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:27:04 +0100 Subject: [PATCH 08/49] we surely don't need to support python 2.7 now :) --- Lib/_pyrepl/fancycompleter.py | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 6769a0bbeb0ae7..9881f9e5acdaff 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -15,27 +15,6 @@ import os.path from itertools import count -PY3K = sys.version_info[0] >= 3 - -# python3 compatibility -# --------------------- -try: - from itertools import izip -except ImportError: - izip = zip - -try: - from types import ClassType -except ImportError: - ClassType = type - -try: - unicode -except NameError: - unicode = str - -# ---------------------- - class Color: black = '30' darkred = '31' @@ -78,13 +57,12 @@ class DefaultConfig: types.FunctionType: Color.blue, types.BuiltinFunctionType: Color.blue, - ClassType: Color.fuchsia, type: Color.fuchsia, types.ModuleType: Color.teal, type(None): Color.lightgray, str: Color.green, - unicode: Color.green, + bytes: Color.green, int: Color.yellow, float: Color.yellow, complex: Color.yellow, @@ -257,12 +235,6 @@ def attr_matches(self, text): except Exception: val = None # Include even if attribute not set - if not PY3K and isinstance(word, unicode): - # this is needed because pyrepl doesn't like unicode - # completions: as soon as it finds something which is not str, - # it stops. - word = word.encode('utf-8') - names.append(word) values.append(val) if names or not noprefix: @@ -292,7 +264,7 @@ def attr_matches(self, text): def color_matches(self, names, values): matches = [self.color_for_obj(i, name, obj) for i, name, obj - in izip(count(), names, values)] + in zip(count(), names, values)] # We add a space at the end to prevent the automatic completion of the # common prefix, which is the ANSI ESCAPE sequence. return matches + [' '] From 04b5feee9190d0ba62194142aab6f4694fd9ab88 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:28:24 +0100 Subject: [PATCH 09/49] kill ConfigurableClass --- Lib/_pyrepl/fancycompleter.py | 54 ++--------------------------------- 1 file changed, 3 insertions(+), 51 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 9881f9e5acdaff..0848b09c85f505 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -82,55 +82,7 @@ def setup(self): self.use_colors = colors.RED != "" -def my_execfile(filename, mydict): - with open(filename) as f: - code = compile(f.read(), filename, 'exec') - exec(code, mydict) - - -class ConfigurableClass: - DefaultConfig = None - config_filename = None - - def get_config(self, Config): - if Config is not None: - return Config() - # try to load config from the ~/filename file - filename = '~/' + self.config_filename - rcfile = os.path.normpath(os.path.expanduser(filename)) - if not os.path.exists(rcfile): - return self.DefaultConfig() - - mydict = {} - try: - my_execfile(rcfile, mydict) - except Exception as exc: - import traceback - - sys.stderr.write("** error when importing %s: %r **\n" % (filename, exc)) - traceback.print_tb(sys.exc_info()[2]) - return self.DefaultConfig() - - try: - Config = mydict["Config"] - except KeyError: - return self.DefaultConfig() - - try: - return Config() - except Exception as exc: - err = "error when setting up Config from %s: %s" % (filename, exc) - tb = sys.exc_info()[2] - if tb and tb.tb_next: - tb = tb.tb_next - err_fname = tb.tb_frame.f_code.co_filename - err_lnum = tb.tb_lineno - err += " (%s:%d)" % (err_fname, err_lnum,) - sys.stderr.write("** %s **\n" % err) - return self.DefaultConfig() - - -class Completer(rlcompleter.Completer, ConfigurableClass): +class Completer(rlcompleter.Completer): """ When doing someting like a.b., display only the attributes of b instead of the full a.b.attr string. @@ -142,9 +94,9 @@ class Completer(rlcompleter.Completer, ConfigurableClass): DefaultConfig = DefaultConfig config_filename = '.fancycompleterrc.py.xxx' - def __init__(self, namespace=None, Config=None): + def __init__(self, namespace=None, Config=DefaultConfig): rlcompleter.Completer.__init__(self, namespace) - self.config = self.get_config(Config) + self.config = Config() self.config.setup() # XXX: double check what happens in this case once fancycompleter works From d733675108d8884ec901b362e440938955c40330 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:36:28 +0100 Subject: [PATCH 10/49] better name --- Lib/_pyrepl/fancycompleter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 0848b09c85f505..1b000dfe27e95c 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -207,13 +207,13 @@ def attr_matches(self, text): return ['%s.%s' % (expr, prefix)] # autocomplete prefix if self.config.use_colors: - return self.color_matches(names, values) + return self.colorize_matches(names, values) if prefix: names += [' '] return names - def color_matches(self, names, values): + def colorize_matches(self, names, values): matches = [self.color_for_obj(i, name, obj) for i, name, obj in zip(count(), names, values)] From 116a634609fb3e071caf5cb35fdb10eba407eaf1 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 17:54:42 +0100 Subject: [PATCH 11/49] use _colorize instead of our own Color --- Lib/_colorize.py | 3 +- Lib/_pyrepl/fancycompleter.py | 72 ++++++++++++----------------------- 2 files changed, 26 insertions(+), 49 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index fd0ae9d6145961..2bf3da0f3d1398 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -15,7 +15,6 @@ class ANSIColors: RESET = "\x1b[0m" - BLACK = "\x1b[30m" BLUE = "\x1b[34m" CYAN = "\x1b[36m" @@ -24,6 +23,7 @@ class ANSIColors: MAGENTA = "\x1b[35m" RED = "\x1b[31m" WHITE = "\x1b[37m" # more like LIGHT GRAY + TEAL = "\x1b[36m" YELLOW = "\x1b[33m" BOLD = "\x1b[1m" @@ -33,6 +33,7 @@ class ANSIColors: BOLD_GREEN = "\x1b[1;32m" BOLD_MAGENTA = "\x1b[1;35m" BOLD_RED = "\x1b[1;31m" + BOLD_TEAL = "\x1b[1;36m" BOLD_WHITE = "\x1b[1;37m" # actual WHITE BOLD_YELLOW = "\x1b[1;33m" diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 1b000dfe27e95c..4ee2e3b12908b3 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -9,38 +9,13 @@ from __future__ import print_function from _pyrepl import readline +from _colorize import ANSIColors import rlcompleter import sys import types import os.path from itertools import count -class Color: - black = '30' - darkred = '31' - darkgreen = '32' - brown = '33' - darkblue = '34' - purple = '35' - teal = '36' - lightgray = '37' - darkgray = '30;01' - red = '31;01' - green = '32;01' - yellow = '33;01' - blue = '34;01' - fuchsia = '35;01' - turquoise = '36;01' - white = '37;01' - - @classmethod - def set(cls, color, string): - try: - color = getattr(cls, color) - except AttributeError: - pass - return '\x1b[%sm%s\x1b[00m' % (color, string) - class DefaultConfig: @@ -48,30 +23,30 @@ class DefaultConfig: use_colors = 'auto' color_by_type = { - types.BuiltinMethodType: Color.turquoise, - types.MethodType: Color.turquoise, - type((42).__add__): Color.turquoise, - type(int.__add__): Color.turquoise, - type(str.replace): Color.turquoise, - - types.FunctionType: Color.blue, - types.BuiltinFunctionType: Color.blue, - - type: Color.fuchsia, - - types.ModuleType: Color.teal, - type(None): Color.lightgray, - str: Color.green, - bytes: Color.green, - int: Color.yellow, - float: Color.yellow, - complex: Color.yellow, - bool: Color.yellow, + types.BuiltinMethodType: ANSIColors.BOLD_TEAL, + types.MethodType: ANSIColors.BOLD_TEAL, + type((42).__add__): ANSIColors.BOLD_TEAL, + type(int.__add__): ANSIColors.BOLD_TEAL, + type(str.replace): ANSIColors.BOLD_TEAL, + + types.FunctionType: ANSIColors.BOLD_BLUE, + types.BuiltinFunctionType: ANSIColors.BOLD_BLUE, + + type: ANSIColors.BOLD_MAGENTA, + + types.ModuleType: ANSIColors.TEAL, + type(None): ANSIColors.GREY, + str: ANSIColors.BOLD_GREEN, + bytes: ANSIColors.BOLD_GREEN, + int: ANSIColors.BOLD_YELLOW, + float: ANSIColors.BOLD_YELLOW, + complex: ANSIColors.BOLD_YELLOW, + bool: ANSIColors.BOLD_YELLOW, } # Fallback to look up colors by `isinstance` when not matched # via color_by_type. color_by_baseclass = [ - ((BaseException,), Color.red), + ((BaseException,), ANSIColors.BOLD_RED), ] @@ -230,10 +205,11 @@ def color_for_obj(self, i, name, value): color = _color break else: - color = '00' + color = ANSIColors.RESET # hack: prepend an (increasing) fake escape sequence, # so that readline can sort the matches correctly. - return '\x1b[%03d;00m' % i + Color.set(color, name) + N = f"\x1b[{i:03d};00m" + return f"{N}{color}{name}{ANSIColors.RESET}" def commonprefix(names, base=''): From 98d3f865c686783f6d3d68f7b3487dea0507925a Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:25:01 +0100 Subject: [PATCH 12/49] WIP: copy&adapt some tests from the original fancycompleter. They don't work because they need to be ported from pytest to unittest --- Lib/_pyrepl/fancycompleter.py | 7 +- Lib/test/test_pyrepl/test_fancycompleter.py | 209 ++++++++++++++++++++ 2 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 Lib/test/test_pyrepl/test_fancycompleter.py diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 4ee2e3b12908b3..5ad17a47fc9f6e 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -5,15 +5,10 @@ """ Colorful TAB completion for Python prompt """ -from __future__ import with_statement -from __future__ import print_function - from _pyrepl import readline from _colorize import ANSIColors import rlcompleter -import sys import types -import os.path from itertools import count @@ -124,7 +119,7 @@ def global_matches(self, text): except Exception as exc: values.append(exc) if self.config.use_colors and names: - return self.color_matches(names, values) + return self.colorize_matches(names, values) return names def attr_matches(self, text): diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py new file mode 100644 index 00000000000000..ebd4684256f3b1 --- /dev/null +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -0,0 +1,209 @@ +import unittest +import sys + +import _pyrepl.readline +from _pyrepl.fancycompleter import Completer, DefaultConfig, commonprefix + + +class ConfigForTest(DefaultConfig): + use_colors = False + +class ColorConfig(DefaultConfig): + use_colors = True + +class FancyCompleterTests(unittest.TestCase): + + def test_commonprefix(self): + assert commonprefix(['isalpha', 'isdigit', 'foo']) == '' + assert commonprefix(['isalpha', 'isdigit']) == 'is' + assert commonprefix(['isalpha', 'isdigit', 'foo'], base='i') == 'is' + assert commonprefix([]) == '' + assert commonprefix(['aaa', 'bbb'], base='x') == '' + + + def test_complete_attribute(self): + compl = Completer({'a': None}, ConfigForTest) + assert compl.attr_matches('a.') == ['a.__'] + matches = compl.attr_matches('a.__') + assert 'a.__class__' not in matches + assert '__class__' in matches + assert compl.attr_matches('a.__class') == ['a.__class__'] + + + def test_complete_attribute_prefix(self): + class C(object): + attr = 1 + _attr = 2 + __attr__attr = 3 + compl = Completer({'a': C}, ConfigForTest) + assert compl.attr_matches('a.') == ['attr', 'mro'] + assert compl.attr_matches('a._') == ['_C__attr__attr', '_attr', ' '] + matches = compl.attr_matches('a.__') + assert 'a.__class__' not in matches + assert '__class__' in matches + assert compl.attr_matches('a.__class') == ['a.__class__'] + + compl = Completer({'a': None}, ConfigForTest) + assert compl.attr_matches('a._') == ['a.__'] + + + def test_complete_attribute_colored(self): + compl = Completer({'a': 42}, ColorConfig) + matches = compl.attr_matches('a.__') + assert len(matches) > 2 + expected_color = compl.config.color_by_type.get(type(compl.__class__)) + assert expected_color == '35;01' + expected_part = Color.set(expected_color, '__class__') + for match in matches: + if expected_part in match: + break + else: + assert False, matches + assert ' ' in matches + + + def test_complete_colored_single_match(self): + """No coloring, via commonprefix.""" + compl = Completer({'foobar': 42}, ColorConfig) + matches = compl.global_matches('foob') + assert matches == ['foobar'] + + + def test_does_not_color_single_match(self): + class obj: + msgs = [] + + compl = Completer({'obj': obj}, ColorConfig) + matches = compl.attr_matches('obj.msgs') + assert matches == ['obj.msgs'] + + + def test_complete_global(self): + compl = Completer({'foobar': 1, 'foobazzz': 2}, ConfigForTest) + assert compl.global_matches('foo') == ['fooba'] + matches = compl.global_matches('fooba') + assert set(matches) == set(['foobar', 'foobazzz']) + assert compl.global_matches('foobaz') == ['foobazzz'] + assert compl.global_matches('nothing') == [] + + + def test_complete_global_colored(self): + compl = Completer({'foobar': 1, 'foobazzz': 2}, ColorConfig) + assert compl.global_matches('foo') == ['fooba'] + matches = compl.global_matches('fooba') + assert set(matches) == { + ' ', + '\x1b[001;00m\x1b[33;01mfoobazzz\x1b[00m', + '\x1b[000;00m\x1b[33;01mfoobar\x1b[00m', + } + assert compl.global_matches('foobaz') == ['foobazzz'] + assert compl.global_matches('nothing') == [] + + + def test_complete_global_colored_exception(self): + compl = Completer({'tryme': ValueError()}, ColorConfig) + if sys.version_info >= (3, 6): + assert compl.global_matches('try') == [ + '\x1b[000;00m\x1b[37mtry:\x1b[00m', + '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', + ' ' + ] + else: + assert compl.global_matches('try') == [ + '\x1b[000;00m\x1b[37mtry\x1b[00m', + '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', + ' ' + ] + + + def test_complete_global_exception(monkeypatchself): + import rlcompleter + + def rlcompleter_global_matches(self, text): + return ['trigger_exception!', 'nameerror', 'valid'] + + monkeypatch.setattr(rlcompleter.Completer, 'global_matches', + rlcompleter_global_matches) + + compl = Completer({'valid': 42}, ColorConfig) + assert compl.global_matches("") == [ + "\x1b[000;00m\x1b[31;01mnameerror\x1b[00m", + "\x1b[001;00m\x1b[31;01mtrigger_exception!\x1b[00m", + "\x1b[002;00m\x1b[33;01mvalid\x1b[00m", + " ", + ] + + + def test_color_for_obj(monkeypatchself): + class Config(ColorConfig): + color_by_type = {} + + compl = Completer({}, Config) + assert compl.color_for_obj(1, "foo", "bar") == "\x1b[001;00m\x1b[00mfoo\x1b[00m" + + + def test_complete_with_indexer(self): + compl = Completer({'lst': [None, 2, 3]}, ConfigForTest) + assert compl.attr_matches('lst[0].') == ['lst[0].__'] + matches = compl.attr_matches('lst[0].__') + assert 'lst[0].__class__' not in matches + assert '__class__' in matches + assert compl.attr_matches('lst[0].__class') == ['lst[0].__class__'] + + + def test_autocomplete(self): + class A: + aaa = None + abc_1 = None + abc_2 = None + abc_3 = None + bbb = None + compl = Completer({'A': A}, ConfigForTest) + # + # in this case, we want to display all attributes which start with + # 'a'. MOREOVER, we also include a space to prevent readline to + # automatically insert the common prefix (which will the the ANSI escape + # sequence if we use colors) + matches = compl.attr_matches('A.a') + assert sorted(matches) == [' ', 'aaa', 'abc_1', 'abc_2', 'abc_3'] + # + # IF there is an actual common prefix, we return just it, so that readline + # will insert it into place + matches = compl.attr_matches('A.ab') + assert matches == ['A.abc_'] + # + # finally, at the next TAB, we display again all the completions available + # for this common prefix. Agai, we insert a spurious space to prevent the + # automatic completion of ANSI sequences + matches = compl.attr_matches('A.abc_') + assert sorted(matches) == [' ', 'abc_1', 'abc_2', 'abc_3'] + + + def test_complete_exception(self): + compl = Completer({}, ConfigForTest) + assert compl.attr_matches('xxx.') == [] + + + def test_complete_invalid_attr(self): + compl = Completer({'str': str}, ConfigForTest) + assert compl.attr_matches('str.xx') == [] + + + def test_complete_function_skipped(self): + compl = Completer({'str': str}, ConfigForTest) + assert compl.attr_matches('str.split().') == [] + + + def test_unicode_in___dir__(self): + class Foo(object): + def __dir__(self): + return [u'hello', 'world'] + + compl = Completer({'a': Foo()}, ConfigForTest) + matches = compl.attr_matches('a.') + assert matches == ['hello', 'world'] + assert type(matches[0]) is str + + +if __name__ == "__main__": + unittest.main() From 9d40f1b9dc6987206c378f426a2b0d7cb81855c4 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:27:55 +0100 Subject: [PATCH 13/49] edited by copilot: move from pytest-style to unittest-style --- Lib/test/test_pyrepl/test_fancycompleter.py | 146 ++++++++++---------- 1 file changed, 75 insertions(+), 71 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index ebd4684256f3b1..1007dbd4179f97 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -11,24 +11,43 @@ class ConfigForTest(DefaultConfig): class ColorConfig(DefaultConfig): use_colors = True +class MockPatch: + def __init__(self): + self.original_values = {} + + def setattr(self, obj, name, value): + if obj not in self.original_values: + self.original_values[obj] = {} + if name not in self.original_values[obj]: + self.original_values[obj][name] = getattr(obj, name) + setattr(obj, name, value) + + def restore_all(self): + for obj, attrs in self.original_values.items(): + for name, value in attrs.items(): + setattr(obj, name, value) + class FancyCompleterTests(unittest.TestCase): + def setUp(self): + self.mock_patch = MockPatch() - def test_commonprefix(self): - assert commonprefix(['isalpha', 'isdigit', 'foo']) == '' - assert commonprefix(['isalpha', 'isdigit']) == 'is' - assert commonprefix(['isalpha', 'isdigit', 'foo'], base='i') == 'is' - assert commonprefix([]) == '' - assert commonprefix(['aaa', 'bbb'], base='x') == '' + def tearDown(self): + self.mock_patch.restore_all() + def test_commonprefix(self): + self.assertEqual(commonprefix(['isalpha', 'isdigit', 'foo']), '') + self.assertEqual(commonprefix(['isalpha', 'isdigit']), 'is') + self.assertEqual(commonprefix(['isalpha', 'isdigit', 'foo'], base='i'), 'is') + self.assertEqual(commonprefix([]), '') + self.assertEqual(commonprefix(['aaa', 'bbb'], base='x'), '') def test_complete_attribute(self): compl = Completer({'a': None}, ConfigForTest) - assert compl.attr_matches('a.') == ['a.__'] + self.assertEqual(compl.attr_matches('a.'), ['a.__']) matches = compl.attr_matches('a.__') - assert 'a.__class__' not in matches - assert '__class__' in matches - assert compl.attr_matches('a.__class') == ['a.__class__'] - + self.assertNotIn('a.__class__', matches) + self.assertIn('__class__', matches) + self.assertEqual(compl.attr_matches('a.__class'), ['a.__class__']) def test_complete_attribute_prefix(self): class C(object): @@ -36,38 +55,35 @@ class C(object): _attr = 2 __attr__attr = 3 compl = Completer({'a': C}, ConfigForTest) - assert compl.attr_matches('a.') == ['attr', 'mro'] - assert compl.attr_matches('a._') == ['_C__attr__attr', '_attr', ' '] + self.assertEqual(compl.attr_matches('a.'), ['attr', 'mro']) + self.assertEqual(compl.attr_matches('a._'), ['_C__attr__attr', '_attr', ' ']) matches = compl.attr_matches('a.__') - assert 'a.__class__' not in matches - assert '__class__' in matches - assert compl.attr_matches('a.__class') == ['a.__class__'] + self.assertNotIn('a.__class__', matches) + self.assertIn('__class__', matches) + self.assertEqual(compl.attr_matches('a.__class'), ['a.__class__']) compl = Completer({'a': None}, ConfigForTest) - assert compl.attr_matches('a._') == ['a.__'] - + self.assertEqual(compl.attr_matches('a._'), ['a.__']) def test_complete_attribute_colored(self): compl = Completer({'a': 42}, ColorConfig) matches = compl.attr_matches('a.__') - assert len(matches) > 2 + self.assertGreater(len(matches), 2) expected_color = compl.config.color_by_type.get(type(compl.__class__)) - assert expected_color == '35;01' + self.assertEqual(expected_color, '35;01') expected_part = Color.set(expected_color, '__class__') for match in matches: if expected_part in match: break else: - assert False, matches - assert ' ' in matches - + self.assertFalse(True, matches) + self.assertIn(' ', matches) def test_complete_colored_single_match(self): """No coloring, via commonprefix.""" compl = Completer({'foobar': 42}, ColorConfig) matches = compl.global_matches('foob') - assert matches == ['foobar'] - + self.assertEqual(matches, ['foobar']) def test_does_not_color_single_match(self): class obj: @@ -75,81 +91,73 @@ class obj: compl = Completer({'obj': obj}, ColorConfig) matches = compl.attr_matches('obj.msgs') - assert matches == ['obj.msgs'] - + self.assertEqual(matches, ['obj.msgs']) def test_complete_global(self): compl = Completer({'foobar': 1, 'foobazzz': 2}, ConfigForTest) - assert compl.global_matches('foo') == ['fooba'] + self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') - assert set(matches) == set(['foobar', 'foobazzz']) - assert compl.global_matches('foobaz') == ['foobazzz'] - assert compl.global_matches('nothing') == [] - + self.assertEqual(set(matches), set(['foobar', 'foobazzz'])) + self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) + self.assertEqual(compl.global_matches('nothing'), []) def test_complete_global_colored(self): compl = Completer({'foobar': 1, 'foobazzz': 2}, ColorConfig) - assert compl.global_matches('foo') == ['fooba'] + self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') - assert set(matches) == { + self.assertEqual(set(matches), { ' ', '\x1b[001;00m\x1b[33;01mfoobazzz\x1b[00m', '\x1b[000;00m\x1b[33;01mfoobar\x1b[00m', - } - assert compl.global_matches('foobaz') == ['foobazzz'] - assert compl.global_matches('nothing') == [] - + }) + self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) + self.assertEqual(compl.global_matches('nothing'), []) def test_complete_global_colored_exception(self): compl = Completer({'tryme': ValueError()}, ColorConfig) if sys.version_info >= (3, 6): - assert compl.global_matches('try') == [ + self.assertEqual(compl.global_matches('try'), [ '\x1b[000;00m\x1b[37mtry:\x1b[00m', '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', ' ' - ] + ]) else: - assert compl.global_matches('try') == [ + self.assertEqual(compl.global_matches('try'), [ '\x1b[000;00m\x1b[37mtry\x1b[00m', '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', ' ' - ] - - - def test_complete_global_exception(monkeypatchself): - import rlcompleter + ]) + def test_complete_global_exception(self): def rlcompleter_global_matches(self, text): return ['trigger_exception!', 'nameerror', 'valid'] - monkeypatch.setattr(rlcompleter.Completer, 'global_matches', - rlcompleter_global_matches) + self.mock_patch.setattr(rlcompleter.Completer, 'global_matches', + rlcompleter_global_matches) compl = Completer({'valid': 42}, ColorConfig) - assert compl.global_matches("") == [ + self.assertEqual(compl.global_matches(""), [ "\x1b[000;00m\x1b[31;01mnameerror\x1b[00m", "\x1b[001;00m\x1b[31;01mtrigger_exception!\x1b[00m", "\x1b[002;00m\x1b[33;01mvalid\x1b[00m", " ", - ] - + ]) - def test_color_for_obj(monkeypatchself): + def test_color_for_obj(self): class Config(ColorConfig): color_by_type = {} compl = Completer({}, Config) - assert compl.color_for_obj(1, "foo", "bar") == "\x1b[001;00m\x1b[00mfoo\x1b[00m" - + self.assertEqual(compl.color_for_obj(1, "foo", "bar"), + "\x1b[001;00m\x1b[00mfoo\x1b[00m") def test_complete_with_indexer(self): compl = Completer({'lst': [None, 2, 3]}, ConfigForTest) - assert compl.attr_matches('lst[0].') == ['lst[0].__'] + self.assertEqual(compl.attr_matches('lst[0].'), ['lst[0].__']) matches = compl.attr_matches('lst[0].__') - assert 'lst[0].__class__' not in matches - assert '__class__' in matches - assert compl.attr_matches('lst[0].__class') == ['lst[0].__class__'] - + self.assertNotIn('lst[0].__class__', matches) + self.assertIn('__class__', matches) + self.assertEqual(compl.attr_matches('lst[0].__class'), ['lst[0].__class__']) def test_autocomplete(self): class A: @@ -165,34 +173,30 @@ class A: # automatically insert the common prefix (which will the the ANSI escape # sequence if we use colors) matches = compl.attr_matches('A.a') - assert sorted(matches) == [' ', 'aaa', 'abc_1', 'abc_2', 'abc_3'] + self.assertEqual(sorted(matches), [' ', 'aaa', 'abc_1', 'abc_2', 'abc_3']) # # IF there is an actual common prefix, we return just it, so that readline # will insert it into place matches = compl.attr_matches('A.ab') - assert matches == ['A.abc_'] + self.assertEqual(matches, ['A.abc_']) # # finally, at the next TAB, we display again all the completions available # for this common prefix. Agai, we insert a spurious space to prevent the # automatic completion of ANSI sequences matches = compl.attr_matches('A.abc_') - assert sorted(matches) == [' ', 'abc_1', 'abc_2', 'abc_3'] - + self.assertEqual(sorted(matches), [' ', 'abc_1', 'abc_2', 'abc_3']) def test_complete_exception(self): compl = Completer({}, ConfigForTest) - assert compl.attr_matches('xxx.') == [] - + self.assertEqual(compl.attr_matches('xxx.'), []) def test_complete_invalid_attr(self): compl = Completer({'str': str}, ConfigForTest) - assert compl.attr_matches('str.xx') == [] - + self.assertEqual(compl.attr_matches('str.xx'), []) def test_complete_function_skipped(self): compl = Completer({'str': str}, ConfigForTest) - assert compl.attr_matches('str.split().') == [] - + self.assertEqual(compl.attr_matches('str.split().'), []) def test_unicode_in___dir__(self): class Foo(object): @@ -201,8 +205,8 @@ def __dir__(self): compl = Completer({'a': Foo()}, ConfigForTest) matches = compl.attr_matches('a.') - assert matches == ['hello', 'world'] - assert type(matches[0]) is str + self.assertEqual(matches, ['hello', 'world']) + self.assertIs(type(matches[0]), str) if __name__ == "__main__": From d04f41d1f020d9c5548681402af759047b33899c Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:39:55 +0100 Subject: [PATCH 14/49] don't try to be too clever with exceptions: if a global name raises an exception when evaluated, just color it as None --- Lib/_pyrepl/fancycompleter.py | 2 +- Lib/test/test_pyrepl/test_fancycompleter.py | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 5ad17a47fc9f6e..7586d37ac7895d 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -117,7 +117,7 @@ def global_matches(self, text): try: values.append(eval(name, self.namespace)) except Exception as exc: - values.append(exc) + values.append(None) if self.config.use_colors and names: return self.colorize_matches(names, values) return names diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 1007dbd4179f97..a9167d128989b2 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -128,21 +128,6 @@ def test_complete_global_colored_exception(self): ' ' ]) - def test_complete_global_exception(self): - def rlcompleter_global_matches(self, text): - return ['trigger_exception!', 'nameerror', 'valid'] - - self.mock_patch.setattr(rlcompleter.Completer, 'global_matches', - rlcompleter_global_matches) - - compl = Completer({'valid': 42}, ColorConfig) - self.assertEqual(compl.global_matches(""), [ - "\x1b[000;00m\x1b[31;01mnameerror\x1b[00m", - "\x1b[001;00m\x1b[31;01mtrigger_exception!\x1b[00m", - "\x1b[002;00m\x1b[33;01mvalid\x1b[00m", - " ", - ]) - def test_color_for_obj(self): class Config(ColorConfig): color_by_type = {} From 15ea5de00742d73480ffefc9434389f7379bbc3a Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:40:43 +0100 Subject: [PATCH 15/49] no longer needed --- Lib/_pyrepl/fancycompleter.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 7586d37ac7895d..d13923677d3fb2 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -38,12 +38,6 @@ class DefaultConfig: complex: ANSIColors.BOLD_YELLOW, bool: ANSIColors.BOLD_YELLOW, } - # Fallback to look up colors by `isinstance` when not matched - # via color_by_type. - color_by_baseclass = [ - ((BaseException,), ANSIColors.BOLD_RED), - ] - def setup(self): import _colorize @@ -60,10 +54,6 @@ class Completer(rlcompleter.Completer): Optionally, display the various completions in different colors depending on the type. """ - - DefaultConfig = DefaultConfig - config_filename = '.fancycompleterrc.py.xxx' - def __init__(self, namespace=None, Config=DefaultConfig): rlcompleter.Completer.__init__(self, namespace) self.config = Config() @@ -193,14 +183,7 @@ def colorize_matches(self, names, values): def color_for_obj(self, i, name, value): t = type(value) - color = self.config.color_by_type.get(t, None) - if color is None: - for x, _color in self.config.color_by_baseclass: - if isinstance(value, x): - color = _color - break - else: - color = ANSIColors.RESET + color = self.config.color_by_type.get(t, ANSIColors.RESET) # hack: prepend an (increasing) fake escape sequence, # so that readline can sort the matches correctly. N = f"\x1b[{i:03d};00m" From 0063a7068852dd37005eaa4b3f35cdda696c292c Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:42:27 +0100 Subject: [PATCH 16/49] this doesn't test anything meaningful --- Lib/test/test_pyrepl/test_fancycompleter.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index a9167d128989b2..832e2f7e7c9cbb 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -128,14 +128,6 @@ def test_complete_global_colored_exception(self): ' ' ]) - def test_color_for_obj(self): - class Config(ColorConfig): - color_by_type = {} - - compl = Completer({}, Config) - self.assertEqual(compl.color_for_obj(1, "foo", "bar"), - "\x1b[001;00m\x1b[00mfoo\x1b[00m") - def test_complete_with_indexer(self): compl = Completer({'lst': [None, 2, 3]}, ConfigForTest) self.assertEqual(compl.attr_matches('lst[0].'), ['lst[0].__']) From 0af3c84ad8d8d661ace5a6758f14ba7d2ece528f Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:45:19 +0100 Subject: [PATCH 17/49] fix this test --- Lib/test/test_pyrepl/test_fancycompleter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 832e2f7e7c9cbb..2039da428b44fe 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,6 +1,7 @@ import unittest import sys +from _colorize import ANSIColors import _pyrepl.readline from _pyrepl.fancycompleter import Completer, DefaultConfig, commonprefix @@ -70,8 +71,8 @@ def test_complete_attribute_colored(self): matches = compl.attr_matches('a.__') self.assertGreater(len(matches), 2) expected_color = compl.config.color_by_type.get(type(compl.__class__)) - self.assertEqual(expected_color, '35;01') - expected_part = Color.set(expected_color, '__class__') + self.assertEqual(expected_color, ANSIColors.BOLD_MAGENTA) + expected_part = f'{expected_color}__class__{ANSIColors.RESET}' for match in matches: if expected_part in match: break From d9a9f6fd343189ea5a109d48927505dfe235fb7b Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:49:54 +0100 Subject: [PATCH 18/49] fix this test --- Lib/test/test_pyrepl/test_fancycompleter.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 2039da428b44fe..f21b0ccbaa5365 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -106,10 +106,16 @@ def test_complete_global_colored(self): compl = Completer({'foobar': 1, 'foobazzz': 2}, ColorConfig) self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') + + # these are the fake escape sequences which are needed so that + # readline displays the matches in the proper order + N0 = f"\x1b[000;00m" + N1 = f"\x1b[001;00m" + self.assertEqual(set(matches), { ' ', - '\x1b[001;00m\x1b[33;01mfoobazzz\x1b[00m', - '\x1b[000;00m\x1b[33;01mfoobar\x1b[00m', + f'{N0}{ANSIColors.BOLD_YELLOW}foobar{ANSIColors.RESET}', + f'{N1}{ANSIColors.BOLD_YELLOW}foobazzz{ANSIColors.RESET}', }) self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) From 0c268121a24ed7c5bc465be861117838cc13a7b0 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Tue, 18 Feb 2025 18:52:27 +0100 Subject: [PATCH 19/49] Fix this test --- Lib/test/test_pyrepl/test_fancycompleter.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index f21b0ccbaa5365..089710f33b1a49 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -121,19 +121,14 @@ def test_complete_global_colored(self): self.assertEqual(compl.global_matches('nothing'), []) def test_complete_global_colored_exception(self): - compl = Completer({'tryme': ValueError()}, ColorConfig) - if sys.version_info >= (3, 6): - self.assertEqual(compl.global_matches('try'), [ - '\x1b[000;00m\x1b[37mtry:\x1b[00m', - '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', - ' ' - ]) - else: - self.assertEqual(compl.global_matches('try'), [ - '\x1b[000;00m\x1b[37mtry\x1b[00m', - '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', - ' ' - ]) + compl = Completer({'tryme': 42}, ColorConfig) + N0 = f"\x1b[000;00m" + N1 = f"\x1b[001;00m" + self.assertEqual(compl.global_matches('try'), [ + f'{N0}{ANSIColors.GREY}try:{ANSIColors.RESET}', + f'{N1}{ANSIColors.BOLD_YELLOW}tryme{ANSIColors.RESET}', + ' ' + ]) def test_complete_with_indexer(self): compl = Completer({'lst': [None, 2, 3]}, ConfigForTest) From 30468acf8fbe432fcb8a98d5c045e76ba60c9ad3 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:06:45 +0200 Subject: [PATCH 20/49] Apply hugovk suggestions from code review Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 2 -- Lib/_pyrepl/fancycompleter.py | 24 ++++++++++----------- Lib/test/test_pyrepl/test_fancycompleter.py | 14 ++++++------ 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 2bf3da0f3d1398..3ad5b450091dd9 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -23,7 +23,6 @@ class ANSIColors: MAGENTA = "\x1b[35m" RED = "\x1b[31m" WHITE = "\x1b[37m" # more like LIGHT GRAY - TEAL = "\x1b[36m" YELLOW = "\x1b[33m" BOLD = "\x1b[1m" @@ -33,7 +32,6 @@ class ANSIColors: BOLD_GREEN = "\x1b[1;32m" BOLD_MAGENTA = "\x1b[1;35m" BOLD_RED = "\x1b[1;31m" - BOLD_TEAL = "\x1b[1;36m" BOLD_WHITE = "\x1b[1;37m" # actual WHITE BOLD_YELLOW = "\x1b[1;33m" diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index d13923677d3fb2..b15feff9a921b5 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -3,7 +3,7 @@ # # All Rights Reserved """ -Colorful TAB completion for Python prompt +Colorful tab completion for Python prompt """ from _pyrepl import readline from _colorize import ANSIColors @@ -18,18 +18,18 @@ class DefaultConfig: use_colors = 'auto' color_by_type = { - types.BuiltinMethodType: ANSIColors.BOLD_TEAL, - types.MethodType: ANSIColors.BOLD_TEAL, - type((42).__add__): ANSIColors.BOLD_TEAL, - type(int.__add__): ANSIColors.BOLD_TEAL, - type(str.replace): ANSIColors.BOLD_TEAL, + types.BuiltinMethodType: ANSIColors.BOLD_CYAN, + types.MethodType: ANSIColors.BOLD_CYAN, + type((42).__add__): ANSIColors.BOLD_CYAN, + type(int.__add__): ANSIColors.BOLD_CYAN, + type(str.replace): ANSIColors.BOLD_CYAN, types.FunctionType: ANSIColors.BOLD_BLUE, types.BuiltinFunctionType: ANSIColors.BOLD_BLUE, type: ANSIColors.BOLD_MAGENTA, - types.ModuleType: ANSIColors.TEAL, + types.ModuleType: ANSIColors.CYAN, type(None): ANSIColors.GREY, str: ANSIColors.BOLD_GREEN, bytes: ANSIColors.BOLD_GREEN, @@ -48,7 +48,7 @@ def setup(self): class Completer(rlcompleter.Completer): """ - When doing someting like a.b., display only the attributes of + When doing someting like a.b., display only the attributes of b instead of the full a.b.attr string. Optionally, display the various completions in different colors @@ -61,10 +61,10 @@ def __init__(self, namespace=None, Config=DefaultConfig): # XXX: double check what happens in this case once fancycompleter works if False and hasattr(readline, '_setup'): - # this is needed to offer pyrepl a better chance to patch - # raw_input. Usually, it does at import time, but is we are under + # This is needed to offer PyREPL a better chance to patch + # raw_input. Usually, it does at import time, but if we are under # pytest with output captured, at import time we don't have a - # terminal and thus the raw_input hook is not installed + # terminal and thus the raw_input hook is not installed. readline._setup() if self.config.use_colors: @@ -178,7 +178,7 @@ def colorize_matches(self, names, values): for i, name, obj in zip(count(), names, values)] # We add a space at the end to prevent the automatic completion of the - # common prefix, which is the ANSI ESCAPE sequence. + # common prefix, which is the ANSI escape sequence. return matches + [' '] def color_for_obj(self, i, name, value): diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 089710f33b1a49..4f99f157dec5bf 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -147,21 +147,21 @@ class A: bbb = None compl = Completer({'A': A}, ConfigForTest) # - # in this case, we want to display all attributes which start with - # 'a'. MOREOVER, we also include a space to prevent readline to + # In this case, we want to display all attributes which start with + # 'a'. Moreover, we also include a space to prevent readline to # automatically insert the common prefix (which will the the ANSI escape - # sequence if we use colors) + # sequence if we use colors). matches = compl.attr_matches('A.a') self.assertEqual(sorted(matches), [' ', 'aaa', 'abc_1', 'abc_2', 'abc_3']) # - # IF there is an actual common prefix, we return just it, so that readline + # If there is an actual common prefix, we return just it, so that readline # will insert it into place matches = compl.attr_matches('A.ab') self.assertEqual(matches, ['A.abc_']) # - # finally, at the next TAB, we display again all the completions available - # for this common prefix. Agai, we insert a spurious space to prevent the - # automatic completion of ANSI sequences + # Finally, at the next tab, we display again all the completions available + # for this common prefix. Again, we insert a spurious space to prevent the + # automatic completion of ANSI sequences. matches = compl.attr_matches('A.abc_') self.assertEqual(sorted(matches), [' ', 'abc_1', 'abc_2', 'abc_3']) From 4700d1a8db8ac25e1589a321ea608ab3e888bc37 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:08:55 +0200 Subject: [PATCH 21/49] Apply suggestions from code review Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Co-authored-by: Tomas R. --- Lib/_pyrepl/fancycompleter.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index b15feff9a921b5..56bbcc205cc1a3 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -9,7 +9,6 @@ from _colorize import ANSIColors import rlcompleter import types -from itertools import count class DefaultConfig: @@ -30,7 +29,7 @@ class DefaultConfig: type: ANSIColors.BOLD_MAGENTA, types.ModuleType: ANSIColors.CYAN, - type(None): ANSIColors.GREY, + types.NoneType: ANSIColors.GREY, str: ANSIColors.BOLD_GREEN, bytes: ANSIColors.BOLD_GREEN, int: ANSIColors.BOLD_YELLOW, @@ -81,7 +80,7 @@ def complete(self, text, state): http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496812 """ if text == "": - return ['\t', None][state] + return ('\t', None)[state] else: return rlcompleter.Completer.complete(self, text, state) @@ -122,8 +121,7 @@ def attr_matches(self, text): return [] # get the content of the object, except __builtins__ - words = set(dir(thisobject)) - words.discard("__builtins__") + words = set(dir(thisobject)) - {'__builtins__'} if hasattr(thisobject, '__class__'): words.add('__class__') @@ -140,8 +138,10 @@ def attr_matches(self, text): words = sorted(words) while True: for word in words: - if (word[:n] == attr and - not (noprefix and word[:n+1] == noprefix)): + if ( + word[:n] == attr + and not (noprefix and word[:n+1] == noprefix) + ): try: val = getattr(thisobject, word) except Exception: @@ -175,8 +175,8 @@ def attr_matches(self, text): def colorize_matches(self, names, values): matches = [self.color_for_obj(i, name, obj) - for i, name, obj - in zip(count(), names, values)] + for i, (name, obj) + in enumerate(zip(names, values))] # We add a space at the end to prevent the automatic completion of the # common prefix, which is the ANSI escape sequence. return matches + [' '] @@ -191,8 +191,7 @@ def color_for_obj(self, i, name, value): def commonprefix(names, base=''): - """ return the common prefix of all 'names' starting with 'base' - """ + """Return the common prefix of all 'names' starting with 'base'""" if base: names = [x for x in names if x.startswith(base)] if not names: From 26591aec0d4fa0e35ea2e515610f754e0e04f8cf Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:10:57 +0200 Subject: [PATCH 22/49] Apply suggestions from code review Co-authored-by: Tomas R. --- Lib/_pyrepl/fancycompleter.py | 4 ++-- Lib/test/test_pyrepl/test_fancycompleter.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 56bbcc205cc1a3..2af4c31d81fd63 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -160,11 +160,11 @@ def attr_matches(self, text): return [] if len(names) == 1: - return ['%s.%s' % (expr, names[0])] # only option, no coloring. + return [f'{expr}.{names[0]}'] # only option, no coloring. prefix = commonprefix(names) if prefix and prefix != attr: - return ['%s.%s' % (expr, prefix)] # autocomplete prefix + return [f'{expr}.{prefix}'] # autocomplete prefix if self.config.use_colors: return self.colorize_matches(names, values) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 4f99f157dec5bf..e30d8b287e5a6b 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -180,7 +180,7 @@ def test_complete_function_skipped(self): def test_unicode_in___dir__(self): class Foo(object): def __dir__(self): - return [u'hello', 'world'] + return ['hello', 'world'] compl = Completer({'a': Foo()}, ConfigForTest) matches = compl.attr_matches('a.') From b2414f8d9c1cd5531beec06915f2a36ab2903e37 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:51:13 +0200 Subject: [PATCH 23/49] remove unneeded lazy import --- Lib/_pyrepl/fancycompleter.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 2af4c31d81fd63..04072dfaecf720 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -6,7 +6,7 @@ Colorful tab completion for Python prompt """ from _pyrepl import readline -from _colorize import ANSIColors +from _colorize import ANSIColors, get_colors import rlcompleter import types @@ -39,9 +39,8 @@ class DefaultConfig: } def setup(self): - import _colorize if self.use_colors == 'auto': - colors = _colorize.get_colors() + colors = get_colors() self.use_colors = colors.RED != "" @@ -139,7 +138,7 @@ def attr_matches(self, text): while True: for word in words: if ( - word[:n] == attr + word[:n] == attr and not (noprefix and word[:n+1] == noprefix) ): try: From 24e4afb6875f068cadcf88db60c474c25e8a591b Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:55:08 +0200 Subject: [PATCH 24/49] Update Lib/_pyrepl/fancycompleter.py Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_pyrepl/fancycompleter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 04072dfaecf720..44da3d53664767 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -84,8 +84,7 @@ def complete(self, text, state): return rlcompleter.Completer.complete(self, text, state) def _callable_postfix(self, val, word): - # disable automatic insertion of '(' for global callables: - # this method exists only in Python 2.6+ + # disable automatic insertion of '(' for global callables return word def global_matches(self, text): From b5935bc143a8c04e53e897585f8cf521a98b0981 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:56:48 +0200 Subject: [PATCH 25/49] move import to module scope --- Lib/_pyrepl/fancycompleter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 44da3d53664767..fcb064d9c782f4 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -9,7 +9,7 @@ from _colorize import ANSIColors, get_colors import rlcompleter import types - +import keyword class DefaultConfig: @@ -88,7 +88,6 @@ def _callable_postfix(self, val, word): return word def global_matches(self, text): - import keyword names = rlcompleter.Completer.global_matches(self, text) prefix = commonprefix(names) if prefix and prefix != text: From 0fbdabc00c9ba1164d039c17503910180953ce92 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 12:57:56 +0200 Subject: [PATCH 26/49] move import --- Lib/_pyrepl/readline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 1148b840e77852..9eefdb5c04ab65 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -35,12 +35,12 @@ from site import gethistoryfile import sys from rlcompleter import Completer as RLCompleter -from .fancycompleter import Completer as FancyCompleter from . import commands, historical_reader from .completing_reader import CompletingReader from .console import Console as ConsoleType from ._module_completer import ModuleCompleter, make_default_module_completer +from .fancycompleter import Completer as FancyCompleter Console: type[ConsoleType] _error: tuple[type[Exception], ...] | type[Exception] From 06840b0784f4922d45e6ee74d19c08a48037d05f Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 14:57:50 +0200 Subject: [PATCH 27/49] kill this for now, we can redintroduce it later if/when we enable fancycompleter+pdb --- Lib/_pyrepl/fancycompleter.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index fcb064d9c782f4..f1058faec1156f 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -57,14 +57,6 @@ def __init__(self, namespace=None, Config=DefaultConfig): self.config = Config() self.config.setup() - # XXX: double check what happens in this case once fancycompleter works - if False and hasattr(readline, '_setup'): - # This is needed to offer PyREPL a better chance to patch - # raw_input. Usually, it does at import time, but if we are under - # pytest with output captured, at import time we don't have a - # terminal and thus the raw_input hook is not installed. - readline._setup() - if self.config.use_colors: readline.parse_and_bind('set dont-escape-ctrl-chars on') if self.config.consider_getitems: From 56384ee0aa3265db7ecf9682defd406ea0818679 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 14:59:04 +0200 Subject: [PATCH 28/49] this link is dead, add a comment to explain what it does instead --- Lib/_pyrepl/fancycompleter.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index f1058faec1156f..699b41e981b234 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -66,10 +66,8 @@ def __init__(self, namespace=None, Config=DefaultConfig): readline.set_completer_delims(delims) def complete(self, text, state): - """ - stolen from: - http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496812 - """ + # if you press at the beginning of a line, insert an actual + # \t. Else, trigger completion. if text == "": return ('\t', None)[state] else: From 49f90f55f0293ad7d435d29e60ad7b4b051bcb50 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 15:02:35 +0200 Subject: [PATCH 29/49] fix precommit --- Lib/test/test_pyrepl/test_fancycompleter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index e30d8b287e5a6b..95b3e826bde5bf 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,8 +1,6 @@ import unittest -import sys from _colorize import ANSIColors -import _pyrepl.readline from _pyrepl.fancycompleter import Completer, DefaultConfig, commonprefix From 3c441e1e23967ae2f3331d875c8983c78a1b5270 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 15:24:02 +0200 Subject: [PATCH 30/49] we need to make this import lazy, else we get circular imports --- Lib/_pyrepl/fancycompleter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 699b41e981b234..2d5e37b54227da 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -5,7 +5,6 @@ """ Colorful tab completion for Python prompt """ -from _pyrepl import readline from _colorize import ANSIColors, get_colors import rlcompleter import types @@ -53,6 +52,7 @@ class Completer(rlcompleter.Completer): depending on the type. """ def __init__(self, namespace=None, Config=DefaultConfig): + from _pyrepl import readline rlcompleter.Completer.__init__(self, namespace) self.config = Config() self.config.setup() From aabf91c50e01eeb3230e653c7c9c8986cc509105 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 15:40:53 +0200 Subject: [PATCH 31/49] now that we have themes, we can kill the config object --- Lib/_colorize.py | 28 +++++++++ Lib/_pyrepl/fancycompleter.py | 69 ++++++++------------- Lib/test/test_pyrepl/test_fancycompleter.py | 45 ++++++-------- 3 files changed, 74 insertions(+), 68 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 3ad5b450091dd9..697c769a93bfcd 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -344,6 +344,30 @@ class Unittest(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True, kw_only=True) +class FancyCompleter(ThemeSection): + # functions and methods + function: str = ANSIColors.BOLD_BLUE + builtin_function_or_method: str = ANSIColors.BOLD_BLUE + method: str = ANSIColors.BOLD_CYAN + method_wrapper: str = ANSIColors.BOLD_CYAN + wrapper_descriptor: str = ANSIColors.BOLD_CYAN + method_descriptor: str = ANSIColors.BOLD_CYAN + + # numbers + int: str = ANSIColors.BOLD_YELLOW + float: str = ANSIColors.BOLD_YELLOW + complex: str = ANSIColors.BOLD_YELLOW + bool: str = ANSIColors.BOLD_YELLOW + + # others + type: str = ANSIColors.BOLD_MAGENTA + module: str = ANSIColors.CYAN + NoneType: str = ANSIColors.GREY + str: str = ANSIColors.BOLD_GREEN + bytes: str = ANSIColors.BOLD_GREEN + + @dataclass(frozen=True, kw_only=True) class Theme: """A suite of themes for all sections of Python. @@ -357,6 +381,7 @@ class Theme: syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) + fancycompleter: FancyCompleter = field(default_factory=FancyCompleter) def copy_with( self, @@ -367,6 +392,7 @@ def copy_with( syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, + fancycompleter: FancyCompleter | None = None, ) -> Self: """Return a new Theme based on this instance with some sections replaced. @@ -380,6 +406,7 @@ def copy_with( syntax=syntax or self.syntax, traceback=traceback or self.traceback, unittest=unittest or self.unittest, + fancycompleter=fancycompleter or self.fancycompleter, ) @classmethod @@ -397,6 +424,7 @@ def no_colors(cls) -> Self: syntax=Syntax.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), + fancycompleter=FancyCompleter.no_colors(), ) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 2d5e37b54227da..df0e32f4adabe0 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -5,44 +5,11 @@ """ Colorful tab completion for Python prompt """ -from _colorize import ANSIColors, get_colors +from _colorize import ANSIColors, get_colors, get_theme import rlcompleter import types import keyword -class DefaultConfig: - - consider_getitems = True - use_colors = 'auto' - - color_by_type = { - types.BuiltinMethodType: ANSIColors.BOLD_CYAN, - types.MethodType: ANSIColors.BOLD_CYAN, - type((42).__add__): ANSIColors.BOLD_CYAN, - type(int.__add__): ANSIColors.BOLD_CYAN, - type(str.replace): ANSIColors.BOLD_CYAN, - - types.FunctionType: ANSIColors.BOLD_BLUE, - types.BuiltinFunctionType: ANSIColors.BOLD_BLUE, - - type: ANSIColors.BOLD_MAGENTA, - - types.ModuleType: ANSIColors.CYAN, - types.NoneType: ANSIColors.GREY, - str: ANSIColors.BOLD_GREEN, - bytes: ANSIColors.BOLD_GREEN, - int: ANSIColors.BOLD_YELLOW, - float: ANSIColors.BOLD_YELLOW, - complex: ANSIColors.BOLD_YELLOW, - bool: ANSIColors.BOLD_YELLOW, - } - - def setup(self): - if self.use_colors == 'auto': - colors = get_colors() - self.use_colors = colors.RED != "" - - class Completer(rlcompleter.Completer): """ When doing someting like a.b., display only the attributes of @@ -51,15 +18,24 @@ class Completer(rlcompleter.Completer): Optionally, display the various completions in different colors depending on the type. """ - def __init__(self, namespace=None, Config=DefaultConfig): + def __init__( + self, + namespace=None, + *, + use_colors='auto', + consider_getitems=True, + ): from _pyrepl import readline rlcompleter.Completer.__init__(self, namespace) - self.config = Config() - self.config.setup() + if use_colors == 'auto': + # use colors only if we can + use_colors = get_colors().RED != "" + self.use_colors = use_colors + self.consider_getitems = consider_getitems - if self.config.use_colors: + if self.use_colors: readline.parse_and_bind('set dont-escape-ctrl-chars on') - if self.config.consider_getitems: + if self.consider_getitems: delims = readline.get_completer_delims() delims = delims.replace('[', '') delims = delims.replace(']', '') @@ -94,7 +70,7 @@ def global_matches(self, text): values.append(eval(name, self.namespace)) except Exception as exc: values.append(None) - if self.config.use_colors and names: + if self.use_colors and names: return self.colorize_matches(names, values) return names @@ -153,7 +129,7 @@ def attr_matches(self, text): if prefix and prefix != attr: return [f'{expr}.{prefix}'] # autocomplete prefix - if self.config.use_colors: + if self.use_colors: return self.colorize_matches(names, values) if prefix: @@ -170,12 +146,21 @@ def colorize_matches(self, names, values): def color_for_obj(self, i, name, value): t = type(value) - color = self.config.color_by_type.get(t, ANSIColors.RESET) + color = self.color_by_type(t) # hack: prepend an (increasing) fake escape sequence, # so that readline can sort the matches correctly. N = f"\x1b[{i:03d};00m" return f"{N}{color}{name}{ANSIColors.RESET}" + def color_by_type(self, t): + theme = get_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) + def commonprefix(names, base=''): """Return the common prefix of all 'names' starting with 'base'""" diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 95b3e826bde5bf..b7271a04ad7dff 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,14 +1,7 @@ import unittest -from _colorize import ANSIColors -from _pyrepl.fancycompleter import Completer, DefaultConfig, commonprefix - - -class ConfigForTest(DefaultConfig): - use_colors = False - -class ColorConfig(DefaultConfig): - use_colors = True +from _colorize import ANSIColors, get_theme +from _pyrepl.fancycompleter import Completer, commonprefix class MockPatch: def __init__(self): @@ -41,7 +34,7 @@ def test_commonprefix(self): self.assertEqual(commonprefix(['aaa', 'bbb'], base='x'), '') def test_complete_attribute(self): - compl = Completer({'a': None}, ConfigForTest) + compl = Completer({'a': None}, use_colors=False) self.assertEqual(compl.attr_matches('a.'), ['a.__']) matches = compl.attr_matches('a.__') self.assertNotIn('a.__class__', matches) @@ -53,7 +46,7 @@ class C(object): attr = 1 _attr = 2 __attr__attr = 3 - compl = Completer({'a': C}, ConfigForTest) + compl = Completer({'a': C}, use_colors=False) self.assertEqual(compl.attr_matches('a.'), ['attr', 'mro']) self.assertEqual(compl.attr_matches('a._'), ['_C__attr__attr', '_attr', ' ']) matches = compl.attr_matches('a.__') @@ -61,15 +54,15 @@ class C(object): self.assertIn('__class__', matches) self.assertEqual(compl.attr_matches('a.__class'), ['a.__class__']) - compl = Completer({'a': None}, ConfigForTest) + compl = Completer({'a': None}, use_colors=False) self.assertEqual(compl.attr_matches('a._'), ['a.__']) def test_complete_attribute_colored(self): - compl = Completer({'a': 42}, ColorConfig) + theme = get_theme() + compl = Completer({'a': 42}, use_colors=True) matches = compl.attr_matches('a.__') self.assertGreater(len(matches), 2) - expected_color = compl.config.color_by_type.get(type(compl.__class__)) - self.assertEqual(expected_color, ANSIColors.BOLD_MAGENTA) + expected_color = theme.fancycompleter.type expected_part = f'{expected_color}__class__{ANSIColors.RESET}' for match in matches: if expected_part in match: @@ -80,7 +73,7 @@ def test_complete_attribute_colored(self): def test_complete_colored_single_match(self): """No coloring, via commonprefix.""" - compl = Completer({'foobar': 42}, ColorConfig) + compl = Completer({'foobar': 42}, use_colors=True) matches = compl.global_matches('foob') self.assertEqual(matches, ['foobar']) @@ -88,12 +81,12 @@ def test_does_not_color_single_match(self): class obj: msgs = [] - compl = Completer({'obj': obj}, ColorConfig) + compl = Completer({'obj': obj}, use_colors=True) matches = compl.attr_matches('obj.msgs') self.assertEqual(matches, ['obj.msgs']) def test_complete_global(self): - compl = Completer({'foobar': 1, 'foobazzz': 2}, ConfigForTest) + compl = Completer({'foobar': 1, 'foobazzz': 2}, use_colors=False) self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') self.assertEqual(set(matches), set(['foobar', 'foobazzz'])) @@ -101,7 +94,7 @@ def test_complete_global(self): self.assertEqual(compl.global_matches('nothing'), []) def test_complete_global_colored(self): - compl = Completer({'foobar': 1, 'foobazzz': 2}, ColorConfig) + compl = Completer({'foobar': 1, 'foobazzz': 2}, use_colors=True) self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') @@ -119,7 +112,7 @@ def test_complete_global_colored(self): self.assertEqual(compl.global_matches('nothing'), []) def test_complete_global_colored_exception(self): - compl = Completer({'tryme': 42}, ColorConfig) + compl = Completer({'tryme': 42}, use_colors=True) N0 = f"\x1b[000;00m" N1 = f"\x1b[001;00m" self.assertEqual(compl.global_matches('try'), [ @@ -129,7 +122,7 @@ def test_complete_global_colored_exception(self): ]) def test_complete_with_indexer(self): - compl = Completer({'lst': [None, 2, 3]}, ConfigForTest) + compl = Completer({'lst': [None, 2, 3]}, use_colors=False) self.assertEqual(compl.attr_matches('lst[0].'), ['lst[0].__']) matches = compl.attr_matches('lst[0].__') self.assertNotIn('lst[0].__class__', matches) @@ -143,7 +136,7 @@ class A: abc_2 = None abc_3 = None bbb = None - compl = Completer({'A': A}, ConfigForTest) + compl = Completer({'A': A}, use_colors=False) # # In this case, we want to display all attributes which start with # 'a'. Moreover, we also include a space to prevent readline to @@ -164,15 +157,15 @@ class A: self.assertEqual(sorted(matches), [' ', 'abc_1', 'abc_2', 'abc_3']) def test_complete_exception(self): - compl = Completer({}, ConfigForTest) + compl = Completer({}, use_colors=False) self.assertEqual(compl.attr_matches('xxx.'), []) def test_complete_invalid_attr(self): - compl = Completer({'str': str}, ConfigForTest) + compl = Completer({'str': str}, use_colors=False) self.assertEqual(compl.attr_matches('str.xx'), []) def test_complete_function_skipped(self): - compl = Completer({'str': str}, ConfigForTest) + compl = Completer({'str': str}, use_colors=False) self.assertEqual(compl.attr_matches('str.split().'), []) def test_unicode_in___dir__(self): @@ -180,7 +173,7 @@ class Foo(object): def __dir__(self): return ['hello', 'world'] - compl = Completer({'a': Foo()}, ConfigForTest) + compl = Completer({'a': Foo()}, use_colors=False) matches = compl.attr_matches('a.') self.assertEqual(matches, ['hello', 'world']) self.assertIs(type(matches[0]), str) From 233c51d91baa67ea389999b41d0c7987309ef47a Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 15:50:23 +0200 Subject: [PATCH 32/49] style --- Lib/_pyrepl/fancycompleter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index df0e32f4adabe0..c88a2858c36cfb 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -2,9 +2,7 @@ # Daniel Hahler # # All Rights Reserved -""" -Colorful tab completion for Python prompt -""" +"""Colorful tab completion for Python prompt""" from _colorize import ANSIColors, get_colors, get_theme import rlcompleter import types From 1a86cafaea41d867c212064bfc101f614ae1c9fd Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:54:55 +0000 Subject: [PATCH 33/49] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- diff --git a/Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- b/Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- new file mode 100644 index 00000000000000..3d2a7f00d3e6a8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- @@ -0,0 +1 @@ +Add fancycompleter and enable it by default when using pyrepl. This gives colored tab completion. From ee882e7ab6750abfc22acbeb9bcd493aae7421ac Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 15:59:10 +0200 Subject: [PATCH 34/49] fix mypy --- Lib/_colorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 697c769a93bfcd..7929ca5212f59f 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -364,8 +364,8 @@ class FancyCompleter(ThemeSection): type: str = ANSIColors.BOLD_MAGENTA module: str = ANSIColors.CYAN NoneType: str = ANSIColors.GREY - str: str = ANSIColors.BOLD_GREEN bytes: str = ANSIColors.BOLD_GREEN + str: str = ANSIColors.BOLD_GREEN @dataclass(frozen=True, kw_only=True) From 850d74b83a0f74972690b352e27ef30220e0cbec Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 16:04:40 +0200 Subject: [PATCH 35/49] document PYTHON_BASIC_COMPLETER --- Doc/using/cmdline.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 73cd8d31d0b20d..fc112dc20440cf 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -1338,6 +1338,13 @@ conflict. .. versionadded:: 3.13 +.. envvar:: PYTHON_BASIC_COMPLETER + + If this variable is set to any value, PyREPL will use :mod:`rlcompleter` to + implement tab complition, instead of the default :mod:`_pyrepl.fancycompleter`. + + .. versionadded:: 3.15 + .. envvar:: PYTHON_HISTORY This environment variable can be used to set the location of a From 7c603a2ac02d11bc261c861b368c0c9780434f96 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 16:10:14 +0200 Subject: [PATCH 36/49] try to manually fix the filename --- ...4.gh-issue- => 2025-09-19-13-54-54.gh-issue-130472.LODfdk.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Misc/NEWS.d/next/Library/{2025-09-19-13-54-54.gh-issue- => 2025-09-19-13-54-54.gh-issue-130472.LODfdk.rst} (100%) diff --git a/Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- b/Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue-130472.LODfdk.rst similarity index 100% rename from Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue- rename to Misc/NEWS.d/next/Library/2025-09-19-13-54-54.gh-issue-130472.LODfdk.rst From cf6bf1eeef6acea9605c4fa2f5a7e86042ea46ac Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 16:41:10 +0200 Subject: [PATCH 37/49] Typo Co-authored-by: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com> --- Doc/using/cmdline.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index fc112dc20440cf..13c1c0c8091a53 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -1341,7 +1341,7 @@ conflict. .. envvar:: PYTHON_BASIC_COMPLETER If this variable is set to any value, PyREPL will use :mod:`rlcompleter` to - implement tab complition, instead of the default :mod:`_pyrepl.fancycompleter`. + implement tab completion, instead of the default :mod:`_pyrepl.fancycompleter`. .. versionadded:: 3.15 From 0dc7d164defbbc1a0ed1c158fa7d5bf92f2f28ee Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 16:42:11 +0200 Subject: [PATCH 38/49] reword --- Doc/using/cmdline.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index 13c1c0c8091a53..d0355ce47a6504 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -1341,7 +1341,7 @@ conflict. .. envvar:: PYTHON_BASIC_COMPLETER If this variable is set to any value, PyREPL will use :mod:`rlcompleter` to - implement tab completion, instead of the default :mod:`_pyrepl.fancycompleter`. + implement tab completion, instead of the default one which uses colors. .. versionadded:: 3.15 From 49eda06b60bf54923aeec3fbb3be76f62245270e Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 18:37:17 +0200 Subject: [PATCH 39/49] force PYTHON_COLORS=1 for tests which expects to see colors. Hopefully this fixes the failures on Android CI --- Lib/test/test_pyrepl/test_fancycompleter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index b7271a04ad7dff..517bf814c28175 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,4 +1,6 @@ import unittest +import os +from unittest.mock import patch from _colorize import ANSIColors, get_theme from _pyrepl.fancycompleter import Completer, commonprefix @@ -57,6 +59,7 @@ class C(object): compl = Completer({'a': None}, use_colors=False) self.assertEqual(compl.attr_matches('a._'), ['a.__']) + @patch.dict(os.environ, {'PYTHON_COLORS': '1'}) def test_complete_attribute_colored(self): theme = get_theme() compl = Completer({'a': 42}, use_colors=True) @@ -93,6 +96,7 @@ def test_complete_global(self): self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) + @patch.dict(os.environ, {'PYTHON_COLORS': '1'}) def test_complete_global_colored(self): compl = Completer({'foobar': 1, 'foobazzz': 2}, use_colors=True) self.assertEqual(compl.global_matches('foo'), ['fooba']) @@ -111,6 +115,7 @@ def test_complete_global_colored(self): self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) + @patch.dict(os.environ, {'PYTHON_COLORS': '1'}) def test_complete_global_colored_exception(self): compl = Completer({'tryme': 42}, use_colors=True) N0 = f"\x1b[000;00m" From 3ce8d006333f1fb070ab6011985cb2ae3224ef2d Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 19:27:30 +0200 Subject: [PATCH 40/49] fix it in a different way: just look in the theme to find the expected color. This is both more robust and more correct --- Lib/test/test_pyrepl/test_fancycompleter.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 517bf814c28175..69566acaeaba46 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,6 +1,5 @@ import unittest import os -from unittest.mock import patch from _colorize import ANSIColors, get_theme from _pyrepl.fancycompleter import Completer, commonprefix @@ -59,7 +58,6 @@ class C(object): compl = Completer({'a': None}, use_colors=False) self.assertEqual(compl.attr_matches('a._'), ['a.__']) - @patch.dict(os.environ, {'PYTHON_COLORS': '1'}) def test_complete_attribute_colored(self): theme = get_theme() compl = Completer({'a': 42}, use_colors=True) @@ -96,8 +94,8 @@ def test_complete_global(self): self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) - @patch.dict(os.environ, {'PYTHON_COLORS': '1'}) def test_complete_global_colored(self): + theme = get_theme() compl = Completer({'foobar': 1, 'foobazzz': 2}, use_colors=True) self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') @@ -106,26 +104,15 @@ def test_complete_global_colored(self): # readline displays the matches in the proper order N0 = f"\x1b[000;00m" N1 = f"\x1b[001;00m" - + int_color = theme.fancycompleter.int self.assertEqual(set(matches), { ' ', - f'{N0}{ANSIColors.BOLD_YELLOW}foobar{ANSIColors.RESET}', - f'{N1}{ANSIColors.BOLD_YELLOW}foobazzz{ANSIColors.RESET}', + f'{N0}{int_color}foobar{ANSIColors.RESET}', + f'{N1}{int_color}foobazzz{ANSIColors.RESET}', }) self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) - @patch.dict(os.environ, {'PYTHON_COLORS': '1'}) - def test_complete_global_colored_exception(self): - compl = Completer({'tryme': 42}, use_colors=True) - N0 = f"\x1b[000;00m" - N1 = f"\x1b[001;00m" - self.assertEqual(compl.global_matches('try'), [ - f'{N0}{ANSIColors.GREY}try:{ANSIColors.RESET}', - f'{N1}{ANSIColors.BOLD_YELLOW}tryme{ANSIColors.RESET}', - ' ' - ]) - def test_complete_with_indexer(self): compl = Completer({'lst': [None, 2, 3]}, use_colors=False) self.assertEqual(compl.attr_matches('lst[0].'), ['lst[0].__']) From c71eed6c247b5340c31852b864323465fb325046 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 19 Sep 2025 19:30:34 +0200 Subject: [PATCH 41/49] fix precommit --- Lib/test/test_pyrepl/test_fancycompleter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 69566acaeaba46..3deb96c99c79a6 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,5 +1,4 @@ import unittest -import os from _colorize import ANSIColors, get_theme from _pyrepl.fancycompleter import Completer, commonprefix From d6b77e08f6c8b9796fa4d2440c8ef946249690fa Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Sun, 21 Sep 2025 22:03:06 +0200 Subject: [PATCH 42/49] Update Lib/_pyrepl/fancycompleter.py Co-authored-by: Pieter Eendebak --- Lib/_pyrepl/fancycompleter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index c88a2858c36cfb..b29ebfe5746d50 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -140,7 +140,8 @@ def colorize_matches(self, names, values): in enumerate(zip(names, values))] # We add a space at the end to prevent the automatic completion of the # common prefix, which is the ANSI escape sequence. - return matches + [' '] + matches.append(' ') + return matches def color_for_obj(self, i, name, value): t = type(value) From f919815bcb28f2ec758667c77da5abeca480b7c8 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 22 Sep 2025 09:58:07 +0200 Subject: [PATCH 43/49] put _colorize.FancyCompleter in alphabetical order w.r.t. the other sections --- Lib/_colorize.py | 56 ++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 7929ca5212f59f..8361ddbea89716 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -199,6 +199,30 @@ class Difflib(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True, kw_only=True) +class FancyCompleter(ThemeSection): + # functions and methods + function: str = ANSIColors.BOLD_BLUE + builtin_function_or_method: str = ANSIColors.BOLD_BLUE + method: str = ANSIColors.BOLD_CYAN + method_wrapper: str = ANSIColors.BOLD_CYAN + wrapper_descriptor: str = ANSIColors.BOLD_CYAN + method_descriptor: str = ANSIColors.BOLD_CYAN + + # numbers + int: str = ANSIColors.BOLD_YELLOW + float: str = ANSIColors.BOLD_YELLOW + complex: str = ANSIColors.BOLD_YELLOW + bool: str = ANSIColors.BOLD_YELLOW + + # others + type: str = ANSIColors.BOLD_MAGENTA + module: str = ANSIColors.CYAN + NoneType: str = ANSIColors.GREY + bytes: str = ANSIColors.BOLD_GREEN + str: str = ANSIColors.BOLD_GREEN + + @dataclass(frozen=True, kw_only=True) class LiveProfiler(ThemeSection): """Theme section for the live profiling TUI (Tachyon profiler). @@ -344,30 +368,6 @@ class Unittest(ThemeSection): reset: str = ANSIColors.RESET -@dataclass(frozen=True, kw_only=True) -class FancyCompleter(ThemeSection): - # functions and methods - function: str = ANSIColors.BOLD_BLUE - builtin_function_or_method: str = ANSIColors.BOLD_BLUE - method: str = ANSIColors.BOLD_CYAN - method_wrapper: str = ANSIColors.BOLD_CYAN - wrapper_descriptor: str = ANSIColors.BOLD_CYAN - method_descriptor: str = ANSIColors.BOLD_CYAN - - # numbers - int: str = ANSIColors.BOLD_YELLOW - float: str = ANSIColors.BOLD_YELLOW - complex: str = ANSIColors.BOLD_YELLOW - bool: str = ANSIColors.BOLD_YELLOW - - # others - type: str = ANSIColors.BOLD_MAGENTA - module: str = ANSIColors.CYAN - NoneType: str = ANSIColors.GREY - bytes: str = ANSIColors.BOLD_GREEN - str: str = ANSIColors.BOLD_GREEN - - @dataclass(frozen=True, kw_only=True) class Theme: """A suite of themes for all sections of Python. @@ -377,22 +377,22 @@ class Theme: """ argparse: Argparse = field(default_factory=Argparse) difflib: Difflib = field(default_factory=Difflib) + fancycompleter: FancyCompleter = field(default_factory=FancyCompleter) live_profiler: LiveProfiler = field(default_factory=LiveProfiler) syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) - fancycompleter: FancyCompleter = field(default_factory=FancyCompleter) def copy_with( self, *, argparse: Argparse | None = None, difflib: Difflib | None = None, + fancycompleter: FancyCompleter | None = None, live_profiler: LiveProfiler | None = None, syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, - fancycompleter: FancyCompleter | None = None, ) -> Self: """Return a new Theme based on this instance with some sections replaced. @@ -402,11 +402,11 @@ def copy_with( return type(self)( argparse=argparse or self.argparse, difflib=difflib or self.difflib, + fancycompleter=fancycompleter or self.fancycompleter, live_profiler=live_profiler or self.live_profiler, syntax=syntax or self.syntax, traceback=traceback or self.traceback, unittest=unittest or self.unittest, - fancycompleter=fancycompleter or self.fancycompleter, ) @classmethod @@ -420,11 +420,11 @@ def no_colors(cls) -> Self: return cls( argparse=Argparse.no_colors(), difflib=Difflib.no_colors(), + fancycompleter=FancyCompleter.no_colors(), live_profiler=LiveProfiler.no_colors(), syntax=Syntax.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), - fancycompleter=FancyCompleter.no_colors(), ) From 97c684e31b7ded0a98d30811c073b7f39285742e Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 26 Sep 2025 23:23:57 +0200 Subject: [PATCH 44/49] get_theme() is relatively expensive, fetch it early and cache it --- Lib/_pyrepl/fancycompleter.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index b29ebfe5746d50..27a7534b5784c8 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -33,6 +33,10 @@ def __init__( if self.use_colors: readline.parse_and_bind('set dont-escape-ctrl-chars on') + self.theme = get_theme() + else: + self.theme = None + if self.consider_getitems: delims = readline.get_completer_delims() delims = delims.replace('[', '') @@ -152,13 +156,12 @@ def color_for_obj(self, i, name, value): return f"{N}{color}{name}{ANSIColors.RESET}" def color_by_type(self, t): - theme = get_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) + return getattr(self.theme.fancycompleter, typename, ANSIColors.RESET) def commonprefix(names, base=''): From 588c204598beb8c608b2be4c7ba9b8f0abcb2af9 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 26 Sep 2025 23:26:00 +0200 Subject: [PATCH 45/49] base is never used when calling commonprefix, remove it --- Lib/_pyrepl/fancycompleter.py | 6 ++---- Lib/test/test_pyrepl/test_fancycompleter.py | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 27a7534b5784c8..46e58768ee0355 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -164,10 +164,8 @@ def color_by_type(self, t): return getattr(self.theme.fancycompleter, typename, ANSIColors.RESET) -def commonprefix(names, base=''): - """Return the common prefix of all 'names' starting with 'base'""" - if base: - names = [x for x in names if x.startswith(base)] +def commonprefix(names): + """Return the common prefix of all 'names'""" if not names: return '' s1 = min(names) diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 3deb96c99c79a6..88c5dfd306602f 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -29,9 +29,7 @@ def tearDown(self): def test_commonprefix(self): self.assertEqual(commonprefix(['isalpha', 'isdigit', 'foo']), '') self.assertEqual(commonprefix(['isalpha', 'isdigit']), 'is') - self.assertEqual(commonprefix(['isalpha', 'isdigit', 'foo'], base='i'), 'is') self.assertEqual(commonprefix([]), '') - self.assertEqual(commonprefix(['aaa', 'bbb'], base='x'), '') def test_complete_attribute(self): compl = Completer({'a': None}, use_colors=False) From 0646abbae65e914cc520892b4d635373dc57c654 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 26 Sep 2025 23:38:36 +0200 Subject: [PATCH 46/49] Update Lib/_pyrepl/fancycompleter.py Co-authored-by: Pieter Eendebak --- Lib/_pyrepl/fancycompleter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 46e58768ee0355..ca1e7b45ee8eb2 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -135,7 +135,7 @@ def attr_matches(self, text): return self.colorize_matches(names, values) if prefix: - names += [' '] + names.append(' ') return names def colorize_matches(self, names, values): From 4b11774471e003d994a9084e47d2ee9b8f15300c Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Sat, 27 Sep 2025 10:12:13 +0200 Subject: [PATCH 47/49] there is no need to sort words in advance, we can just sort names later --- Lib/_pyrepl/fancycompleter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index ca1e7b45ee8eb2..11ef38273d10c2 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -100,7 +100,6 @@ def attr_matches(self, text): noprefix = '__' else: noprefix = None - words = sorted(words) while True: for word in words: if ( @@ -131,6 +130,7 @@ def attr_matches(self, text): if prefix and prefix != attr: return [f'{expr}.{prefix}'] # autocomplete prefix + names.sort() if self.use_colors: return self.colorize_matches(names, values) From 9b9b37d46497d00abd62f8ff647d3104dde0436e Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Sat, 27 Sep 2025 22:39:34 +0200 Subject: [PATCH 48/49] undo 6a5bcfe9ed: there IS actually a good reason to sort the words in advance, let's add a comment to explain why --- Lib/_pyrepl/fancycompleter.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 11ef38273d10c2..c0c2081e66b52b 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -100,6 +100,12 @@ def attr_matches(self, text): noprefix = '__' else: noprefix = None + + # sort the words now to make sure to return completions in + # alphabetical order. It's easier to do it now, else we would need to + # sort 'names' later but make sure that 'values' in kept in sync, + # which is annoying. + words = sorted(words) while True: for word in words: if ( @@ -130,7 +136,6 @@ def attr_matches(self, text): if prefix and prefix != attr: return [f'{expr}.{prefix}'] # autocomplete prefix - names.sort() if self.use_colors: return self.colorize_matches(names, values) From cba06ba3a2ddef37467fd8f0ed49872b82977058 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Sun, 5 Apr 2026 14:55:51 +0100 Subject: [PATCH 49/49] 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. --- Lib/_pyrepl/completing_reader.py | 12 ++- Lib/_pyrepl/fancycompleter.py | 99 +++++++++++------- Lib/_pyrepl/readline.py | 6 +- Lib/test/test_pyrepl/test_fancycompleter.py | 107 +++++++++++++++++--- Lib/test/test_pyrepl/test_pyrepl.py | 88 ++++++++++++++++ 5 files changed, 255 insertions(+), 57 deletions(-) diff --git a/Lib/_pyrepl/completing_reader.py b/Lib/_pyrepl/completing_reader.py index 9d2d43be5144e8..5802920a907ca4 100644 --- a/Lib/_pyrepl/completing_reader.py +++ b/Lib/_pyrepl/completing_reader.py @@ -178,12 +178,14 @@ def do(self) -> None: if not completions: r.error("no matches") elif len(completions) == 1: - if completions_unchangable and len(completions[0]) == len(stem): + completion = stripcolor(completions[0]) + if completions_unchangable and len(completion) == len(stem): r.msg = "[ sole completion ]" r.dirty = True - r.insert(completions[0][len(stem):]) + r.insert(completion[len(stem):]) else: - p = prefix(completions, len(stem)) + clean_completions = [stripcolor(word) for word in completions] + p = prefix(clean_completions, len(stem)) if p: r.insert(p) if last_is_completer: @@ -195,7 +197,7 @@ def do(self) -> None: r.dirty = True elif not r.cmpltn_menu_visible: r.cmpltn_message_visible = True - if stem + p in completions: + if stem + p in clean_completions: r.msg = "[ complete but not unique ]" r.dirty = True else: @@ -215,7 +217,7 @@ def do(self) -> None: r.cmpltn_reset() else: completions = [w for w in r.cmpltn_menu_choices - if w.startswith(stem)] + if stripcolor(w).startswith(stem)] if completions: r.cmpltn_menu, r.cmpltn_menu_end = build_menu( r.console, completions, 0, diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index c0c2081e66b52b..5b5b7ae5f2bb59 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -5,13 +5,13 @@ """Colorful tab completion for Python prompt""" from _colorize import ANSIColors, get_colors, get_theme import rlcompleter -import types import keyword +import types class Completer(rlcompleter.Completer): """ - When doing someting like a.b., display only the attributes of - b instead of the full a.b.attr string. + When doing something like a.b., keep the full a.b.attr completion + stem so readline-style completion can keep refining the menu as you type. Optionally, display the various completions in different colors depending on the type. @@ -32,6 +32,10 @@ def __init__( self.consider_getitems = consider_getitems if self.use_colors: + # In GNU readline, this prevents escaping of ANSI control + # characters in completion results. pyrepl's parse_and_bind() + # is a no-op, but pyrepl handles ANSI sequences natively + # via real_len()/stripcolor(). readline.parse_and_bind('set dont-escape-ctrl-chars on') self.theme = get_theme() else: @@ -55,6 +59,9 @@ def _callable_postfix(self, val, word): # disable automatic insertion of '(' for global callables return word + def _callable_attr_postfix(self, val, word): + return rlcompleter.Completer._callable_postfix(self, val, word) + def global_matches(self, text): names = rlcompleter.Completer.global_matches(self, text) prefix = commonprefix(names) @@ -65,25 +72,52 @@ def global_matches(self, text): values = [] for name in names: clean_name = name.rstrip(': ') - if clean_name in keyword.kwlist: + if keyword.iskeyword(clean_name) or keyword.issoftkeyword(clean_name): values.append(None) else: try: values.append(eval(name, self.namespace)) - except Exception as exc: + except Exception: values.append(None) if self.use_colors and names: return self.colorize_matches(names, values) return names def attr_matches(self, text): + try: + expr, attr, names, values = self._attr_matches(text) + except ValueError: + return [] + + if not names: + return [] + + if len(names) == 1: + # No coloring: when returning a single completion, readline + # inserts it directly into the prompt, so ANSI codes would + # appear as literal characters. + return [self._callable_attr_postfix(values[0], f'{expr}.{names[0]}')] + + prefix = commonprefix(names) + if prefix and prefix != attr: + return [f'{expr}.{prefix}'] # autocomplete prefix + + names = [f'{expr}.{name}' for name in names] + if self.use_colors: + return self.colorize_matches(names, values) + + if prefix: + names.append(' ') + return names + + def _attr_matches(self, text): expr, attr = text.rsplit('.', 1) if '(' in expr or ')' in expr: # don't call functions - return [] + return expr, attr, [], [] try: thisobject = eval(expr, self.namespace) except Exception: - return [] + return expr, attr, [], [] # get the content of the object, except __builtins__ words = set(dir(thisobject)) - {'__builtins__'} @@ -112,13 +146,23 @@ def attr_matches(self, text): word[:n] == attr and not (noprefix and word[:n+1] == noprefix) ): - try: - val = getattr(thisobject, word) - except Exception: - val = None # Include even if attribute not set + # 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) names.append(word) - values.append(val) + values.append(value) if names or not noprefix: break if noprefix == '_': @@ -126,25 +170,10 @@ def attr_matches(self, text): else: noprefix = None - if not names: - return [] - - if len(names) == 1: - return [f'{expr}.{names[0]}'] # only option, no coloring. - - prefix = commonprefix(names) - if prefix and prefix != attr: - return [f'{expr}.{prefix}'] # autocomplete prefix - - if self.use_colors: - return self.colorize_matches(names, values) - - if prefix: - names.append(' ') - return names + return expr, attr, names, values def colorize_matches(self, names, values): - matches = [self.color_for_obj(i, name, obj) + matches = [self._color_for_obj(i, name, obj) for i, (name, obj) in enumerate(zip(names, values))] # We add a space at the end to prevent the automatic completion of the @@ -152,15 +181,15 @@ def colorize_matches(self, names, values): matches.append(' ') return matches - def color_for_obj(self, i, name, value): + def _color_for_obj(self, i, name, value): t = type(value) - color = self.color_by_type(t) - # hack: prepend an (increasing) fake escape sequence, - # so that readline can sort the matches correctly. - N = f"\x1b[{i:03d};00m" + color = self._color_by_type(t) + # Encode the match index into a fake escape sequence that + # stripcolor() can still remove once i reaches four digits. + N = f"\x1b[{i // 100:03d};{i % 100:02d}m" return f"{N}{color}{name}{ANSIColors.RESET}" - def color_by_type(self, t): + 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" diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 9eefdb5c04ab65..17319963b1950a 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -610,7 +610,11 @@ def _setup(namespace: Mapping[str, Any]) -> None: if not isinstance(namespace, dict): namespace = dict(namespace) _wrapper.config.module_completer = ModuleCompleter(namespace) - completer_cls = RLCompleter if os.getenv("PYTHON_BASIC_COMPLETER") else FancyCompleter + 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 # this is not really what readline.c does. Better than nothing I guess diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 88c5dfd306602f..77c80853a3c0e3 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -1,7 +1,12 @@ +import importlib +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 test.support.import_helper import ready_to_import class MockPatch: def __init__(self): @@ -35,9 +40,11 @@ def test_complete_attribute(self): compl = Completer({'a': None}, use_colors=False) self.assertEqual(compl.attr_matches('a.'), ['a.__']) matches = compl.attr_matches('a.__') - self.assertNotIn('a.__class__', matches) - self.assertIn('__class__', matches) - self.assertEqual(compl.attr_matches('a.__class'), ['a.__class__']) + self.assertNotIn('__class__', matches) + self.assertIn('a.__class__', matches) + match = compl.attr_matches('a.__class') + self.assertEqual(len(match), 1) + self.assertTrue(match[0].startswith('a.__class__')) def test_complete_attribute_prefix(self): class C(object): @@ -45,12 +52,17 @@ class C(object): _attr = 2 __attr__attr = 3 compl = Completer({'a': C}, use_colors=False) - self.assertEqual(compl.attr_matches('a.'), ['attr', 'mro']) - self.assertEqual(compl.attr_matches('a._'), ['_C__attr__attr', '_attr', ' ']) + self.assertEqual(compl.attr_matches('a.'), ['a.attr', 'a.mro']) + self.assertEqual( + compl.attr_matches('a._'), + ['a._C__attr__attr', 'a._attr', ' '], + ) matches = compl.attr_matches('a.__') - self.assertNotIn('a.__class__', matches) - self.assertIn('__class__', matches) - self.assertEqual(compl.attr_matches('a.__class'), ['a.__class__']) + self.assertNotIn('__class__', matches) + self.assertIn('a.__class__', matches) + match = compl.attr_matches('a.__class') + self.assertEqual(len(match), 1) + self.assertTrue(match[0].startswith('a.__class__')) compl = Completer({'a': None}, use_colors=False) self.assertEqual(compl.attr_matches('a._'), ['a.__']) @@ -61,7 +73,7 @@ def test_complete_attribute_colored(self): matches = compl.attr_matches('a.__') self.assertGreater(len(matches), 2) expected_color = theme.fancycompleter.type - expected_part = f'{expected_color}__class__{ANSIColors.RESET}' + expected_part = f'{expected_color}a.__class__{ANSIColors.RESET}' for match in matches: if expected_part in match: break @@ -69,6 +81,56 @@ def test_complete_attribute_colored(self): self.assertFalse(True, matches) self.assertIn(' ', matches) + def test_preserves_callable_postfix_for_single_attribute_match(self): + compl = Completer({'os': os}, use_colors=False) + self.assertEqual(compl.attr_matches('os.getpid'), ['os.getpid()']) + + def test_property_method_not_called(self): + class Foo: + property_called = False + + @property + def bar(self): + self.property_called = True + return 1 + + foo = Foo() + compl = Completer({'foo': foo}, use_colors=False) + self.assertEqual(compl.attr_matches('foo.b'), ['foo.bar']) + self.assertFalse(foo.property_called) + + def test_excessive_getattr(self): + class Foo: + calls = 0 + bar = '' + + def __getattribute__(self, name): + if name == 'bar': + self.calls += 1 + return None + return super().__getattribute__(name) + + foo = Foo() + compl = Completer({'foo': foo}, use_colors=False) + self.assertEqual(compl.complete('foo.b', 0), 'foo.bar') + self.assertEqual(foo.calls, 1) + + def test_uncreated_attr(self): + class Foo: + __slots__ = ('bar',) + + compl = Completer({'foo': Foo()}, use_colors=False) + self.assertEqual(compl.complete('foo.', 0), 'foo.bar') + + def test_module_attributes_do_not_reify_lazy_imports(self): + with ready_to_import("test_pyrepl_lazy_mod", "lazy import json\n") as (name, _): + module = importlib.import_module(name) + self.assertIs(type(module.__dict__["json"]), types.LazyImportType) + + compl = Completer({name: module}, use_colors=False) + self.assertEqual(compl.attr_matches(f"{name}.j"), [f"{name}.json"]) + self.assertIs(type(module.__dict__["json"]), types.LazyImportType) + def test_complete_colored_single_match(self): """No coloring, via commonprefix.""" compl = Completer({'foobar': 42}, use_colors=True) @@ -100,7 +162,7 @@ def test_complete_global_colored(self): # these are the fake escape sequences which are needed so that # readline displays the matches in the proper order N0 = f"\x1b[000;00m" - N1 = f"\x1b[001;00m" + N1 = f"\x1b[000;01m" int_color = theme.fancycompleter.int self.assertEqual(set(matches), { ' ', @@ -110,13 +172,20 @@ def test_complete_global_colored(self): self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) + def test_large_color_sort_prefix_is_stripped(self): + compl = Completer({'a': 42}, use_colors=True) + match = compl._color_for_obj(1000, 'spam', 1) + self.assertEqual(stripcolor(match), 'spam') + def test_complete_with_indexer(self): compl = Completer({'lst': [None, 2, 3]}, use_colors=False) self.assertEqual(compl.attr_matches('lst[0].'), ['lst[0].__']) matches = compl.attr_matches('lst[0].__') - self.assertNotIn('lst[0].__class__', matches) - self.assertIn('__class__', matches) - self.assertEqual(compl.attr_matches('lst[0].__class'), ['lst[0].__class__']) + self.assertNotIn('__class__', matches) + self.assertIn('lst[0].__class__', matches) + match = compl.attr_matches('lst[0].__class') + self.assertEqual(len(match), 1) + self.assertTrue(match[0].startswith('lst[0].__class__')) def test_autocomplete(self): class A: @@ -132,7 +201,10 @@ class A: # automatically insert the common prefix (which will the the ANSI escape # sequence if we use colors). matches = compl.attr_matches('A.a') - self.assertEqual(sorted(matches), [' ', 'aaa', 'abc_1', 'abc_2', 'abc_3']) + self.assertEqual( + sorted(matches), + [' ', 'A.aaa', 'A.abc_1', 'A.abc_2', 'A.abc_3'], + ) # # If there is an actual common prefix, we return just it, so that readline # will insert it into place @@ -143,7 +215,10 @@ class A: # for this common prefix. Again, we insert a spurious space to prevent the # automatic completion of ANSI sequences. matches = compl.attr_matches('A.abc_') - self.assertEqual(sorted(matches), [' ', 'abc_1', 'abc_2', 'abc_3']) + self.assertEqual( + sorted(matches), + [' ', 'A.abc_1', 'A.abc_2', 'A.abc_3'], + ) def test_complete_exception(self): compl = Completer({}, use_colors=False) @@ -164,7 +239,7 @@ def __dir__(self): compl = Completer({'a': Foo()}, use_colors=False) matches = compl.attr_matches('a.') - self.assertEqual(matches, ['hello', 'world']) + self.assertEqual(matches, ['a.hello', 'a.world']) self.assertIs(type(matches[0]), str) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 8854b19efce019..18e88ce4e7724a 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -33,6 +33,8 @@ ModuleCompleter, HARDCODED_SUBMODULES, ) +from _pyrepl.fancycompleter import Completer as FancyCompleter +import _pyrepl.readline as pyrepl_readline from _pyrepl.readline import ( ReadlineAlikeReader, ReadlineConfig, @@ -941,6 +943,92 @@ def test_func(self): self.assertEqual(mock_stderr.getvalue(), "") +class TestPyReplFancyCompleter(TestCase): + def prepare_reader(self, events, namespace, *, use_colors): + console = FakeConsole(events) + config = ReadlineConfig() + config.readline_completer = FancyCompleter( + namespace, use_colors=use_colors + ).complete + reader = ReadlineAlikeReader(console=console, config=config) + return reader + + def test_simple_completion_preserves_callable_postfix(self): + events = code_to_events("os.getpid\t\n") + + namespace = {"os": os} + reader = self.prepare_reader(events, namespace, use_colors=False) + + output = multiline_input(reader, namespace) + self.assertEqual(output, "os.getpid()") + + def test_attribute_menu_tracks_typed_stem(self): + class Obj: + apple = 1 + apricot = 2 + banana = 3 + + namespace = {"obj": Obj} + reader = self.prepare_reader( + code_to_events("obj.\t\ta"), + namespace, + use_colors=True, + ) + + with self.assertRaises(StopIteration): + while True: + reader.handle1() + + self.assertEqual("".join(reader.buffer), "obj.a") + self.assertTrue(reader.cmpltn_menu_visible) + menu = "\n".join(reader.cmpltn_menu) + self.assertIn("apple", menu) + self.assertIn("apricot", menu) + self.assertNotIn("banana", menu) + self.assertNotIn("mro", menu) + + +class TestPyReplReadlineSetup(TestCase): + def test_setup_ignores_basic_completer_env_when_env_is_disabled(self): + class FakeFancyCompleter: + def __init__(self, namespace): + self.namespace = namespace + + def complete(self, text, state): + return None + + class FakeBasicCompleter(FakeFancyCompleter): + pass + + wrapper = Mock() + wrapper.config = ReadlineConfig() + stdin = Mock() + stdout = Mock() + stdin.fileno.return_value = 0 + stdout.fileno.return_value = 1 + + with ( + patch.object(pyrepl_readline, "_wrapper", wrapper), + patch.object(pyrepl_readline, "raw_input", None), + patch.object(pyrepl_readline, "FancyCompleter", FakeFancyCompleter), + patch.object(pyrepl_readline, "RLCompleter", FakeBasicCompleter), + patch.object(pyrepl_readline.sys, "stdin", stdin), + patch.object(pyrepl_readline.sys, "stdout", stdout), + patch.object(pyrepl_readline.sys, "flags", Mock(ignore_environment=True)), + patch.object(pyrepl_readline.os, "isatty", return_value=True), + patch.object(pyrepl_readline.os, "getenv") as mock_getenv, + patch("builtins.input", lambda prompt="": prompt), + ): + mock_getenv.return_value = "1" + pyrepl_readline._setup({}) + + self.assertIsInstance( + wrapper.config.readline_completer.__self__, + FakeFancyCompleter, + ) + mock_getenv.assert_not_called() + + class TestPyReplModuleCompleter(TestCase): def setUp(self): # Make iter_modules() search only the standard library.