1"""
2Helpers for retrieving the function signature of the function call that we are
3editing.
4
5Either with the Jedi library, or using `inspect.signature` if Jedi fails and we
6can use `eval()` to evaluate the function object.
7"""
8import inspect
9from inspect import Signature as InspectSignature
10from inspect import _ParameterKind as ParameterKind
11from typing import Any, Dict, List, Optional, Sequence, Tuple
12
13from prompt_toolkit.document import Document
14
15from .completer import DictionaryCompleter
16from .utils import get_jedi_script_from_document
17
18__all__ = ["Signature", "get_signatures_using_jedi", "get_signatures_using_eval"]
19
20
21class Parameter:
22    def __init__(
23        self,
24        name: str,
25        annotation: Optional[str],
26        default: Optional[str],
27        kind: ParameterKind,
28    ) -> None:
29        self.name = name
30        self.kind = kind
31
32        self.annotation = annotation
33        self.default = default
34
35    def __repr__(self) -> str:
36        return f"Parameter(name={self.name!r})"
37
38    @property
39    def description(self) -> str:
40        """
41        Name + annotation.
42        """
43        description = self.name
44
45        if self.annotation is not None:
46            description += f": {self.annotation}"
47
48        return description
49
50
51class Signature:
52    """
53    Signature definition used wrap around both Jedi signatures and
54    python-inspect signatures.
55
56    :param index: Parameter index of the current cursor position.
57    :param bracket_start: (line, column) tuple for the open bracket that starts
58        the function call.
59    """
60
61    def __init__(
62        self,
63        name: str,
64        docstring: str,
65        parameters: Sequence[Parameter],
66        index: Optional[int] = None,
67        returns: str = "",
68        bracket_start: Tuple[int, int] = (0, 0),
69    ) -> None:
70        self.name = name
71        self.docstring = docstring
72        self.parameters = parameters
73        self.index = index
74        self.returns = returns
75        self.bracket_start = bracket_start
76
77    @classmethod
78    def from_inspect_signature(
79        cls,
80        name: str,
81        docstring: str,
82        signature: InspectSignature,
83        index: int,
84    ) -> "Signature":
85        parameters = []
86
87        def get_annotation_name(annotation: object) -> str:
88            """
89            Get annotation as string from inspect signature.
90            """
91            try:
92                # In case the annotation is a class like "int", "float", ...
93                return str(annotation.__name__)  # type: ignore
94            except AttributeError:
95                pass  # No attribute `__name__`, e.g., in case of `List[int]`.
96
97            annotation = str(annotation)
98            if annotation.startswith("typing."):
99                annotation = annotation[len("typing:") :]
100            return annotation
101
102        for p in signature.parameters.values():
103            parameters.append(
104                Parameter(
105                    name=p.name,
106                    annotation=get_annotation_name(p.annotation),
107                    default=repr(p.default)
108                    if p.default is not inspect.Parameter.empty
109                    else None,
110                    kind=p.kind,
111                )
112            )
113
114        return cls(
115            name=name,
116            docstring=docstring,
117            parameters=parameters,
118            index=index,
119            returns="",
120        )
121
122    @classmethod
123    def from_jedi_signature(cls, signature) -> "Signature":
124        parameters = []
125
126        for p in signature.params:
127            if p is None:
128                # We just hit the "*".
129                continue
130
131            parameters.append(
132                Parameter(
133                    name=p.to_string(),  # p.name, (`to_string()` already includes the annotation).
134                    annotation=None,  # p.infer_annotation()
135                    default=None,  # p.infer_default()
136                    kind=p.kind,
137                )
138            )
139
140        docstring = signature.docstring()
141        if not isinstance(docstring, str):
142            docstring = docstring.decode("utf-8")
143
144        return cls(
145            name=signature.name,
146            docstring=docstring,
147            parameters=parameters,
148            index=signature.index,
149            returns="",
150            bracket_start=signature.bracket_start,
151        )
152
153    def __repr__(self) -> str:
154        return f"Signature({self.name!r}, parameters={self.parameters!r})"
155
156
157def get_signatures_using_jedi(
158    document: Document, locals: Dict[str, Any], globals: Dict[str, Any]
159) -> List[Signature]:
160    script = get_jedi_script_from_document(document, locals, globals)
161
162    # Show signatures in help text.
163    if not script:
164        return []
165
166    try:
167        signatures = script.get_signatures()
168    except ValueError:
169        # e.g. in case of an invalid \\x escape.
170        signatures = []
171    except Exception:
172        # Sometimes we still get an exception (TypeError), because
173        # of probably bugs in jedi. We can silence them.
174        # See: https://github.com/davidhalter/jedi/issues/492
175        signatures = []
176    else:
177        # Try to access the params attribute just once. For Jedi
178        # signatures containing the keyword-only argument star,
179        # this will crash when retrieving it the first time with
180        # AttributeError. Every following time it works.
181        # See: https://github.com/jonathanslenders/ptpython/issues/47
182        #      https://github.com/davidhalter/jedi/issues/598
183        try:
184            if signatures:
185                signatures[0].params
186        except AttributeError:
187            pass
188
189    return [Signature.from_jedi_signature(sig) for sig in signatures]
190
191
192def get_signatures_using_eval(
193    document: Document, locals: Dict[str, Any], globals: Dict[str, Any]
194) -> List[Signature]:
195    """
196    Look for the signature of the function before the cursor position without
197    use of Jedi. This uses a similar approach as the `DictionaryCompleter` of
198    running `eval()` over the detected function name.
199    """
200    # Look for open parenthesis, before cursor position.
201    text = document.text_before_cursor
202    pos = document.cursor_position - 1
203
204    paren_mapping = {")": "(", "}": "{", "]": "["}
205    paren_stack = [
206        ")"
207    ]  # Start stack with closing ')'. We are going to look for the matching open ')'.
208    comma_count = 0  # Number of comma's between start of function call and cursor pos.
209    found_start = False  # Found something.
210
211    while pos >= 0:
212        char = document.text[pos]
213        if char in ")]}":
214            paren_stack.append(char)
215        elif char in "([{":
216            if not paren_stack:
217                # Open paren, while no closing paren was found. Mouse cursor is
218                # positioned in nested parentheses. Not at the "top-level" of a
219                # function call.
220                break
221            if paren_mapping[paren_stack[-1]] != char:
222                # Unmatching parentheses: syntax error?
223                break
224
225            paren_stack.pop()
226
227            if len(paren_stack) == 0:
228                found_start = True
229                break
230
231        elif char == "," and len(paren_stack) == 1:
232            comma_count += 1
233
234        pos -= 1
235
236    if not found_start:
237        return []
238
239    # We found the start of the function call. Now look for the object before
240    # this position on which we can do an 'eval' to retrieve the function
241    # object.
242    obj = DictionaryCompleter(lambda: globals, lambda: locals).eval_expression(
243        Document(document.text, cursor_position=pos), locals
244    )
245    if obj is None:
246        return []
247
248    try:
249        name = obj.__name__  # type:ignore
250    except Exception:
251        name = obj.__class__.__name__
252
253    try:
254        signature = inspect.signature(obj)  # type: ignore
255    except TypeError:
256        return []  # Not a callable object.
257    except ValueError:
258        return []  # No signature found, like for build-ins like "print".
259
260    try:
261        doc = obj.__doc__ or ""
262    except:
263        doc = ""
264
265    # TODO: `index` is not yet correct when dealing with keyword-only arguments.
266    return [Signature.from_inspect_signature(name, doc, signature, index=comma_count)]
267