Skip to content
81 changes: 61 additions & 20 deletions Lib/_pyrepl/_module_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from dataclasses import dataclass
from itertools import chain
from tokenize import TokenInfo
from .fancycompleter import safe_getattr

TYPE_CHECKING = False

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

def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None:
def get_completions(
self, line: str, *, include_values: bool = True
) -> tuple[list[str], list[Any], CompletionAction | None] | None:
"""Return the next possible import completions for 'line'.

For attributes completion, if the module to complete from is not
imported, also return an action (prompt + callback to run if the
user press TAB again) to import the module.

If *include_values* is false, the returned values list is empty and
attribute values are not resolved.
"""
result = ImportParser(line).parse()
if not result:
return None
try:
return self.complete(*result)
return self.complete(*result, include_values=include_values)
except Exception:
# Some unexpected error occurred, make it look like
# no completions are available
return [], None

def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]:
return [], [], None

def complete(
self,
from_name: str | None,
name: str | None,
*,
include_values: bool = True,
) -> tuple[list[str], list[Any], CompletionAction | None]:
if from_name is None:
# import x.y.z<tab>
assert name is not None
path, prefix = self.get_path_and_prefix(name)
modules = self.find_modules(path, prefix)
return [self.format_completion(path, module) for module in modules], None
names = [self.format_completion(path, module) for module in modules]
# These are always modules, use dummy values to get the right color
values = [sys] * len(names) if include_values else []
return names, values, None

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

# from x.y import z<tab>
submodules = self.find_modules(from_name, name)
attributes, action = self.find_attributes(from_name, name)
return sorted({*submodules, *attributes}), action
attr_names, attr_module, action = self._find_attributes(from_name, name)
all_names = sorted({*submodules, *attr_names})
if not include_values:
return all_names, [], action

# Build values list matching the sorted order:
# submodules use `sys` as a dummy value so they get the 'module' color,
# attributes use their actual value.
attr_map = {}
if attr_module is not None:
attr_map = {n: safe_getattr(attr_module, n) for n in attr_names}
all_values = [attr_map[n] if n in attr_map else sys for n in all_names]
return all_names, all_values, action

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

def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
def find_attributes(
self, path: str, prefix: str
) -> tuple[list[str], list[Any], CompletionAction | None]:
"""Find all attributes of module 'path' that start with 'prefix'."""
attributes, action = self._find_attributes(path, prefix)
# Filter out invalid attribute names
# (for example those containing dashes that cannot be imported with 'import')
return [attr for attr in attributes if attr.isidentifier()], action
attributes, module, action = self._find_attributes(path, prefix)
if module is not None:
values = [safe_getattr(module, attr) for attr in attributes]
else:
values = []
return attributes, values, action

def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
def _find_attributes(
self, path: str, prefix: str
) -> tuple[list[str], ModuleType | None, CompletionAction | None]:
path = self._resolve_relative_path(path) # type: ignore[assignment]
if path is None:
return [], None
return [], None, None

imported_module = sys.modules.get(path)
if not imported_module:
if path in self._failed_imports: # Do not propose to import again
return [], None
return [], None, None
imported_module = self._maybe_import_module(path)
if not imported_module:
return [], self._get_import_completion_action(path)
return [], None, self._get_import_completion_action(path)
try:
module_attributes = dir(imported_module)
except Exception:
module_attributes = []
return [attr_name for attr_name in module_attributes
if self.is_suggestion_match(attr_name, prefix)], None
# Filter out invalid attribute names, such as dashes that cannot be
# imported with 'import'.
names = [
attr_name for attr_name in module_attributes
if (self.is_suggestion_match(attr_name, prefix)
and attr_name.isidentifier())
]
return names, imported_module, None

def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
if prefix:
Expand Down
76 changes: 44 additions & 32 deletions Lib/_pyrepl/fancycompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,53 @@
#
# All Rights Reserved
"""Colorful tab completion for Python prompt"""
from __future__ import annotations

from _colorize import ANSIColors, get_colors, get_theme
import rlcompleter
import keyword
import types

TYPE_CHECKING = False

if TYPE_CHECKING:
from typing import Any
from _colorize import Theme


def safe_getattr(obj, name):
# Mirror rlcompleter's safeguards so completion does not
# call properties or reify lazy module attributes.
if isinstance(getattr(type(obj), name, None), property):
return None
if (isinstance(obj, types.ModuleType)
and isinstance(obj.__dict__.get(name), types.LazyImportType)
):
return obj.__dict__.get(name)
return getattr(obj, name, None)


