Skip to content

Commit e7e7dd2

Browse files
committed
Auto import actions using importmagic
1 parent 5850ac0 commit e7e7dd2

File tree

5 files changed

+261
-0
lines changed

5 files changed

+261
-0
lines changed

pyls/_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,7 @@ def is_process_alive(pid):
192192
return e.errno == errno.EPERM
193193
else:
194194
return True
195+
196+
197+
def camel_to_underscore(camelcase):
198+
return re.sub('([A-Z]+)', r'_\1',camelcase).lower()

pyls/plugins/importmagic_lint.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Copyright 2017 Palantir Technologies, Inc.
2+
import logging
3+
import re
4+
import sys
5+
import importmagic
6+
from pyls import hookimpl, lsp, _utils
7+
8+
9+
log = logging.getLogger(__name__)
10+
11+
SOURCE = 'importmagic'
12+
ADD_IMPORT_COMMAND = 'importmagic.addimport'
13+
MAX_COMMANDS = 4
14+
UNRES_RE = re.compile(r"Unresolved import '(?P<unresolved>[\w.]+)'")
15+
16+
_index_cache = {}
17+
18+
19+
def _get_index(sys_path):
20+
"""Build index of symbols from python modules.
21+
Cache the index so we don't build it multiple times unnecessarily.
22+
"""
23+
key = tuple(sys_path)
24+
if key not in _index_cache:
25+
log.debug("Started building importmagic index")
26+
index = importmagic.SymbolIndex()
27+
# The build tend to be noisy
28+
logging.getLogger('importmagic.index').setLevel(logging.ERROR)
29+
index.build_index(paths=sys_path)
30+
_index_cache[key] = index
31+
logging.getLogger('importmagic.index').setLevel(logging.DEBUG)
32+
log.debug("Finished building importmagic index")
33+
return _index_cache[key]
34+
35+
36+
@hookimpl
37+
def pyls_commands():
38+
return [ADD_IMPORT_COMMAND]
39+
40+
41+
@hookimpl
42+
def pyls_lint(document):
43+
"""Build a diagnostics of unresolved symbols. Every entry follows this format:
44+
{
45+
'source': 'importmagic',
46+
'range': {
47+
'start': {
48+
'line': start_line,
49+
'character': start_column,
50+
},
51+
'end': {
52+
'line': end_line,
53+
'character': end_column,
54+
},
55+
},
56+
'message': 'Unresolved import <symbol>',
57+
'severity': lsp.DiagnosticSeverity.Hint,
58+
}
59+
60+
Args:
61+
document: The document to be linted.
62+
Returns:
63+
A list of dictionaries.
64+
"""
65+
scope = importmagic.Scope.from_source(document.source)
66+
unresolved, _unreferenced = scope.find_unresolved_and_unreferenced_symbols()
67+
68+
diagnostics = []
69+
70+
# Annoyingly, we only get the text of an unresolved import, so we'll look for it ourselves
71+
for unres in unresolved:
72+
if unres not in document.source:
73+
continue
74+
75+
for line_no, line in enumerate(document.lines):
76+
pos = line.find(unres)
77+
if pos < 0:
78+
continue
79+
80+
diagnostics.append({
81+
'source': SOURCE,
82+
'range': {
83+
'start': {'line': line_no, 'character': pos},
84+
'end': {'line': line_no, 'character': pos + len(unres)}
85+
},
86+
'message': "Unresolved import '%s'" % unres,
87+
'severity': lsp.DiagnosticSeverity.Hint,
88+
})
89+
90+
return diagnostics
91+
92+
93+
@hookimpl
94+
def pyls_code_actions(config, document, context):
95+
"""Build a list of actions to be suggested to the user. Each action follow this format:
96+
{
97+
'title': 'importmagic',
98+
'command': command ('importmagic.add_import'),
99+
'arguments':
100+
{
101+
'uri': document.uri,
102+
'version': document.version,
103+
'startLine': start_line,
104+
'endLine': end_line,
105+
'newText': text,
106+
}
107+
}
108+
"""
109+
# Update the style configuration
110+
conf = config.plugin_settings('importmagic_lint')
111+
log.debug("Got importmagic settings: %s", conf)
112+
importmagic.Imports.set_style(**{_utils.camel_to_underscore(k): v for k, v in conf.items()})
113+
114+
actions = []
115+
diagnostics = context.get('diagnostics', [])
116+
for diagnostic in diagnostics:
117+
if diagnostic.get('source') != SOURCE:
118+
continue
119+
m = UNRES_RE.match(diagnostic['message'])
120+
if not m:
121+
continue
122+
123+
unres = m.group('unresolved')
124+
# Might be slow but is cached once built
125+
index = _get_index(sys.path)
126+
127+
for score, module, variable in sorted(index.symbol_scores(unres)[:MAX_COMMANDS], reverse=True):
128+
if score < 1:
129+
# These tend to be terrible
130+
continue
131+
132+
# Generate the patch we would need to apply
133+
imports = importmagic.Imports(index, document.source)
134+
if variable:
135+
imports.add_import_from(module, variable)
136+
else:
137+
imports.add_import(module)
138+
start_line, end_line, text = imports.get_update()
139+
140+
actions.append({
141+
'title': _command_title(variable, module),
142+
'command': ADD_IMPORT_COMMAND,
143+
'arguments': [{
144+
'uri': document.uri,
145+
'version': document.version,
146+
'startLine': start_line,
147+
'endLine': end_line,
148+
'newText': text
149+
}]
150+
})
151+
return actions
152+
153+
154+
@hookimpl
155+
def pyls_execute_command(workspace, command, arguments):
156+
if command != ADD_IMPORT_COMMAND:
157+
return
158+
159+
args = arguments[0]
160+
161+
edit = {'documentChanges': [{
162+
'textDocument': {
163+
'uri': args['uri'],
164+
'version': args['version']
165+
},
166+
'edits': [{
167+
'range': {
168+
'start': {'line': args['startLine'], 'character': 0},
169+
'end': {'line': args['endLine'], 'character': 0},
170+
},
171+
'newText': args['newText']
172+
}]
173+
}]}
174+
workspace.apply_edit(edit)
175+
176+
177+
def _command_title(variable, module):
178+
if not variable:
179+
return 'Import "%s"' % module
180+
return 'Import "%s" from "%s"' % (variable, module)

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
'all': [
4949
'autopep8',
5050
'flake8',
51+
'importmagic',
5152
'mccabe',
5253
'pycodestyle',
5354
'pydocstyle>=2.0.0',
@@ -58,6 +59,7 @@
5859
],
5960
'autopep8': ['autopep8'],
6061
'flake8': ['flake8'],
62+
'importmagic': ['importmagic'],
6163
'mccabe': ['mccabe'],
6264
'pycodestyle': ['pycodestyle'],
6365
'pydocstyle': ['pydocstyle>=2.0.0'],
@@ -78,6 +80,7 @@
7880
'pyls': [
7981
'autopep8 = pyls.plugins.autopep8_format',
8082
'flake8 = pyls.plugins.flake8_lint',
83+
'importmagic = pyls.plugins.importmagic_lint',
8184
'jedi_completion = pyls.plugins.jedi_completion',
8285
'jedi_definition = pyls.plugins.definition',
8386
'jedi_hover = pyls.plugins.hover',

test/plugins/test_importmagic_lint.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2019 Palantir Technologies, Inc.
2+
import tempfile
3+
import os
4+
from pyls import lsp, uris
5+
from pyls.plugins import importmagic_lint
6+
from pyls.workspace import Document
7+
8+
DOC_URI = uris.from_fs_path(__file__)
9+
DOC = """
10+
files = listdir()
11+
print(files)
12+
"""
13+
14+
15+
def temp_document(doc_text):
16+
temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False)
17+
name = temp_file.name
18+
temp_file.write(doc_text)
19+
temp_file.close()
20+
doc = Document(uris.from_fs_path(name))
21+
22+
return name, doc
23+
24+
25+
def test_importmagic_lint():
26+
try:
27+
name, doc = temp_document(DOC)
28+
diags = importmagic_lint.pyls_lint(doc)
29+
unres_symbol = [d for d in diags if d['source'] == 'importmagic'][0]
30+
31+
assert unres_symbol['message'] == "Unresolved import 'listdir'"
32+
assert unres_symbol['range']['start'] == {'line': 1, 'character': 8}
33+
assert unres_symbol['range']['end'] == {'line': 1, 'character': 15}
34+
assert unres_symbol['severity'] == lsp.DiagnosticSeverity.Hint
35+
36+
finally:
37+
os.remove(name)
38+
39+
40+
def test_importmagic_actions(config):
41+
context = {
42+
'diagnostict': [
43+
{
44+
'range':
45+
{
46+
'start': {'line': 1, 'character': 8},
47+
'end': {'line': 1, 'character': 15}
48+
},
49+
'message': "Unresolved import 'listdir'",
50+
'severity': 4,
51+
'source': 'importmagic'
52+
}
53+
]
54+
}
55+
56+
try:
57+
name, doc = temp_document(DOC)
58+
action = importmagic_lint.pyls_code_actions(config, doc, context)[0]
59+
arguments = action['arguments']
60+
61+
assert action['title'] == 'Import "listdir" from "os"'
62+
assert action['command'] == 'importmagic.addimport'
63+
assert arguments['startLine'] == 1
64+
assert arguments['endLine'] == 1
65+
assert arguments['newText'] == 'from os import listdir\n\n\n'
66+
67+
finally:
68+
os.remove(name)

test/test_utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,9 @@ def test_clip_column():
8787
assert _utils.clip_column(2, ['123\n', '123'], 0) == 2
8888
assert _utils.clip_column(3, ['123\n', '123'], 0) == 3
8989
assert _utils.clip_column(4, ['123\n', '123'], 1) == 3
90+
91+
92+
def test_camel_to_underscore():
93+
assert _utils.camel_to_underscore('camelCase') == 'camel_case'
94+
assert _utils.camel_to_underscore('hangClosing') == 'hang_closing'
95+
assert _utils.camel_to_underscore('ignore') == 'ignore'

0 commit comments

Comments
 (0)