8000 Add a plugin to provide autoimport functionality (#199) · hetmankp/python-lsp-server@b24ffd3 · GitHub
[go: up one dir, main page]

Skip to content

Commit b24ffd3

Browse files
authored
Add a plugin to provide autoimport functionality (python-lsp#199)
1 parent 4e90767 commit b24ffd3

File tree

11 files changed

+530
-8
lines changed

11 files changed

+530
-8
lines changed

CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ This server can be configured using the `workspace/didChangeConfiguration` metho
6161
| `pylsp.plugins.pylint.enabled` | `boolean` | Enable or disable the plugin. | `false` |
6262
| `pylsp.plugins.pylint.args` | `array` of non-unique `string` items | Arguments to pass to pylint. | `[]` |
6363
| `pylsp.plugins.pylint.executable` | `string` | Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3. | `null` |
64+
| `pylsp.plugins.rope_autoimport.enabled` | `boolean` | Enable or disable autoimport. | `false` |
65+
| `pylsp.plugins.rope_autoimport.memory` | `boolean` | Make the autoimport database memory only. Drastically increases startup time. | `false` |
6466
| `pylsp.plugins.rope_completion.enabled` | `boolean` | Enable or disable the plugin. | `false` |
6567
| `pylsp.plugins.rope_completion.eager` | `boolean` | Resolve documentation and detail eagerly. | `false` |
6668
| `pylsp.plugins.yapf.enabled` | `boolean` | Enable or disable the plugin. | `true` |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ pip install 'python-lsp-server[websockets]'
140140
## LSP Server Features
141141

142142
* Auto Completion
143+
* [Autoimport](docs/autoimport.md)
143144
* Code Linting
144145
* Signature Help
145146
* Go to definition

docs/autoimport.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Autoimport for pylsp
2+
3+
Requirements:
4+
5< 9E81 code class="diff-text syntax-highlighted-line addition">+
1. install `python-lsp-server[rope]`
6+
2. set `pylsp.plugins.rope_autoimport.enabled` to `true`
7+
8+
## Startup
9+
10+
Autoimport will generate an autoimport sqllite3 database in .ropefolder/autoimport.db on startup.
11+
This will take a few seconds but should be much quicker on future runs.
12+
13+
## Usage
14+
15+
Autoimport will provide suggestions to import names from everything in `sys.path`. You can change this by changing where pylsp is running or by setting rope's 'python_path' option.
16+
It will suggest modules, submodules, keywords, functions, and classes.
17+
18+
Since autoimport inserts everything towards the end of the import group, its recommended you use the isort [plugin](https://github.com/paradoxxxzero/pyls-isort).
19+
20+
## Credits
21+
22+
- Most of the code was written by me, @bageljrkhanofemus
23+
- [lyz-code](https://github.com/lyz-code/autoimport) for inspiration and some ideas
24+
- [rope](https://github.com/python-rope/rope), especially @lieryan
25+
- [pyright](https://github.com/Microsoft/pyright) for details on language server implementation

pylsp/config/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ def __init__(self, root_uri, init_opts, process_id, capabilities):
8181
if plugin is not None:
8282
log.info("Loaded pylsp plugin %s from %s", name, plugin)
8383

84-
# pylint: disable=no-member
8584
for plugin_conf in self._pm.hook.pylsp_settings(config=self):
8685
self._plugin_settings = _utils.merge_dicts(self._plugin_settings, plugin_conf)
8786

pylsp/config/schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,16 @@
362362
"default": null,
363363
"description": "Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3."
364364
},
365+
"pylsp.plugins.rope_autoimport.enabled": {
366+
"type": "boolean",
367+
"default": false,
368+
"description": "Enable or disable autoimport."
369+
},
370+
"pylsp.plugins.rope_autoimport.memory": {
371+
"type": "boolean",
372+
"default": false,
373+
"description": "Make the autoimport database memory only. Drastically increases startup time."
374+
},
365375
"pylsp.plugins.rope_completion.enabled": {
366376
"type": "boolean",
367377
"default": false,

pylsp/plugins/rope_autoimport.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# Copyright 2022- Python Language Server Contributors.
2+
3+
import logging
4+
from typing import Any, Dict, Generator, List, Set
5+
6+
import parso
7+
from jedi import Script
8+
from parso.python import tree
9+
from parso.tree import NodeOrLeaf
10+
from rope.base.resources import Resource
11+
from rope.contrib.autoimport.defs import SearchResult
12+
from rope.contrib.autoimport.sqlite import AutoImport
13+
14+
from pylsp import hookimpl
15+
from pylsp.config.config import Config
16+
from pylsp.workspace import Document, Workspace
17+
18+
log = logging.getLogger(__name__)
19+
20+
_score_pow = 5
21+
_score_max = 10**_score_pow
22+
MAX_RESULTS = 1000
23+
24+
25+
@hookimpl
26+
def pylsp_settings() -> Dict[str, Dict[str, Dict[str, Any]]]:
27+
# Default rope_completion to disabled
28+
return {"plugins": {"rope_autoimport": {"enabled": True, "memory": False}}}
29+
30+
31+
def _should_insert(expr: tree.BaseNode, word_node: tree.Leaf) -> bool:
32+
"""
33+
Check if we should insert the word_node on the given expr.
34+
35+
Works for both correct and incorrect code. This is because the
36+
user is often working on the code as they write it.
37+
"""
38+
if len(expr.children) == 0:
39+
return True
40+
first_child = expr.children[0]
41+
if isinstance(first_child, tree.EndMarker):
42+
if "#" in first_child.prefix:
43+
return False # Check for single line comment
44+
if first_child == word_node:
45+
return True # If the word is the first word then its fine
46+
if len(expr.children) > 1:
47+
if any(node.type == "operator" and "." in node.value or
48+
node.type == "trailer" for node in expr.children):
49+
return False # Check if we're on a method of a function
50+
if isinstance(first_child, (tree.PythonErrorNode, tree.PythonNode)):
51+
# The tree will often include error nodes like this to indicate errors
52+
# we want to ignore errors since the code is being written
53+
return _should_insert(first_child, word_node)
54+
return _handle_first_child(first_child, expr, word_node)
55+
56+
57+
def _handle_first_child(first_child: NodeOrLeaf, expr: tree.BaseNode,
58+
word_node: tree.Leaf) -> bool:
59+
"""Check if we suggest imports given the following first child."""
60+
if isinstance(first_child, tree.Import):
61+
return False
62+
if isinstance(first_child, (tree.PythonLeaf, tree.PythonErrorLeaf)):
63+
# Check if the first item is a from or import statement even when incomplete
64+
if first_child.value in ("import", "from"):
65+
return False
66+
if isinstance(first_child, tree.Keyword):
67+
if first_child.value == "def":
68+
return _should_import_function(word_node, expr)
69+
if first_child.value == "class":
70+
return _should_import_class(word_node, expr)
71+
return True
72+
73+
74+
def _should_import_class(word_node: tree.Leaf, expr: tree.BaseNode) -> bool:
75+
prev_node = None
76+
for node in expr.children:
77+
if isinstance(node, tree.Name):
78+
if isinstance(prev_node, tree.Operator):
79+
if node == word_node and prev_node.value == "(":
80+
return True
81+
prev_node = node
82+
83+
return False
84+
85+
86+
def _should_import_function(word_node: tree.Leaf, expr: tree.BaseNode) -> bool:
87+
prev_node = None
88+
for node in expr.children:
89+
if _handle_argument(node, word_node):
90+
return True
91+
if isinstance(prev_node, tree.Operator):
92+
if prev_node.value == "->":
93+
if node == word_node:
94+
return True
95+
prev_node = node
96+
return False
97+
98+
99+
def _handle_argument(node: NodeOrLeaf, word_node: tree.Leaf):
100+
if isinstance(node, tree.PythonNode):
101+
if node.type == "tfpdef":
102+
if node.children[2] == word_node:
103+
return True
104+
if node.type == "parameters":
105+
for parameter in node.children:
106+
if _handle_argument(parameter, word_node):
107+
return True
108+
return False
109+
110+
111+
def _process_statements(
112+
suggestions: List[SearchResult],
113+
doc_uri: str,
114+
word: str,
115+
autoimport: AutoImport,
116+
document: Document,
117+
) -> Generator[Dict[str, Any], None, None]:
118+
for suggestion in suggestions:
119+
insert_line = autoimport.find_insertion_line(document.source) - 1
120+
start = {"line": insert_line, "character": 0}
121+
edit_range = {"start": start, "end": start}
122+
edit = {
123+
"range": edit_range,
124+
"newText": suggestion.import_statement + "\n"
125+
}
126+
score = _get_score(suggestion.source, suggestion.import_statement,
127+
suggestion.name, word)
128+
if score > _score_max:
129+
continue
130+
# TODO make this markdown
131+
yield {
132+
"label": suggestion.name,
133+
"kind": suggestion.itemkind,
134+
"sortText": _sort_import(score),
135+
"data": {
136+
"doc_uri": doc_uri
137+
},
138+
"detail": _document(suggestion.import_statement),
139+
"additionalTextEdits": [edit],
140+
}
141+
142+
143+
def get_names(script: Script) -> Set[str]:
144+
"""Get all names to ignore from the current file."""
145+
raw_names = script.get_names(definitions=True)
146+
log.debug(raw_names)
147+
return set(name.name for name in raw_names)
148+
149+
150+
@hookimpl
151+
def pylsp_completions(config: Config, workspace: Workspace, document: Document,
152+
position):
153+
"""Get autoimport suggestions."""
154+
line = document.lines[position["line"]]
155+
expr = parso.parse(line)
156+
word_node = expr.get_leaf_for_position((1, position["character"]))
157+
if not _should_insert(expr, word_node):
158+
return []
159+
word = word_node.value
160+
log.debug(f"autoimport: searching for word: {word}")
161+
rope_config = config.settings(document_path=document.path).get("rope", {})
162+
ignored_names: Set[str] = get_names(
163+
document.jedi_script(use_document_path=True))
164+
autoimport = workspace._rope_autoimport(rope_config)
165+
suggestions = list(
166+
autoimport.search_full(word, ignored_names=ignored_names))
167+
results = list(
168+
sorted(
169+
_process_statements(suggestions, document.uri, word, autoimport,
170+
document),
171+
key=lambda statement: statement["sortText"],
172+
))
173+
if len(results) > MAX_RESULTS:
174+
results = results[:MAX_RESULTS]
175+
return results
176+
177+
178+
def _document(import_statement: str) -> str:
179+
return """# Auto-Import\n""" + import_statement
180+
181+
182+
def _get_score(source: int, full_statement: str, suggested_name: str,
183+
desired_name) -> int:
184+
import_length = len("import")
185+
full_statement_score = len(full_statement) - import_length
186+
suggested_name_score = ((len(suggested_name) - len(desired_name)))**2
187+
source_score = 20 * source
188+
return suggested_name_score + full_statement_score + source_score
189+
190+
191+
def _sort_import(score: int) -> str:
192+
score = max(min(score, (_score_max) - 1), 0)
193+
# Since we are using ints, we need to pad them.
194+
# We also want to prioritize autoimport behind everything since its the last priority.
195+
# The minimum is to prevent score from overflowing the pad
196+
return "[z" + str(score).rjust(_score_pow, "0")
197+
198+
199+
@hookimpl
200+
def pylsp_initialize(config: Config, workspace: Workspace):
201+
"""Initialize AutoImport. Generates the cache for local and global items."""
202+
memory: bool = config.plugin_settings("rope_autoimport").get(
203+
"memory", False)
204+
rope_config = config.settings().get("rope", {})
205+
autoimport = workspace._rope_autoimport(rope_config, memory)
206+
autoimport.generate_modules_cache()
207+
autoimport.generate_cache()
208+
209+
210+
@hookimpl
211+
def pylsp_document_did_save(config: Config, workspace: Workspace,
212+
document: Document):
213+
"""Update the names associated with this document."""
214+
rope_config = config.settings().get("rope", {})
215+
rope_doucment: Resource = document._rope_resource(rope_config)
216+
autoimport = workspace._rope_autoimport(rope_config)
217+
autoimport.generate_cache(resources=[rope_doucment])
218+
# Might as well using saving the document as an indicator to regenerate the module cache
219+
autoimport.generate_modules_cache()

pylsp/python_lsp.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ class _StreamHandlerWrapper(socketserver.StreamRequestHandler):
3434

3535
def setup(self):
3636
super().setup()
37-
# pylint: disable=no-member
3837
self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile)
3938

4039
def handle(self):
@@ -48,7 +47,6 @@ def handle(self):
4847
if isinstance(e, WindowsError) and e.winerror == 10054:
4948
pass
5049

51-
# pylint: disable=no-member
5250
self.SHUTDOWN_CALL()
5351

5452

@@ -212,6 +210,8 @@ def __getitem__(self, item):
212210
raise KeyError()
213211

214212
def m_shutdown(self, **_kwargs):
213+
for workspace in self.workspaces.values():
214+
workspace.close()
215215
self._shutdown = True
216216

217217
def m_exit(self, **_kwargs):
@@ -351,6 +351,9 @@ def definitions(self, doc_uri, position):
351351
def document_symbols(self, doc_uri):
352352
return flatten(self._hook('pylsp_document_symbols', doc_uri))
353353

354+
def document_did_save(self, doc_uri):
355+
return self._hook("pylsp_document_did_save", doc_uri)
356+
354357
def execute_command(self, command, arguments):
355358
return self._hook('pylsp_execute_command', command=command, arguments=arguments)
356359

@@ -417,6 +420,7 @@ def m_text_document__did_change(self, contentChanges=None, textDocument=None, **
417420

418421
def m_text_document__did_save(self, textDocument=None, **_kwargs):
419422
self.lint(textDocument['uri'], is_saved=True)
423+
self.document_did_save(textDocument['uri'])
420424

421425
def m_text_document__code_action(self, textDocument=None, range=None, context=None, **_kwargs):
422426
return self.code_actions(textDocument['uri'], range, context)

pylsp/workspace.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
import functools
99
from threading import RLock
10+
from typing import Optional
1011

1112
import jedi
1213

@@ -50,6 +51,15 @@ def __init__(self, root_uri, endpoint, config=None):
5051
# Whilst incubating, keep rope private
5152
self.__rope = None
5253
self.__rope_config = None
54+
self.__rope_autoimport = None
55+
56+
def _rope_autoimport(self, rope_config: Optional, memory: bool = False):
57+
# pylint: disable=import-outside-toplevel
58+
from rope.contrib.autoimport.sqlite import AutoImport
59+
if self.__rope_autoimport is None:
60+
project = self._rope_project_builder(rope_config)
61+
self.__rope_autoimport = AutoImport(project, memory=memory)
62+
return self.__rope_autoimport
5363

5464
def _rope_project_builder(self, rope_config):
5565
# pylint: disable=import-outside-toplevel
@@ -58,8 +68,12 @@ def _rope_project_builder(self, rope_config):
5868
# TODO: we could keep track of dirty files and validate only those
5969
if self.__rope is None or self.__rope_config != rope_config:
6070
rope_folder = rope_config.get('ropeFolder')
61-
self.__rope = Project(self._root_path, ropefolder=rope_folder)
62-
self.__rope.prefs.set('extension_modules', rope_config.get('extensionModules', []))
71+
if rope_folder:
72+
self.__rope = Project(self._root_path, ropefolder=rope_folder)
73+
else:
74+
self.__rope = Project(self._root_path)
75+
self.__rope.prefs.set('extension_modules',
76+
rope_config.get('extensionModules', []))
6377
self.__rope.prefs.set('ignore_syntax_errors', True)
6478
self.__rope.prefs.set('ignore_bad_imports', True)
6579
self.__rope.validate()
@@ -130,6 +144,10 @@ def _create_document(self, doc_uri, source=None, version=None):
130144
rope_project_builder=self._rope_project_builder,
131145
)
132146

147+
def close(self):
148+
if self.__rope_autoimport is not None:
149+
self.__rope_autoimport.close()
150+
133151

134152
class Document:
135153

0 commit comments

Comments
 (0)
0