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