271 lines
8.1 KiB
Python
271 lines
8.1 KiB
Python
"""
|
|
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)]
|