diff --git a/elpy-rpc.el b/elpy-rpc.el index 0d46aabb5..ef23b759b 100644 --- a/elpy-rpc.el +++ b/elpy-rpc.el @@ -229,8 +229,8 @@ needed packages from `elpy-rpc--get-package-list'." "Return the list of packages to be installed in the RPC virtualenv." (let ((rpc-python-version (elpy-rpc--get-python-version))) (if (version< rpc-python-version "3.6.0") - '("jedi" "flake8" "autopep8" "yapf" "rope") - '("jedi" "flake8" "autopep8" "yapf" "black" "rope")))) + '("jedi" "flake8" "autopep8" "yapf" "rope" "pydantic") + '("jedi" "flake8" "autopep8" "yapf" "black" "rope" "pydantic")))) (defun elpy-rpc--get-python-version () "Return the RPC python version." diff --git a/elpy/api.py b/elpy/api.py new file mode 100644 index 000000000..70993e44d --- /dev/null +++ b/elpy/api.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Any, List, NamedTuple, NoReturn, Optional, Tuple, Union + +from pydantic import BaseModel + + +class Result(BaseModel): + pass + + +class ServerMsg(BaseModel): + pass + + +class NameResult(Result): + name: str + offset: int + filename: str + + +class RefactoringResult(Result): + success: bool + project_path: Path + diff: str + changed_files: List[Path] + error_msg: Optional[str] + + +class ErrorMsg(ServerMsg): + id: int + error: Union[dict, Result] + + +class ResponceMsg(ServerMsg): + id: int + result: Union[dict, List, str, Result] diff --git a/elpy/jedibackend.py b/elpy/jedibackend.py index b7d7a0551..20f683d6f 100644 --- a/elpy/jedibackend.py +++ b/elpy/jedibackend.py @@ -5,34 +5,40 @@ https://github.com/davidhalter/jedi """ +from __future__ import annotations import sys import traceback import re +from typing import List, Optional, Union, Tuple, NamedTuple, Any, NoReturn +from pathlib import Path import jedi + from elpy import rpc from elpy.rpc import Fault +from elpy.api import NameResult, RefactoringResult, Result +from elpy.utils import SourceCode + # in case pkg_resources is not properly installed # (see https://github.com/jorgenschaefer/elpy/issues/1674). try: from pkg_resources import parse_version except ImportError: # pragma: no cover - def parse_version(*arg, **kwargs): + def parse_version(*arg, **kwargs) -> NoReturn: raise Fault("`pkg_resources` could not be imported, " "please reinstall Elpy RPC virtualenv with" " `M-x elpy-rpc-reinstall-virtualenv`", code=400) -JEDISUP17 = parse_version(jedi.__version__) >= parse_version("0.17.0") -class JediBackend(object): +class JediBackend: """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 + Documentation: https://jedi.readthedocs.io/en/latest/docs/api.html """ name = "jedi" @@ -41,24 +47,39 @@ def __init__(self, project_root, environment_binaries_path): self.project_root = project_root self.environment = None if environment_binaries_path is not None: - self.environment = jedi.create_environment(environment_binaries_path, - safe=False) + try: + self.environment = jedi.create_environment( + environment_binaries_path, safe=False) + except jedi.api.environment.InvalidPythonEnvironment as e: + Fault(message=str(e)) self.completions = {} sys.path.append(project_root) - # Backward compatibility to jedi<17 - if not JEDISUP17: # pragma: no cover - self.rpc_get_completions = self.rpc_get_completions_jedi16 - self.rpc_get_docstring = self.rpc_get_docstring_jedi16 - self.rpc_get_definition = self.rpc_get_definition_jedi16 - self.rpc_get_assignment = self.rpc_get_assignment_jedi16 - self.rpc_get_calltip = self.rpc_get_calltip_jedi16 - self.rpc_get_oneline_docstring = self.rpc_get_oneline_docstring_jedi16 - self.rpc_get_usages = self.rpc_get_usages_jedi16 - self.rpc_get_names = self.rpc_get_names_jedi16 + + def _name_result(self, x: jedi.api.classes.Name, offset: int) -> NameResult: + if type(x) is not jedi.api.classes.Name: + raise TypeError('Must be jedi.api.classes.Name') + return NameResult( + name=x.name, filename=str(x.module_path), offset=offset) + + def _refactoring_fail(self, error_msg='') -> RefactoringResult: + return RefactoringResult( + success=False, project_path=Path(), diff="", changed_files=[], + error_msg=error_msg) + + def _refactoring_result(self, x: jedi.api.refactoring.Refactoring + ) -> RefactoringResult: + if type(x) is not jedi.api.refactoring.Refactoring: + raise TypeError("Must be jedi.api.refactoring.Refactoring type") + return RefactoringResult( + success=True, + project_path=x._inference_state.project._path, + diff=x.get_diff(), + changed_files=list(x.get_changed_files().keys())) def rpc_get_completions(self, filename, source, offset): - line, column = pos_to_linecol(source, offset) - proposals = run_with_debug(jedi, 'complete', code=source, + src = SourceCode(filename, source) + line, column = src.get_pos(offset) + proposals = run_with_debug(jedi, 'complete', code=str(src), path=filename, environment=self.environment, fun_kwargs={'line': line, 'column': column}) @@ -70,39 +91,20 @@ def rpc_get_completions(self, filename, source, offset): 'meta': proposal.description} for proposal in proposals] - def rpc_get_completions_jedi16(self, filename, source, offset): - # Backward compatibility to jedi<17 - line, column = pos_to_linecol(source, offset) - proposals = run_with_debug(jedi, 'completions', - source=source, line=line, column=column, - path=filename, encoding='utf-8', - environment=self.environment) - 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): + def rpc_get_completion_docstring(self, completion) -> Optional[str]: proposal = self.completions.get(completion) - if proposal is None: - return None - else: + if proposal is not None: return proposal.docstring(fast=False) - def rpc_get_completion_location(self, completion): + def rpc_get_completion_location(self, completion) -> Optional[Tuple]: proposal = self.completions.get(completion) - if proposal is None: - return None - else: - return (proposal.module_path, proposal.line) + if proposal is not None: + return (str(proposal.module_path), proposal.line) - def rpc_get_docstring(self, filename, source, offset): - line, column = pos_to_linecol(source, offset) + def rpc_get_docstring(self, filename: str, source: str, offset: int + ) -> Optional[str]: + src = SourceCode(filename, source) + line, column = src.get_pos(offset) locations = run_with_debug(jedi, 'goto', code=source, path=filename, @@ -111,39 +113,19 @@ def rpc_get_docstring(self, filename, source, offset): 'column': column, 'follow_imports': True, 'follow_builtin_imports': True}) - if not locations: - return None - # Filter uninteresting things - if locations[-1].name in ["str", "int", "float", "bool", "tuple", - "list", "dict"]: - return None - if locations[-1].docstring(): - return ('Documentation for {0}:\n\n'.format( - locations[-1].full_name) + locations[-1].docstring()) - else: - return None - - def rpc_get_docstring_jedi16(self, filename, source, offset): - # Backward compatibility to jedi<17 - 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', - environment=self.environment) - if not locations: - return None # Filter uninteresting things - if locations[-1].name in ["str", "int", "float", "bool", "tuple", - "list", "dict"]: - return None - if locations[-1].docstring(): - return ('Documentation for {0}:\n\n'.format( - locations[-1].full_name) + locations[-1].docstring()) - else: + uninternsting_to_be_filtered_names = { + "str", "int", "float", "bool", "tuple", "list", "dict"} + if not locations \ + or locations[-1].name in uninternsting_to_be_filtered_names \ + or not locations[-1].docstring(): return None + return ('Documentation for {0}:\n\n'.format( + locations[-1].full_name) + locations[-1].docstring()) def rpc_get_definition(self, filename, source, offset): - line, column = pos_to_linecol(source, offset) + src = SourceCode(filename, source) + line, column = src.get_pos(offset) locations = run_with_debug(jedi, 'goto', code=source, path=filename, @@ -152,6 +134,7 @@ def rpc_get_definition(self, filename, source, offset): 'column': column, 'follow_imports': True, 'follow_builtin_imports': True}) + if not locations: return None # goto_definitions() can return silly stuff like __builtin__ @@ -165,96 +148,21 @@ def rpc_get_definition(self, filename, source, offset): return None loc = locations[-1] try: - if loc.module_path == filename: - offset = linecol_to_pos(source, - loc.line, - loc.column) + if loc.module_path == Path(filename): + offset = src.get_offset(loc.line, loc.column) else: - with open(loc.module_path) as f: - offset = linecol_to_pos(f.read(), - loc.line, - loc.column) + offset = SourceCode( + path=loc.module_path).get_offset(loc.line, loc.column) except IOError: # pragma: no cover return None - return (loc.module_path, offset) - - def rpc_get_definition_jedi16(self, filename, source, offset): # pragma: no cover - # Backward compatibility to jedi<17 - 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', - environment=self.environment) - # 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', - environment=self.environment) - 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) + return (str(loc.module_path), offset) def rpc_get_assignment(self, filename, source, offset): raise Fault("Obsolete since jedi 17.0. Please use 'get_definition'.") - def rpc_get_assignment_jedi16(self, filename, source, offset): # pragma: no cover - # Backward compatibility to jedi<17 - 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', - environment=self.environment) - - 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) + src = SourceCode(filename, source) + line, column = src.get_pos(offset) calls = run_with_debug(jedi, 'get_signatures', code=source, path=filename, @@ -269,32 +177,12 @@ def rpc_get_calltip(self, filename, source, offset): "index": calls[0].index, "params": params} - def rpc_get_calltip_jedi16(self, filename, source, offset): # pragma: no cover - # Backward compatibility to jedi<17 - 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', - environment=self.environment) - if calls: - call = calls[0] - else: - call = None - if not call: - return None - # Strip 'param' added by jedi at the beginning 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_calltip_or_oneline_docstring(self, filename, source, offset): """ Return the current function calltip or its oneline docstring. Meant to be used with eldoc. + """ # Try to get a oneline docstring then docs = self.rpc_get_oneline_docstring(filename=filename, @@ -319,9 +207,10 @@ def rpc_get_calltip_or_oneline_docstring(self, filename, source, offset): 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) + src = SourceCode(filename, source) + line, column = src.get_pos(offset) definitions = run_with_debug(jedi, 'goto', - code=source, + code=str(src), path=filename, environment=self.environment, fun_kwargs={'line': line, @@ -377,140 +266,36 @@ def rpc_get_oneline_docstring(self, filename, source, offset): return {"name": name, "doc": onelinedoc} - def rpc_get_oneline_docstring_jedi16(self, filename, source, offset): # pragma: no cover - """Return a oneline docstring for the symbol at offset""" - # Backward compatibility to jedi<17 - 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', - environment=self.environment) - # avoid unintersting stuff - try: - if definitions[0].name in ["str", "int", "float", "bool", "tuple", - "list", "dict"]: - return None - except: - pass - assignments = run_with_debug(jedi, 'goto_assignments', - source=source, line=line, column=column, - path=filename, encoding='utf-8', - environment=self.environment) - - 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): + def rpc_get_usages(self, filename, source, offset) -> List[NameResult]: """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, 'get_references', - code=source, + src = SourceCode(filename, source) + line, column = src.get_pos(offset) + names = run_with_debug(jedi, 'get_references', + code=str(src), path=filename, environment=self.environment, fun_kwargs={'line': line, 'column': column}) - 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_usages_jedi16(self, filename, source, offset): # pragma: no cover - """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. - - """ - # Backward compatibility to jedi<17 - line, column = pos_to_linecol(source, offset) - uses = run_with_debug(jedi, 'usages', - source=source, line=line, column=column, - path=filename, encoding='utf-8', - environment=self.environment) - 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}) - + for name in names: + if name.module_path == Path(filename): + offset = src.get_offset(name.line, name.column) + elif name.module_path is not None: + other_src = SourceCode(name.module_path) + offset = other_src.get_offset(name.line, name.column) + result.append(self._name_result(name, offset)) return result - def rpc_get_names(self, filename, source, offset): + def rpc_get_names(self, filename, source, offset) -> List[Result]: """Return the list of possible names""" + src = SourceCode(filename, source) names = run_with_debug(jedi, 'get_names', - code=source, + code=str(src), path=filename, environment=self.environment, fun_kwargs={'all_scopes': True, @@ -518,64 +303,33 @@ def rpc_get_names(self, filename, source, offset): 'references': True}) result = [] for name in names: - if name.module_path == filename: - offset = linecol_to_pos(source, name.line, name.column) + if name.module_path == src.path: + offset = src.get_offset(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 - - def rpc_get_names_jedi16(self, filename, source, offset): # pragma: no cover - """Return the list of possible names""" - # Backward compatibility to jedi<17 - 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}) + other_src = SourceCode(name.module_path) + offset = other_src.get_offset(name.line, name.column) + result.append(self._name_result(name, offset)) return result def rpc_get_rename_diff(self, filename, source, offset, new_name): """Get the diff resulting from renaming the thing at point""" - if not hasattr(jedi.Script, "rename"): # pragma: no cover - return {'success': "Not available"} - line, column = pos_to_linecol(source, offset) - ren = run_with_debug(jedi, 'rename', code=source, - path=filename, - environment=self.environment, - fun_kwargs={'line': line, - 'column': column, - 'new_name': new_name}) - if ren is None: - return {'success': False} - else: - return {'success': True, - 'project_path': ren._inference_state.project._path, - 'diff': ren.get_diff(), - 'changed_files': list(ren.get_changed_files().keys())} + src = SourceCode(filename, source) + line, column = src.get_pos(offset) + script = jedi.Script(code=str(src), path=filename, + environment=self.environment) + try: + ref = script.rename(line=line, + column=column, + new_name=new_name) + except Exception as e: + return self._refactoring_fail(error_msg=str(e)) + return self._refactoring_result(ref) - def rpc_get_extract_variable_diff(self, filename, source, offset, new_name, - line_beg, line_end, col_beg, col_end): + def rpc_get_extract_variable_diff( + self, filename, source, offset, new_name, + line_beg, line_end, col_beg, col_end) -> Result: """Get the diff resulting from extracting the selected code""" - if not hasattr(jedi.Script, "extract_variable"): # pragma: no cover - return {'success': "Not available"} - ren = run_with_debug(jedi, 'extract_variable', code=source, + ref = run_with_debug(jedi, 'extract_variable', code=source, path=filename, environment=self.environment, fun_kwargs={'line': line_beg, @@ -583,95 +337,40 @@ def rpc_get_extract_variable_diff(self, filename, source, offset, new_name, 'column': col_beg, 'until_column': col_end, 'new_name': new_name}) - if ren is None: - return {'success': False} + if ref is None: + return self._refactoring_fail() else: - return {'success': True, - 'project_path': ren._inference_state.project._path, - 'diff': ren.get_diff(), - 'changed_files': list(ren.get_changed_files().keys())} + return self._refactoring_result(ref) - def rpc_get_extract_function_diff(self, filename, source, offset, new_name, - line_beg, line_end, col_beg, col_end): + def rpc_get_extract_function_diff( + self, filename, source, offset, new_name, + line_beg, line_end, col_beg, col_end) -> Result: """Get the diff resulting from extracting the selected code""" - if not hasattr(jedi.Script, "extract_function"): # pragma: no cover - return {'success': "Not available"} - ren = run_with_debug(jedi, 'extract_function', code=source, - path=filename, - environment=self.environment, - fun_kwargs={'line': line_beg, - 'until_line': line_end, - 'column': col_beg, - 'until_column': col_end, - 'new_name': new_name}) - if ren is None: - return {'success': False} - else: - return {'success': True, - 'project_path': ren._inference_state.project._path, - 'diff': ren.get_diff(), - 'changed_files': list(ren.get_changed_files().keys())} + script = jedi.Script(code=source, path=filename, + environment=self.environment) + try: + ref = script.extract_function(line=line_beg, + until_line=line_end, + column=col_beg, + until_column=col_end, + new_name=new_name) + except Exception as e: + return self._refactoring_fail(error_msg=str(e)) + return self._refactoring_result(ref) - def rpc_get_inline_diff(self, filename, source, offset): + def rpc_get_inline_diff(self, filename, source, offset) -> Result: """Get the diff resulting from inlining the selected variable""" - if not hasattr(jedi.Script, "inline"): # pragma: no cover - return {'success': "Not available"} - line, column = pos_to_linecol(source, offset) - ren = run_with_debug(jedi, 'inline', code=source, + src = SourceCode(filename, source) + line, column = src.get_pos(offset) + ref = run_with_debug(jedi, 'inline', code=str(src), path=filename, environment=self.environment, fun_kwargs={'line': line, 'column': column}) - if ren is None: - return {'success': False} + if ref is None: + return self._refactoring_fail() else: - return {'success': True, - 'project_path': ren._inference_state.project._path, - 'diff': ren.get_diff(), - 'changed_files': list(ren.get_changed_files().keys())} - - -# 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 + return self._refactoring_result(ref) def run_with_debug(jedi, name, fun_kwargs={}, *args, **kwargs): @@ -682,9 +381,8 @@ def run_with_debug(jedi, name, fun_kwargs={}, *args, **kwargs): except Exception as e: if isinstance(e, re_raise): raise - if JEDISUP17: - if isinstance(e, jedi.RefactoringError): - return None + if isinstance(e, jedi.RefactoringError): + return None # Bug jedi#485 if ( isinstance(e, ValueError) and diff --git a/elpy/rpc.py b/elpy/rpc.py index 07b2ce97e..7c508ca20 100644 --- a/elpy/rpc.py +++ b/elpy/rpc.py @@ -10,7 +10,11 @@ import json import sys import traceback +from typing import Dict, List, Optional, Union +from elpy.api import ServerMsg +from elpy.api import ErrorMsg, ResponceMsg +from elpy.api import Result class JSONRPCServer(object): """Simple JSON-RPC-like server. @@ -36,7 +40,7 @@ class JSONRPCServer(object): {"id": 23, "error": "Simple error message"} - See http://www.jsonrpc.org/ for the inspiration of the protocol. + See https://www.jsonrpc.org/ for the inspiration of the protocol. """ @@ -56,7 +60,7 @@ def __init__(self, stdin=None, stdout=None): else: self.stdout = stdout - def read_json(self): + def read_json(self) -> Dict: """Read a single line and decode it as JSON. Can raise an EOFError() when the input source was closed. @@ -67,17 +71,13 @@ def read_json(self): raise EOFError() return json.loads(line) - def write_json(self, **kwargs): + def send_msg(self, msg: ServerMsg) -> None: """Write an JSON object on a single line. - - The keyword arguments are interpreted as a single JSON object. - It's not possible with this method to write non-objects. - """ - self.stdout.write(json.dumps(kwargs) + "\n") + self.stdout.write(msg.json() + '\n') self.stdout.flush() - - def handle_request(self): + + def handle_request(self) -> None: """Handle a single JSON-RPC request. Read a request, call the appropriate handler method, and @@ -94,35 +94,42 @@ def handle_request(self): method_name = request['method'] request_id = request.get('id', None) params = request.get('params') or [] + msg = None try: method = getattr(self, "rpc_" + method_name, None) if method is not None: result = method(*params) else: result = self.handle(method_name, params) - if request_id is not None: - self.write_json(result=result, - id=request_id) + if request_id is not None and result is not None: + msg = ResponceMsg(result=result, id=request_id) except Fault as fault: - error = {"message": fault.message, - "code": fault.code} - if fault.data is not None: - error["data"] = fault.data - self.write_json(error=error, id=request_id) + msg = self._make_error_msg(id=request_id, msg=fault.message, + code=fault.code, data=fault.data) except Exception as e: - error = {"message": str(e), - "code": 500, - "data": {"traceback": traceback.format_exc()}} - self.write_json(error=error, id=request_id) - - def handle(self, method_name, args): + msg = self._make_error_msg( + id=request_id, msg=str(e), code=500, + data={"traceback": traceback.format_exc()}) + if msg is not None: + self.send_msg(msg) + + def _make_error_msg(self, id: int, msg: str, code: int, data=Optional[dict] + ) -> ErrorMsg: + error = {"message": msg, + "code": code, + "data": '' if data is None else data, + } + return ErrorMsg(error=error, id=id) + + def handle(self, method_name: str, args: List + ) -> Optional[Result]: """Handle the call to method_name. You should overwrite this method in a subclass. """ raise Fault("Unknown method {0}".format(method_name)) - def serve_forever(self): + def serve_forever(self) -> None: """Serve requests forever. Errors are not caught, so this is a slight misnomer. diff --git a/elpy/tests/support.py b/elpy/tests/support.py index 1e79654f3..8ced65022 100644 --- a/elpy/tests/support.py +++ b/elpy/tests/support.py @@ -18,10 +18,12 @@ import tempfile import unittest import re +from pathlib import Path from elpy.tests import compat from elpy.rpc import Fault from elpy import jedibackend +from elpy.jedibackend import NameResult class BackendTestCase(unittest.TestCase): @@ -44,18 +46,14 @@ def project_file(self, relname, contents): Write contents into that file. """ - full_name = os.path.join(self.project_root, relname) + path = Path(self.project_root, relname) try: - os.makedirs(os.path.dirname(full_name)) + os.makedirs(os.path.dirname(path)) except OSError: pass - if compat.PYTHON3: - fobj = open(full_name, "w", encoding="utf-8") - else: - fobj = open(full_name, "w") - with fobj as f: + with open(path, "w") as f: f.write(contents) - return full_name + return str(path) class GenericRPCTests(object): @@ -451,13 +449,6 @@ def test_should_return_nothing_when_no_completion(self): self.assertEqual([], self.backend.rpc_get_completions("test.py", source, offset)) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_completions - self.backend.rpc_get_completions = self.backend.rpc_get_completions_jedi16 - self.test_should_complete_builtin() - self.backend.rpc_get_completions = backup - - class RPCGetCompletionDocstringTests(object): def test_should_return_docstring(self): source, offset = source_and_offset("import json\n" @@ -488,12 +479,6 @@ def test_should_return_none_if_on_a_builtin(self): offset) self.assertIsNone(completions) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_docstring - self.backend.rpc_get_docstring = self.backend.rpc_get_docstring_jedi16 - self.test_should_return_docstring() - self.backend.rpc_get_docstring = backup - class RPCGetCompletionLocationTests(object): def test_should_return_location(self): @@ -624,40 +609,13 @@ def test_should_find_variable_definition(self): offset), (filename, 0)) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_definition - self.backend.rpc_get_definition = self.backend.rpc_get_definition_jedi16 - self.test_should_return_definition_location_same_file() - self.backend.rpc_get_definition = backup - class RPCGetAssignmentTests(): METHOD = "rpc_get_assignment" - def test_should_raise_fault(self): - if jedibackend.JEDISUP17: with self.assertRaises(Fault): self.backend.rpc_get_assignment("test.py", "dummy code", 1) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_assignment - self.backend.rpc_get_assignment = self.backend.rpc_get_assignment_jedi16 - source, offset = source_and_offset("import threading\n" - "def test_function(a, b):\n" - " return a + b\n" - "\n" - "test_func_|_tion(\n") - filename = self.project_file("test.py", source) - - location = self.backend.rpc_get_assignment(filename, - source, - offset) - - self.assertEqual(location[0], filename) - # On def or on the function name - self.assertIn(location[1], (17, 21)) - self.backend.rpc_get_assignment = backup - class RPCGetCalltipTests(GenericRPCTests): METHOD = "rpc_get_calltip" @@ -804,13 +762,6 @@ def test_should_return_oneline_docstring_if_no_calltip(self): self.assertEqual(calltip['kind'], 'oneline_doc') self.assertEqual(calltip['doc'], 'No documentation') - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_calltip - self.backend.rpc_get_calltip = self.backend.rpc_get_calltip_jedi16 - self.test_should_get_calltip() - self.backend.rpc_get_calltip = backup - - class RPCGetDocstringTests(GenericRPCTests): METHOD = "rpc_get_docstring" @@ -840,12 +791,6 @@ def test_should_return_none_for_bad_identifier(self): offset) self.assertIsNone(docstring) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_docstring - self.backend.rpc_get_docstring = self.backend.rpc_get_docstring_jedi16 - self.test_should_get_docstring() - self.backend.rpc_get_docstring = backup - class RPCGetOnelineDocstringTests(GenericRPCTests): METHOD = "rpc_get_oneline_docstring" @@ -901,17 +846,6 @@ def test_should_return_none_for_bad_identifier(self): offset) self.assertIsNone(docstring) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_oneline_docstring - self.backend.rpc_get_oneline_docstring = self.backend.rpc_get_oneline_docstring_jedi16 - self.test_should_get_oneline_docstring() - self.test_should_get_oneline_docstring_for_modules() - self.test_should_return_none_for_bad_identifier() - self.backend.rpc_get_oneline_docstring = backup - - -@unittest.skipIf(not jedibackend.JEDISUP17, - "Refactoring not available with jedi<17") @unittest.skipIf(sys.version_info < (3, 6), "Jedi refactoring not available for python < 3.6") class RPCGetRenameDiffTests(object): @@ -924,12 +858,12 @@ def test_should_return_rename_diff(self): new_name = "c" diff = self.backend.rpc_get_rename_diff("test.py", source, offset, new_name) - assert diff['success'] + assert diff.success self.assertIn("-def foo(a, b):\n" "- print(a)\n" "+def foo(c, b):\n" "+ print(c)", - diff['diff']) + diff.diff) def test_should_fail_for_invalid_symbol_at_point(self): source, offset = source_and_offset("def foo(a, b):\n" @@ -938,11 +872,9 @@ def test_should_fail_for_invalid_symbol_at_point(self): new_name = "c" diff = self.backend.rpc_get_rename_diff("test.py", source, offset, new_name) - self.assertFalse(diff['success']) + self.assertFalse(diff.success) -@unittest.skipIf(not jedibackend.JEDISUP17, - "Refactoring not available with jedi<17") @unittest.skipIf(sys.version_info < (3, 6), "Jedi refactoring not available for python < 3.6") class RPCGetExtractFunctionDiffTests(object): @@ -957,17 +889,15 @@ def test_should_return_function_extraction_diff(self): new_name, line_beg=1, line_end=2, col_beg=0, col_end=8) - assert diff['success'] + assert diff.success self.assertIn('-print(a)\n' '-return b\n' '+def foo(a, b):\n' '+ print(a)\n' '+ return b\n', - diff['diff']) + diff.diff) -@unittest.skipIf(not jedibackend.JEDISUP17, - "Refactoring not available with jedi<17") @unittest.skipIf(sys.version_info < (3, 6), "Jedi refactoring not available for python < 3.6") class RPCGetExtractVariableDiffTests(object): @@ -983,13 +913,10 @@ def test_should_return_variable_extraction_diff(self): new_name, line_beg=3, line_end=3, col_beg=7, col_end=16) - assert diff['success'] self.assertIn("-print(a + 1 + b/2)\n+c = a + 1 + b/2\n+print(c)\n", - diff['diff']) + diff.diff) -@unittest.skipIf(not jedibackend.JEDISUP17, - "Refactoring not available with jedi<17") @unittest.skipIf(sys.version_info < (3, 6), "Jedi refactoring not available for python < 3.6") class RPCGetInlineDiffTests(object): @@ -1001,9 +928,9 @@ def test_should_return_inline_diff(self): "x = int(ba_|_r)\n") diff = self.backend.rpc_get_inline_diff("test.py", source, offset) - assert diff['success'] + assert diff.success self.assertIn("-bar = foo + 1\n-x = int(bar)\n+x = int(foo + 1)", - diff['diff']) + diff.diff) def test_should_error_on_refactoring_failure(self): source, offset = source_and_offset("foo = 3.1\n" @@ -1011,7 +938,7 @@ def test_should_error_on_refactoring_failure(self): "x = in_|_t(bar)\n") diff = self.backend.rpc_get_inline_diff("test.py", source, offset) - self.assertFalse(diff['success']) + self.assertFalse(diff.success) class RPCGetNamesTests(GenericRPCTests): @@ -1060,13 +987,6 @@ def test_should_not_fail_without_symbol(self): self.assertEqual(names, []) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_names - self.backend.rpc_get_names = self.backend.rpc_get_names_jedi16 - self.test_shouldreturn_names_in_same_file() - self.test_should_not_fail_without_symbol() - self.backend.rpc_get_names = backup - class RPCGetUsagesTests(GenericRPCTests): METHOD = "rpc_get_usages" @@ -1082,15 +1002,12 @@ def test_should_return_uses_in_same_file(self): offset) self.assertEqual(usages, - [{'name': 'x', - 'offset': 8, - 'filename': filename}, - {'name': 'x', - 'filename': filename, - 'offset': 23}, - {'name': u'x', - 'filename': filename, - 'offset': 27}]) + [NameResult +(name='x', offset=8, filename=filename), + NameResult +(name='x', offset=23, filename=filename), + NameResult +(name='x', offset=27, filename=filename)]) def test_should_return_uses_in_other_file(self): file1 = self.project_file("file1.py", "") @@ -1103,13 +1020,10 @@ def test_should_return_uses_in_other_file(self): source, offset) - self.assertEqual(usages, - [{'name': 'x', - 'filename': file1, - 'offset': 19}, - {'name': 'x', - 'filename': file2, - 'offset': 5}]) + self.assertEqual(usages, [NameResult +(name="x", filename=file1, offset=19), + NameResult +(name="x", filename=file2, offset=5)]) def test_should_not_fail_without_symbol(self): filename = self.project_file("file.py", "") @@ -1120,14 +1034,6 @@ def test_should_not_fail_without_symbol(self): self.assertEqual(usages, []) - def test_should_handle_jedi16(self): - backup = self.backend.rpc_get_usages - self.backend.rpc_get_usages = self.backend.rpc_get_usages_jedi16 - self.test_should_return_uses_in_same_file() - self.test_should_return_uses_in_other_file() - self.test_should_not_fail_without_symbol() - self.backend.rpc_get_usages = backup - def source_and_offset(source): """Return a source and offset from a source description. diff --git a/elpy/tests/test_jedibackend.py b/elpy/tests/test_jedibackend.py index 1e8d2c634..bca6fe974 100644 --- a/elpy/tests/test_jedibackend.py +++ b/elpy/tests/test_jedibackend.py @@ -2,12 +2,14 @@ import sys import unittest +from pathlib import Path import jedi import mock import re from elpy import jedibackend +from elpy.utils import SourceCode from elpy import rpc from elpy.tests import compat from elpy.tests.support import BackendTestCase @@ -25,7 +27,7 @@ from elpy.tests.support import RPCGetExtractVariableDiffTests from elpy.tests.support import RPCGetInlineDiffTests from elpy.tests.support import RPCGetAssignmentTests - +from elpy.tests.support import source_and_offset class JediBackendTestCase(BackendTestCase): def setUp(self): @@ -33,6 +35,50 @@ def setUp(self): env = jedi.get_default_environment().path self.backend = jedibackend.JediBackend(self.project_root, env) +class TestSourceCode(BackendTestCase): + def setUp(self): + super().setUp() + self.src = SourceCode(path="/notreal/path", source="\n1234\n6789") + + def test_should_build_offset_index(self): + self.assertEqual(list(self.src._get_line_offsets()), [0, 1, 6, 10]) + + def test_should_return_offset(self): + self.assertEqual(self.src.get_offset(1, 0), 0) + self.assertEqual(self.src.get_offset(2, 0), 1) + + def test_should_return_pos(self): + self.assertEqual(self.src.get_pos(0), (1, 0)) + self.assertEqual(self.src.get_pos(1), (2, 0)) + self.assertEqual(self.src.get_pos(5), (2, 4)) + self.assertEqual(self.src.get_pos(9), (3, 3)) + + def test_should_return_source_string(self): + self.assertEqual(str(self.src), "\n1234\n6789") + + def test_should_return_source_path(self): + self.assertEqual(self.src.path, Path("/notreal/path")) + + def test_should_load_source_if_not_source_arg(self): + file_name = self.project_file("test.py", "my\nsource\ncode") + src = SourceCode(file_name) + self.assertEqual("my\nsource\ncode", str(src)) + + def test_should_not_load_source_if_source_in_args(self): + file_name = self.project_file("test.py", "my\nsource\nin\file") + not_saved_source_string = "edited\nbut\not\nsaved\source" + src = SourceCode(file_name, not_saved_source_string) + self.assertEqual(not_saved_source_string, str(src)) + + def test_should_fail_on_wrong_pos(self): + with self.assertRaises(ValueError) as cm: + self.src.get_offset(100, 1) + self.assertEqual("Text does not have 100 lines.", str(cm.exception)) + with self.assertRaises(ValueError) as cm: + self.src.get_offset(1, 1) + self.assertEqual( "Line 1 column 1 is not within the text", + str(cm.exception)) + class TestInit(JediBackendTestCase): def test_should_have_jedi_as_name(self): @@ -232,44 +278,6 @@ class TestRPCGetNames(RPCGetNamesTests, pass -class TestPosToLinecol(unittest.TestCase): - def test_should_handle_beginning_of_string(self): - self.assertEqual(jedibackend.pos_to_linecol("foo", 0), - (1, 0)) - - def test_should_handle_end_of_line(self): - self.assertEqual(jedibackend.pos_to_linecol("foo\nbar\nbaz\nqux", 9), - (3, 1)) - - def test_should_handle_end_of_string(self): - self.assertEqual(jedibackend.pos_to_linecol("foo\nbar\nbaz\nqux", 14), - (4, 2)) - - -class TestLinecolToPos(unittest.TestCase): - def test_should_handle_beginning_of_string(self): - self.assertEqual(jedibackend.linecol_to_pos("foo", 1, 0), - 0) - - def test_should_handle_end_of_string(self): - self.assertEqual(jedibackend.linecol_to_pos("foo\nbar\nbaz\nqux", - 3, 1), - 9) - - def test_should_return_offset(self): - self.assertEqual(jedibackend.linecol_to_pos("foo\nbar\nbaz\nqux", - 4, 2), - 14) - - def test_should_fail_for_line_past_text(self): - self.assertRaises(ValueError, - jedibackend.linecol_to_pos, "foo\n", 3, 1) - - def test_should_fail_for_column_past_text(self): - self.assertRaises(ValueError, - jedibackend.linecol_to_pos, "foo\n", 1, 10) - - class TestRunWithDebug(unittest.TestCase): @mock.patch('jedi.Script') def test_should_call_method(self, Script): diff --git a/elpy/tests/test_rpc.py b/elpy/tests/test_rpc.py index 0d35196c1..400d807e8 100644 --- a/elpy/tests/test_rpc.py +++ b/elpy/tests/test_rpc.py @@ -5,6 +5,7 @@ import sys from elpy import rpc +from elpy.api import Result, ErrorMsg, ResponceMsg from elpy.tests.compat import StringIO @@ -77,15 +78,45 @@ def test_should_fail_on_malformed_json(self): class TestWriteJson(TestJSONRPCServer): - def test_should_write_json_line(self): - objlist = [{'foo': 'bar'}, - {'baz': 'qux', 'fnord': 'argl\nbargl'}, - ] - for obj in objlist: - self.rpc.write_json(**obj) - self.assertEqual(json.loads(self.read()), - obj) - + def test_result_msg_shuld_work_with_msg(self): + class DataMsg(Result): + data: str + self.rpc.send_msg( + ResponceMsg(id=100, result=DataMsg(data="some data"))) + self.assertEqual( + self.read(), '{"id": 100, "result": {"data": "some data"}}\n') + + def test_result_msg__shuld_work_with_dict(self): + self.rpc.send_msg( + ResponceMsg(id=100, result={'data': 'some data'})) + self.assertEqual(self.read(), + '{"id": 100, "result": {"data": "some data"}}\n') + + def test_result_msg__shuld_work_with_list(self): + self.rpc.send_msg( + ResponceMsg(id=100, result=['some data1', 'some data2', 3])) + self.assertEqual( + self.read(), + '{"id": 100, "result": ["some data1", "some data2", 3]}\n') + + def test_result_msg__shuld_work_with_str(self): + self.rpc.send_msg(ResponceMsg(id=100, result="some string")) + self.assertEqual(self.read(), '{"id": 100, "result": "some string"}\n') + + def test_error_msg__shuld_work_with_dict(self): + self.rpc.send_msg( + ErrorMsg(id=100, error={'error': 'error msg'})) + self.assertEqual( + self.read(), '{"id": 100, "error": {"error": "error msg"}}\n') + + def test_error_msg__shuld_work_with_msg(self): + class MyErrorMsg(Result): + error: str + self.rpc.send_msg( + ErrorMsg(id=100, error=MyErrorMsg(error="error msg"))) + self.assertEqual( + self.read(), + '{"id": 100, "error": {"error": "error msg"}}\n') class TestHandleRequest(TestJSONRPCServer): def test_should_fail_if_json_does_not_contain_a_method(self): diff --git a/elpy/utils.py b/elpy/utils.py new file mode 100644 index 000000000..6b8b8a168 --- /dev/null +++ b/elpy/utils.py @@ -0,0 +1,89 @@ +from typing import Optional, Union, NamedTuple +from pathlib import Path +from array import array +from bisect import bisect_right +from io import StringIO + + +# 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. +class Pos(NamedTuple): + line: int + col: int + + +class SourceCode: + _source: Optional[str] + _path: Optional[Path] + _line_offsets: Optional[array] + + def __init__( + self, path: Union[None, str, Path], source: Optional[str] = None, + line_start: int = 1, col_start: int = 0): + if path is not None: + self._path = Path(path) + self._source = source + self._line_offsets = None + self._line_start = line_start + self._col_start = col_start + + def get_source(self): + if self._source is None: + with open(self._path, 'r') as fh: + self._source = fh.read() + return self._source + + def __str__(self): + return self.get_source() + + @property + def path(self) -> Optional[Path]: + return self._path + + def get_pos(self, offset: int) -> 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. + """ + idx = self._get_line_offsets() + line_num_from_zero = bisect_right(idx, offset) - self._line_start + line_offset = idx[line_num_from_zero] + return Pos(line_num_from_zero + self._line_start, + offset - line_offset + self._col_start) + + def get_offset(self, line: int, col: int): + """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. + """ + line_offsets = self._get_line_offsets() + line_from_zero = line - self._line_start + col_from_zero = col - self._col_start + try: + cur_line_offset = line_offsets[line_from_zero] + except IndexError: + raise ValueError(f"Text does not have {line} lines.") + offset = cur_line_offset + col_from_zero + next_line_offset = line_offsets[line_from_zero + 1] + if not offset < next_line_offset: + raise ValueError( + f"Line {line} column {col} is not within the text") + return offset + + def _get_line_offsets(self) -> array: + if self._line_offsets is None: + self._line_offsets = array('I') + self._line_offsets.append(0) + curr_line_offset = 0 + for line in StringIO(self.get_source()): + curr_line_offset += len(line) + self._line_offsets.append(curr_line_offset) + return self._line_offsets diff --git a/requirements.txt b/requirements.txt index d0acd85e8..8a7c0f5b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flake8==3.8.3 +pydantic diff --git a/scripts/test b/scripts/test index 104f24e69..ee5c856e9 100755 --- a/scripts/test +++ b/scripts/test @@ -10,7 +10,7 @@ fi if [ -z "$1" -o "$1" = "python" ] then - python -tt -m unittest discover elpy + python3 -tt -m unittest discover elpy fi if [ "$1" = "coverage" ] diff --git a/setup.py b/setup.py index 47972c114..620a0264a 100755 --- a/setup.py +++ b/setup.py @@ -29,6 +29,6 @@ ], packages=find_packages(), include_package_data=True, - install_requires=["flake8>=2.0"], + install_requires=["flake8>=2.0", "pydantic"], test_suite="elpy" )