"""Elpy backend using the Jedi library. This backend uses the Jedi library: https://github.com/davidhalter/jedi """ import sys import traceback import re import jedi from elpy import rpc class JediBackend(object): """The Jedi backend class. Implements the RPC calls we can pass on to Jedi. Documentation: http://jedi.jedidjah.ch/en/latest/docs/plugin-api.html """ name = "jedi" def __init__(self, project_root): self.project_root = project_root self.completions = {} sys.path.append(project_root) def rpc_get_completions(self, filename, source, offset): line, column = pos_to_linecol(source, offset) proposals = run_with_debug(jedi, 'completions', source=source, line=line, column=column, path=filename, encoding='utf-8') if proposals is None: return [] self.completions = dict((proposal.name, proposal) for proposal in proposals) return [{'name': proposal.name.rstrip("="), 'suffix': proposal.complete.rstrip("="), 'annotation': proposal.type, 'meta': proposal.description} for proposal in proposals] def rpc_get_completion_docstring(self, completion): proposal = self.completions.get(completion) if proposal is None: return None else: return proposal.docstring(fast=False) def rpc_get_completion_location(self, completion): proposal = self.completions.get(completion) if proposal is None: return None else: return (proposal.module_path, proposal.line) def rpc_get_docstring(self, filename, source, offset): line, column = pos_to_linecol(source, offset) locations = run_with_debug(jedi, 'goto_definitions', source=source, line=line, column=column, path=filename, encoding='utf-8') if locations and locations[-1].docstring(): return ('Documentation for {0}:\n\n'.format( locations[-1].full_name) + locations[-1].docstring()) else: return None def rpc_get_definition(self, filename, source, offset): line, column = pos_to_linecol(source, offset) locations = run_with_debug(jedi, 'goto_definitions', source=source, line=line, column=column, path=filename, encoding='utf-8') # goto_definitions() can return silly stuff like __builtin__ # for int variables, so we fall back on goto() in those # cases. See issue #76. if ( locations and (locations[0].module_path is None or locations[0].module_name == 'builtins' or locations[0].module_name == '__builtin__') ): locations = run_with_debug(jedi, 'goto_assignments', source=source, line=line, column=column, path=filename, encoding='utf-8') if not locations: return None else: loc = locations[-1] try: if loc.module_path: if loc.module_path == filename: offset = linecol_to_pos(source, loc.line, loc.column) else: with open(loc.module_path) as f: offset = linecol_to_pos(f.read(), loc.line, loc.column) else: return None except IOError: return None return (loc.module_path, offset) def rpc_get_assignment(self, filename, source, offset): line, column = pos_to_linecol(source, offset) locations = run_with_debug(jedi, 'goto_assignments', source=source, line=line, column=column, path=filename, encoding='utf-8') if not locations: return None else: loc = locations[-1] try: if loc.module_path: if loc.module_path == filename: offset = linecol_to_pos(source, loc.line, loc.column) else: with open(loc.module_path) as f: offset = linecol_to_pos(f.read(), loc.line, loc.column) else: return None except IOError: return None return (loc.module_path, offset) def rpc_get_calltip(self, filename, source, offset): line, column = pos_to_linecol(source, offset) calls = run_with_debug(jedi, 'call_signatures', source=source, line=line, column=column, path=filename, encoding='utf-8') if calls: call = calls[0] else: call = None if not call: return None # Strip 'param' added by jedi at the beggining of # parameter names. Should be unecessary for jedi > 0.13.0 params = [re.sub("^param ", '', param.description) for param in call.params] return {"name": call.name, "index": call.index, "params": params} def rpc_get_oneline_docstring(self, filename, source, offset): """Return a oneline docstring for the symbol at offset""" line, column = pos_to_linecol(source, offset) definitions = run_with_debug(jedi, 'goto_definitions', source=source, line=line, column=column, path=filename, encoding='utf-8') assignments = run_with_debug(jedi, 'goto_assignments', source=source, line=line, column=column, path=filename, encoding='utf-8') if definitions: definition = definitions[0] else: definition = None if assignments: assignment = assignments[0] else: assignment = None if definition: # Get name if definition.type in ['function', 'class']: raw_name = definition.name name = '{}()'.format(raw_name) doc = definition.docstring().split('\n') elif definition.type in ['module']: raw_name = definition.name name = '{} {}'.format(raw_name, definition.type) doc = definition.docstring().split('\n') elif (definition.type in ['instance'] and hasattr(assignment, "name")): raw_name = assignment.name name = raw_name doc = assignment.docstring().split('\n') else: return None # Keep only the first paragraph that is not a function declaration lines = [] call = "{}(".format(raw_name) # last line doc.append('') for i in range(len(doc)): if doc[i] == '' and len(lines) != 0: paragraph = " ".join(lines) lines = [] if call != paragraph[0:len(call)]: break paragraph = "" continue lines.append(doc[i]) # Keep only the first sentence onelinedoc = paragraph.split('. ', 1) if len(onelinedoc) == 2: onelinedoc = onelinedoc[0] + '.' else: onelinedoc = onelinedoc[0] if onelinedoc == '': onelinedoc = "No documentation" return {"name": name, "doc": onelinedoc} return None def rpc_get_usages(self, filename, source, offset): """Return the uses of the symbol at offset. Returns a list of occurrences of the symbol, as dicts with the fields name, filename, and offset. """ line, column = pos_to_linecol(source, offset) uses = run_with_debug(jedi, 'usages', source=source, line=line, column=column, path=filename, encoding='utf-8') if uses is None: return None result = [] for use in uses: if use.module_path == filename: offset = linecol_to_pos(source, use.line, use.column) elif use.module_path is not None: with open(use.module_path) as f: text = f.read() offset = linecol_to_pos(text, use.line, use.column) result.append({"name": use.name, "filename": use.module_path, "offset": offset}) return result def rpc_get_names(self, filename, source, offset): """Return the list of possible names""" names = jedi.api.names(source=source, path=filename, encoding='utf-8', all_scopes=True, definitions=True, references=True) result = [] for name in names: if name.module_path == filename: offset = linecol_to_pos(source, name.line, name.column) elif name.module_path is not None: with open(name.module_path) as f: text = f.read() offset = linecol_to_pos(text, name.line, name.column) result.append({"name": name.name, "filename": name.module_path, "offset": offset}) return result # From the Jedi documentation: # # line is the current line you want to perform actions on (starting # with line #1 as the first line). column represents the current # column/indent of the cursor (starting with zero). source_path # should be the path of your file in the file system. def pos_to_linecol(text, pos): """Return a tuple of line and column for offset pos in text. Lines are one-based, columns zero-based. This is how Jedi wants it. Don't ask me why. """ line_start = text.rfind("\n", 0, pos) + 1 line = text.count("\n", 0, line_start) + 1 col = pos - line_start return line, col def linecol_to_pos(text, line, col): """Return the offset of this line and column in text. Lines are one-based, columns zero-based. This is how Jedi wants it. Don't ask me why. """ nth_newline_offset = 0 for i in range(line - 1): new_offset = text.find("\n", nth_newline_offset) if new_offset < 0: raise ValueError("Text does not have {0} lines." .format(line)) nth_newline_offset = new_offset + 1 offset = nth_newline_offset + col if offset > len(text): raise ValueError("Line {0} column {1} is not within the text" .format(line, col)) return offset def run_with_debug(jedi, name, *args, **kwargs): re_raise = kwargs.pop('re_raise', ()) try: script = jedi.Script(*args, **kwargs) return getattr(script, name)() except Exception as e: if isinstance(e, re_raise): raise # Bug jedi#485 if ( isinstance(e, ValueError) and "invalid \\x escape" in str(e) ): return None # Bug jedi#485 in Python 3 if ( isinstance(e, SyntaxError) and "truncated \\xXX escape" in str(e) ): return None from jedi import debug debug_info = [] def _debug(level, str_out): if level == debug.NOTICE: prefix = "[N]" elif level == debug.WARNING: prefix = "[W]" else: prefix = "[?]" debug_info.append(u"{0} {1}".format(prefix, str_out)) jedi.set_debug_function(_debug, speed=False) try: script = jedi.Script(*args, **kwargs) return getattr(script, name)() except Exception as e: source = kwargs.get('source') sc_args = [] sc_args.extend(repr(arg) for arg in args) sc_args.extend("{0}={1}".format(k, "source" if k == "source" else repr(v)) for (k, v) in kwargs.items()) data = { "traceback": traceback.format_exc(), "jedi_debug_info": {'script_args': ", ".join(sc_args), 'source': source, 'method': name, 'debug_info': debug_info} } raise rpc.Fault(message=str(e), code=500, data=data) finally: jedi.set_debug_function(None)