Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 61 additions & 20 deletions Lib/_pyrepl/_module_completer.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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