diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 16:33:15 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 16:33:15 +0000 |
commit | e5f7a41d988de298069eda636e823f374b973bd4 (patch) | |
tree | a6a97f4d2ed5e59ab11cbae6b403ddebc5effe00 /ptpython/signatures.py | |
parent | Initial commit. (diff) | |
download | ptpython-e5f7a41d988de298069eda636e823f374b973bd4.tar.xz ptpython-e5f7a41d988de298069eda636e823f374b973bd4.zip |
Adding upstream version 3.0.26.upstream/3.0.26
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ptpython/signatures.py')
-rw-r--r-- | ptpython/signatures.py | 270 |
1 files changed, 270 insertions, 0 deletions
diff --git a/ptpython/signatures.py b/ptpython/signatures.py new file mode 100644 index 0000000..d4cb98c --- /dev/null +++ b/ptpython/signatures.py @@ -0,0 +1,270 @@ +""" +Helpers for retrieving the function signature of the function call that we are +editing. + +Either with the Jedi library, or using `inspect.signature` if Jedi fails and we +can use `eval()` to evaluate the function object. +""" +from __future__ import annotations + +import inspect +from inspect import Signature as InspectSignature +from inspect import _ParameterKind as ParameterKind +from typing import TYPE_CHECKING, Any, Sequence + +from prompt_toolkit.document import Document + +from .completer import DictionaryCompleter +from .utils import get_jedi_script_from_document + +if TYPE_CHECKING: + import jedi.api.classes + +__all__ = ["Signature", "get_signatures_using_jedi", "get_signatures_using_eval"] + + +class Parameter: + def __init__( + self, + name: str, + annotation: str | None, + default: str | None, + kind: ParameterKind, + ) -> None: + self.name = name + self.kind = kind + + self.annotation = annotation + self.default = default + + def __repr__(self) -> str: + return f"Parameter(name={self.name!r})" + + @property + def description(self) -> str: + """ + Name + annotation. + """ + description = self.name + + if self.annotation is not None: + description += f": {self.annotation}" + + return description + + +class Signature: + """ + Signature definition used wrap around both Jedi signatures and + python-inspect signatures. + + :param index: Parameter index of the current cursor position. + :param bracket_start: (line, column) tuple for the open bracket that starts + the function call. + """ + + def __init__( + self, + name: str, + docstring: str, + parameters: Sequence[Parameter], + index: int | None = None, + returns: str = "", + bracket_start: tuple[int, int] = (0, 0), + ) -> None: + self.name = name + self.docstring = docstring + self.parameters = parameters + self.index = index + self.returns = returns + self.bracket_start = bracket_start + + @classmethod + def from_inspect_signature( + cls, + name: str, + docstring: str, + signature: InspectSignature, + index: int, + ) -> Signature: + parameters = [] + + def get_annotation_name(annotation: object) -> str: + """ + Get annotation as string from inspect signature. + """ + try: + # In case the annotation is a class like "int", "float", ... + return str(annotation.__name__) # type: ignore + except AttributeError: + pass # No attribute `__name__`, e.g., in case of `List[int]`. + + annotation = str(annotation) + if annotation.startswith("typing."): + annotation = annotation[len("typing:") :] + return annotation + + for p in signature.parameters.values(): + parameters.append( + Parameter( + name=p.name, + annotation=get_annotation_name(p.annotation), + default=repr(p.default) + if p.default is not inspect.Parameter.empty + else None, + kind=p.kind, + ) + ) + + return cls( + name=name, + docstring=docstring, + parameters=parameters, + index=index, + returns="", + ) + + @classmethod + def from_jedi_signature(cls, signature: jedi.api.classes.Signature) -> Signature: + parameters = [] + + for p in signature.params: + if p is None: + # We just hit the "*". + continue + + parameters.append( + Parameter( + name=p.to_string(), # p.name, (`to_string()` already includes the annotation). + annotation=None, # p.infer_annotation() + default=None, # p.infer_default() + kind=p.kind, + ) + ) + + docstring = signature.docstring() + if not isinstance(docstring, str): + docstring = docstring.decode("utf-8") + + return cls( + name=signature.name, + docstring=docstring, + parameters=parameters, + index=signature.index, + returns="", + bracket_start=signature.bracket_start, + ) + + def __repr__(self) -> str: + return f"Signature({self.name!r}, parameters={self.parameters!r})" + + +def get_signatures_using_jedi( + document: Document, locals: dict[str, Any], globals: dict[str, Any] +) -> list[Signature]: + script = get_jedi_script_from_document(document, locals, globals) + + # Show signatures in help text. + if not script: + return [] + + try: + signatures = script.get_signatures() + except ValueError: + # e.g. in case of an invalid \\x escape. + signatures = [] + except Exception: + # Sometimes we still get an exception (TypeError), because + # of probably bugs in jedi. We can silence them. + # See: https://github.com/davidhalter/jedi/issues/492 + signatures = [] + else: + # Try to access the params attribute just once. For Jedi + # signatures containing the keyword-only argument star, + # this will crash when retrieving it the first time with + # AttributeError. Every following time it works. + # See: https://github.com/jonathanslenders/ptpython/issues/47 + # https://github.com/davidhalter/jedi/issues/598 + try: + if signatures: + signatures[0].params + except AttributeError: + pass + + return [Signature.from_jedi_signature(sig) for sig in signatures] + + +def get_signatures_using_eval( + document: Document, locals: dict[str, Any], globals: dict[str, Any] +) -> list[Signature]: + """ + Look for the signature of the function before the cursor position without + use of Jedi. This uses a similar approach as the `DictionaryCompleter` of + running `eval()` over the detected function name. + """ + # Look for open parenthesis, before cursor position. + pos = document.cursor_position - 1 + + paren_mapping = {")": "(", "}": "{", "]": "["} + paren_stack = [ + ")" + ] # Start stack with closing ')'. We are going to look for the matching open ')'. + comma_count = 0 # Number of comma's between start of function call and cursor pos. + found_start = False # Found something. + + while pos >= 0: + char = document.text[pos] + if char in ")]}": + paren_stack.append(char) + elif char in "([{": + if not paren_stack: + # Open paren, while no closing paren was found. Mouse cursor is + # positioned in nested parentheses. Not at the "top-level" of a + # function call. + break + if paren_mapping[paren_stack[-1]] != char: + # Unmatching parentheses: syntax error? + break + + paren_stack.pop() + + if len(paren_stack) == 0: + found_start = True + break + + elif char == "," and len(paren_stack) == 1: + comma_count += 1 + + pos -= 1 + + if not found_start: + return [] + + # We found the start of the function call. Now look for the object before + # this position on which we can do an 'eval' to retrieve the function + # object. + obj = DictionaryCompleter(lambda: globals, lambda: locals).eval_expression( + Document(document.text, cursor_position=pos), locals + ) + if obj is None: + return [] + + try: + name = obj.__name__ # type:ignore + except Exception: + name = obj.__class__.__name__ + + try: + signature = inspect.signature(obj) # type: ignore + except TypeError: + return [] # Not a callable object. + except ValueError: + return [] # No signature found, like for build-ins like "print". + + try: + doc = obj.__doc__ or "" + except: + doc = "" + + # TODO: `index` is not yet correct when dealing with keyword-only arguments. + return [Signature.from_inspect_signature(name, doc, signature, index=comma_count)] |