from __future__ import annotations import ast import collections.abc as collections_abc import inspect import keyword import re from enum import Enum from typing import TYPE_CHECKING, Any, Callable, Iterable from prompt_toolkit.completion import ( CompleteEvent, Completer, Completion, PathCompleter, ) from prompt_toolkit.contrib.completers.system import SystemCompleter from prompt_toolkit.contrib.regular_languages.compiler import compile as compile_grammar from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import fragment_list_to_text, to_formatted_text from ptpython.utils import get_jedi_script_from_document if TYPE_CHECKING: import jedi.api.classes from prompt_toolkit.contrib.regular_languages.compiler import _CompiledGrammar __all__ = ["PythonCompleter", "CompletePrivateAttributes", "HidePrivateCompleter"] class CompletePrivateAttributes(Enum): """ Should we display private attributes in the completion pop-up? """ NEVER = "NEVER" IF_NO_PUBLIC = "IF_NO_PUBLIC" ALWAYS = "ALWAYS" class PythonCompleter(Completer): """ Completer for Python code. """ def __init__( self, get_globals: Callable[[], dict[str, Any]], get_locals: Callable[[], dict[str, Any]], enable_dictionary_completion: Callable[[], bool], ) -> None: super().__init__() self.get_globals = get_globals self.get_locals = get_locals self.enable_dictionary_completion = enable_dictionary_completion self._system_completer = SystemCompleter() self._jedi_completer = JediCompleter(get_globals, get_locals) self._dictionary_completer = DictionaryCompleter(get_globals, get_locals) self._path_completer_cache: GrammarCompleter | None = None self._path_completer_grammar_cache: _CompiledGrammar | None = None @property def _path_completer(self) -> GrammarCompleter: if self._path_completer_cache is None: self._path_completer_cache = GrammarCompleter( self._path_completer_grammar, { "var1": PathCompleter(expanduser=True), "var2": PathCompleter(expanduser=True), }, ) return self._path_completer_cache @property def _path_completer_grammar(self) -> _CompiledGrammar: """ Return the grammar for matching paths inside strings inside Python code. """ # We make this lazy, because it delays startup time a little bit. # This way, the grammar is build during the first completion. if self._path_completer_grammar_cache is None: self._path_completer_grammar_cache = self._create_path_completer_grammar() return self._path_completer_grammar_cache def _create_path_completer_grammar(self) -> _CompiledGrammar: def unwrapper(text: str) -> str: return re.sub(r"\\(.)", r"\1", text) def single_quoted_wrapper(text: str) -> str: return text.replace("\\", "\\\\").replace("'", "\\'") def double_quoted_wrapper(text: str) -> str: return text.replace("\\", "\\\\").replace('"', '\\"') grammar = r""" # Text before the current string. ( [^'"#] | # Not quoted characters. ''' ([^'\\]|'(?!')|''(?!')|\\.])* ''' | # Inside single quoted triple strings "" " ([^"\\]|"(?!")|""(?!^)|\\.])* "" " | # Inside double quoted triple strings \#[^\n]*(\n|$) | # Comment. "(?!"") ([^"\\]|\\.)*" | # Inside double quoted strings. '(?!'') ([^'\\]|\\.)*' # Inside single quoted strings. # Warning: The negative lookahead in the above two # statements is important. If we drop that, # then the regex will try to interpret every # triple quoted string also as a single quoted # string, making this exponentially expensive to # execute! )* # The current string that we're completing. ( ' (?P([^\n'\\]|\\.)*) | # Inside a single quoted string. " (?P([^\n"\\]|\\.)*) # Inside a double quoted string. ) """ return compile_grammar( grammar, escape_funcs={"var1": single_quoted_wrapper, "var2": double_quoted_wrapper}, unescape_funcs={"var1": unwrapper, "var2": unwrapper}, ) def _complete_path_while_typing(self, document: Document) -> bool: char_before_cursor = document.char_before_cursor return bool( document.text and (char_before_cursor.isalnum() or char_before_cursor in "/.~") ) def _complete_python_while_typing(self, document: Document) -> bool: """ When `complete_while_typing` is set, only return completions when this returns `True`. """ text = document.text_before_cursor # .rstrip() char_before_cursor = text[-1:] return bool( text and (char_before_cursor.isalnum() or char_before_cursor in "_.([,") ) def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: """ Get Python completions. """ # If the input starts with an exclamation mark. Use the system completer. if document.text.lstrip().startswith("!"): yield from self._system_completer.get_completions( Document( text=document.text[1:], cursor_position=document.cursor_position - 1 ), complete_event, ) return # Do dictionary key completions. if complete_event.completion_requested or self._complete_python_while_typing( document ): if self.enable_dictionary_completion(): has_dict_completions = False for c in self._dictionary_completer.get_completions( document, complete_event ): if c.text not in "[.": # If we get the [ or . completion, still include the other # completions. has_dict_completions = True yield c if has_dict_completions: return # Do Path completions (if there were no dictionary completions). if complete_event.completion_requested or self._complete_path_while_typing( document ): yield from self._path_completer.get_completions(document, complete_event) # Do Jedi completions. if complete_event.completion_requested or self._complete_python_while_typing( document ): # If we are inside a string, Don't do Jedi completion. if not self._path_completer_grammar.match(document.text_before_cursor): # Do Jedi Python completions. yield from self._jedi_completer.get_completions( document, complete_event ) class JediCompleter(Completer): """ Autocompleter that uses the Jedi library. """ def __init__( self, get_globals: Callable[[], dict[str, Any]], get_locals: Callable[[], dict[str, Any]], ) -> None: super().__init__() self.get_globals = get_globals self.get_locals = get_locals def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: script = get_jedi_script_from_document( document, self.get_locals(), self.get_globals() ) if script: try: jedi_completions = script.complete( column=document.cursor_position_col, line=document.cursor_position_row + 1, ) except TypeError: # Issue #9: bad syntax causes completions() to fail in jedi. # https://github.com/jonathanslenders/python-prompt-toolkit/issues/9 pass except UnicodeDecodeError: # Issue #43: UnicodeDecodeError on OpenBSD # https://github.com/jonathanslenders/python-prompt-toolkit/issues/43 pass except AttributeError: # Jedi issue #513: https://github.com/davidhalter/jedi/issues/513 pass except ValueError: # Jedi issue: "ValueError: invalid \x escape" pass except KeyError: # Jedi issue: "KeyError: u'a_lambda'." # https://github.com/jonathanslenders/ptpython/issues/89 pass except OSError: # Jedi issue: "IOError: No such file or directory." # https://github.com/jonathanslenders/ptpython/issues/71 pass except AssertionError: # In jedi.parser.__init__.py: 227, in remove_last_newline, # the assertion "newline.value.endswith('\n')" can fail. pass except SystemError: # In jedi.api.helpers.py: 144, in get_stack_at_position # raise SystemError("This really shouldn't happen. There's a bug in Jedi.") pass except NotImplementedError: # See: https://github.com/jonathanslenders/ptpython/issues/223 pass except Exception: # Suppress all other Jedi exceptions. pass else: # Move function parameters to the top. jedi_completions = sorted( jedi_completions, key=lambda jc: ( # Params first. jc.type != "param", # Private at the end. jc.name.startswith("_"), # Then sort by name. jc.name_with_symbols.lower(), ), ) for jc in jedi_completions: if jc.type == "function": suffix = "()" else: suffix = "" if jc.type == "param": suffix = "..." yield Completion( jc.name_with_symbols, len(jc.complete) - len(jc.name_with_symbols), display=jc.name_with_symbols + suffix, display_meta=jc.type, style=_get_style_for_jedi_completion(jc), ) class DictionaryCompleter(Completer): """ Experimental completer for Python dictionary keys. Warning: This does an `eval` and `repr` on some Python expressions before the cursor, which is potentially dangerous. It doesn't match on function calls, so it only triggers attribute access. """ def __init__( self, get_globals: Callable[[], dict[str, Any]], get_locals: Callable[[], dict[str, Any]], ) -> None: super().__init__() self.get_globals = get_globals self.get_locals = get_locals # Pattern for expressions that are "safe" to eval for auto-completion. # These are expressions that contain only attribute and index lookups. varname = r"[a-zA-Z_][a-zA-Z0-9_]*" expression = rf""" # Any expression safe enough to eval while typing. # No operators, except dot, and only other dict lookups. # Technically, this can be unsafe of course, if bad code runs # in `__getattr__` or ``__getitem__``. ( # Variable name {varname} \s* (?: # Attribute access. \s* \. \s* {varname} \s* | # Item lookup. # (We match the square brackets. The key can be anything. # We don't care about matching quotes here in the regex. # Nested square brackets are not supported.) \s* \[ [^\[\]]+ \] \s* )* ) """ # Pattern for recognizing for-loops, so that we can provide # autocompletion on the iterator of the for-loop. (According to the # first item of the collection we're iterating over.) self.for_loop_pattern = re.compile( rf""" for \s+ ([a-zA-Z0-9_]+) \s+ in \s+ {expression} \s* : """, re.VERBOSE, ) # Pattern for matching a simple expression (for completing [ or . # operators). self.expression_pattern = re.compile( rf""" {expression} $ """, re.VERBOSE, ) # Pattern for matching item lookups. self.item_lookup_pattern = re.compile( rf""" {expression} # Dict lookup to complete (square bracket open + start of # string). \[ \s* ([^\[\]]*)$ """, re.VERBOSE, ) # Pattern for matching attribute lookups. self.attribute_lookup_pattern = re.compile( rf""" {expression} # Attribute lookup to complete (dot + varname). \. \s* ([a-zA-Z0-9_]*)$ """, re.VERBOSE, ) def _lookup(self, expression: str, temp_locals: dict[str, Any]) -> object: """ Do lookup of `object_var` in the context. `temp_locals` is a dictionary, used for the locals. """ try: return eval(expression.strip(), self.get_globals(), temp_locals) except BaseException: return None # Many exception, like NameError can be thrown here. def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: # First, find all for-loops, and assign the first item of the # collections they're iterating to the iterator variable, so that we # can provide code completion on the iterators. temp_locals = self.get_locals().copy() for match in self.for_loop_pattern.finditer(document.text_before_cursor): varname, expression = match.groups() expression_val = self._lookup(expression, temp_locals) # We do this only for lists and tuples. Calling `next()` on any # collection would create undesired side effects. if isinstance(expression_val, (list, tuple)) and expression_val: temp_locals[varname] = expression_val[0] # Get all completions. yield from self._get_expression_completions( document, complete_event, temp_locals ) yield from self._get_item_lookup_completions( document, complete_event, temp_locals ) yield from self._get_attribute_completions( document, complete_event, temp_locals ) def _do_repr(self, obj: object) -> str: try: return str(repr(obj)) except BaseException: raise ReprFailedError def eval_expression(self, document: Document, locals: dict[str, Any]) -> object: """ Evaluate """ match = self.expression_pattern.search(document.text_before_cursor) if match is not None: object_var = match.groups()[0] return self._lookup(object_var, locals) return None def _get_expression_completions( self, document: Document, complete_event: CompleteEvent, temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete the [ or . operator after an object. """ result = self.eval_expression(document, temp_locals) if result is not None: if isinstance( result, (list, tuple, dict, collections_abc.Mapping, collections_abc.Sequence), ): yield Completion("[", 0) else: # Note: Don't call `if result` here. That can fail for types # that have custom truthness checks. yield Completion(".", 0) def _get_item_lookup_completions( self, document: Document, complete_event: CompleteEvent, temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete dictionary keys. """ def meta_repr(value: object) -> Callable[[], str]: "Abbreviate meta text, make sure it fits on one line." # We return a function, so that it gets computed when it's needed. # When there are many completions, that improves the performance # quite a bit (for the multi-column completion menu, we only need # to display one meta text). def get_value_repr() -> str: text = self._do_repr(value) # Take first line, if multiple lines. if "\n" in text: text = text.split("\n", 1)[0] + "..." return text return get_value_repr match = self.item_lookup_pattern.search(document.text_before_cursor) if match is not None: object_var, key = match.groups() # Do lookup of `object_var` in the context. result = self._lookup(object_var, temp_locals) # If this object is a dictionary, complete the keys. if isinstance(result, (dict, collections_abc.Mapping)): # Try to evaluate the key. key_obj = key for k in [key, key + '"', key + "'"]: try: key_obj = ast.literal_eval(k) except (SyntaxError, ValueError): continue else: break for k, v in result.items(): if str(k).startswith(str(key_obj)): try: k_repr = self._do_repr(k) yield Completion( k_repr + "]", -len(key), display=f"[{k_repr}]", display_meta=meta_repr(v), ) except ReprFailedError: pass # Complete list/tuple index keys. elif isinstance(result, (list, tuple, collections_abc.Sequence)): if not key or key.isdigit(): for k in range(min(len(result), 1000)): if str(k).startswith(key): try: k_repr = self._do_repr(k) yield Completion( k_repr + "]", -len(key), display=f"[{k_repr}]", display_meta=meta_repr(result[k]), ) except KeyError: # `result[k]` lookup failed. Trying to complete # broken object. pass except ReprFailedError: pass def _get_attribute_completions( self, document: Document, complete_event: CompleteEvent, temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete attribute names. """ match = self.attribute_lookup_pattern.search(document.text_before_cursor) if match is not None: object_var, attr_name = match.groups() # Do lookup of `object_var` in the context. result = self._lookup(object_var, temp_locals) names = self._sort_attribute_names(dir(result)) def get_suffix(name: str) -> str: try: obj = getattr(result, name, None) if inspect.isfunction(obj) or inspect.ismethod(obj): return "()" if isinstance(obj, collections_abc.Mapping): return "{}" if isinstance(obj, collections_abc.Sequence): return "[]" except: pass return "" for name in names: if name.startswith(attr_name): suffix = get_suffix(name) yield Completion(name, -len(attr_name), display=name + suffix) def _sort_attribute_names(self, names: list[str]) -> list[str]: """ Sort attribute names alphabetically, but move the double underscore and underscore names to the end. """ def sort_key(name: str) -> tuple[int, str]: if name.startswith("__"): return (2, name) # Double underscore comes latest. if name.startswith("_"): return (1, name) # Single underscore before that. return (0, name) # Other names first. return sorted(names, key=sort_key) class HidePrivateCompleter(Completer): """ Wrapper around completer that hides private fields, depending on whether or not public fields are shown. (The reason this is implemented as a `Completer` wrapper is because this way it works also with `FuzzyCompleter`.) """ def __init__( self, completer: Completer, complete_private_attributes: Callable[[], CompletePrivateAttributes], ) -> None: self.completer = completer self.complete_private_attributes = complete_private_attributes def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: completions = list(self.completer.get_completions(document, complete_event)) complete_private_attributes = self.complete_private_attributes() hide_private = False def is_private(completion: Completion) -> bool: text = fragment_list_to_text(to_formatted_text(completion.display)) return text.startswith("_") if complete_private_attributes == CompletePrivateAttributes.NEVER: hide_private = True elif complete_private_attributes == CompletePrivateAttributes.IF_NO_PUBLIC: hide_private = any(not is_private(completion) for completion in completions) if hide_private: completions = [ completion for completion in completions if not is_private(completion) ] return completions class ReprFailedError(Exception): "Raised when the repr() call in `DictionaryCompleter` fails." try: import builtins _builtin_names = dir(builtins) except ImportError: # Python 2. _builtin_names = [] def _get_style_for_jedi_completion( jedi_completion: jedi.api.classes.Completion, ) -> str: """ Return completion style to use for this name. """ name = jedi_completion.name_with_symbols if jedi_completion.type == "param": return "class:completion.param" if name in _builtin_names: return "class:completion.builtin" if keyword.iskeyword(name): return "class:completion.keyword" return ""