def colorize_matches(names: list[str], values: list[Any], theme: Theme) -> list[str]:
return [
_color_for_obj(name, obj, theme)
for name, obj in zip(names, values)
]

def _color_for_obj(name: str, value: Any, theme: Theme) -> str:
t = type(value)
color = _color_by_type(t, theme)
return f"{color}{name}{ANSIColors.RESET}"


def _color_by_type(t, 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)


class Completer(rlcompleter.Completer):
"""
When doing something like a.b.<tab>, keep the full a.b.attr completion
Expand Down Expand Up @@ -143,21 +185,7 @@ def _attr_matches(self, text):
word[:n] == attr
and not (noprefix and word[:n+1] == noprefix)
):
# 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)

value = safe_getattr(thisobject, word)
names.append(word)
values.append(value)
if names or not noprefix:
Expand All @@ -170,23 +198,7 @@ def _attr_matches(self, text):
return expr, attr, names, values

def colorize_matches(self, names, values):
return [
self._color_for_obj(name, obj)
for name, obj in zip(names, values)
]

def _color_for_obj(self, name, value):
t = type(value)
color = self._color_by_type(t)
return f"{color}{name}{ANSIColors.RESET}"

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"
# our keys need to be valid identifiers.
typename = typename.replace('-', '_').replace('.', '_')
return getattr(self.theme.fancycompleter, typename, ANSIColors.RESET)
return colorize_matches(names, values, self.theme)


def commonprefix(names):
Expand Down
26 changes: 21 additions & 5 deletions Lib/_pyrepl/readline.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from .completing_reader import CompletingReader, stripcolor
from .console import Console as ConsoleType
from ._module_completer import ModuleCompleter, make_default_module_completer
from .fancycompleter import Completer as FancyCompleter
from .fancycompleter import Completer as FancyCompleter, colorize_matches

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

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

def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | None:
line = self.get_line()
return self.config.module_completer.get_completions(line)
line = stripcolor(self.get_line())
colorize_completions = self.config.colorize_completions
result = self.config.module_completer.get_completions(
line, include_values=bool(colorize_completions)
)
if result is None:
return None
names, values, action = result
if colorize_completions:
names = colorize_completions(names, values)
return names, action

def get_trimmed_history(self, maxlength: int) -> list[str]:
if maxlength >= 0:
Expand Down Expand Up @@ -609,13 +619,19 @@ def _setup(namespace: Mapping[str, Any]) -> None:
# set up namespace in rlcompleter, which requires it to be a bona fide dict
if not isinstance(namespace, dict):
namespace = dict(namespace)
_wrapper.config.module_completer = ModuleCompleter(namespace)
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
completer = completer_cls(namespace)
_wrapper.config.readline_completer = completer.complete
if isinstance(completer, FancyCompleter) and completer.use_colors:
theme = completer.theme
def _colorize(names: list[str], values: list[object]) -> list[str]:
return colorize_matches(names, values, theme)
_wrapper.config.colorize_completions = _colorize
_wrapper.config.module_completer = ModuleCompleter(namespace)

# this is not really what readline.c does. Better than nothing I guess
import builtins
Expand Down
17 changes: 14 additions & 3 deletions Lib/test/test_pyrepl/test_fancycompleter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import importlib
import inspect
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 _pyrepl.fancycompleter import (
Completer,
colorize_matches,
commonprefix,
_color_for_obj,
)
from test.support.import_helper import ready_to_import

class MockPatch:
Expand Down Expand Up @@ -36,6 +42,11 @@ def test_commonprefix(self):
self.assertEqual(commonprefix(['isalpha', 'isdigit']), 'is')
self.assertEqual(commonprefix([]), '')

def test_colorize_matches_signature(self):
signature = inspect.signature(colorize_matches)

self.assertEqual(list(signature.parameters), ["names", "values", "theme"])

def test_complete_attribute(self):
compl = Completer({'a': None}, use_colors=False)
self.assertEqual(compl.attr_matches('a.'), ['a.__'])
Expand Down Expand Up @@ -168,8 +179,8 @@ def test_complete_global_colored(self):
self.assertEqual(compl.global_matches('nothing'), [])

def test_colorized_match_is_stripped(self):
compl = Completer({'a': 42}, use_colors=True)
match = compl._color_for_obj('spam', 1)
theme = get_theme()
match = _color_for_obj('spam', 1, theme)
self.assertEqual(stripcolor(match), 'spam')

def test_complete_with_indexer(self):
Expand Down
Loading
Loading