-
Notifications
You must be signed in to change notification settings - Fork 285
Code action to add/delete imports #685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 10 commits
e7e7dd2
f9ef0a0
4c89036
4383f1e
7ed8a3b
4ae20c8
8242066
5175028
f54910d
2668de3
ecabe85
936f121
b0f04a3
2261042
d57d904
c7e5b36
061f0cc
e9996f7
48d1f01
2d12e54
71b8385
f99d33e
6812993
d178670
ae63149
a712a73
80def3f
0a9d163
e7ee0ca
809a4af
1c509f9
b82ff8f
107c431
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
# Copyright 2017 Palantir Technologies, Inc. | ||
import logging | ||
import re | ||
import sys | ||
import importmagic | ||
from pyls import hookimpl, lsp, _utils | ||
|
||
|
||
log = logging.getLogger(__name__) | ||
|
||
SOURCE = 'importmagic' | ||
ADD_IMPORT_COMMAND = 'importmagic.addimport' | ||
MAX_COMMANDS = 4 | ||
UNRES_RE = re.compile(r"Unresolved import '(?P<unresolved>[\w.]+)'") | ||
|
||
_index_cache = {} | ||
|
||
|
||
def _get_index(sys_path): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's make this return a future, that way we can skip results instead of blocking on indexing. |
||
"""Build index of symbols from python modules. | ||
Cache the index so we don't build it multiple times unnecessarily. | ||
""" | ||
key = tuple(sys_path) | ||
if key not in _index_cache: | ||
log.debug("Started building importmagic index") | ||
youben11 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
index = importmagic.SymbolIndex() | ||
# The build tend to be noisy | ||
logging.getLogger('importmagic.index').setLevel(logging.ERROR) | ||
youben11 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
index.build_index(paths=sys_path) | ||
_index_cache[key] = index | ||
logging.getLogger('importmagic.index').setLevel(logging.DEBUG) | ||
log.debug("Finished building importmagic index") | ||
return _index_cache[key] | ||
|
||
|
||
@hookimpl | ||
def pyls_commands(): | ||
return [ADD_IMPORT_COMMAND] | ||
|
||
|
||
@hookimpl | ||
def pyls_lint(document): | ||
"""Build a diagnostics of unresolved symbols. Every entry follows this format: | ||
{ | ||
'source': 'importmagic', | ||
'range': { | ||
'start': { | ||
'line': start_line, | ||
'character': start_column, | ||
}, | ||
'end': { | ||
'line': end_line, | ||
'character': end_column, | ||
}, | ||
}, | ||
'message': 'Unresolved import <symbol>', | ||
'severity': lsp.DiagnosticSeverity.Hint, | ||
} | ||
|
||
Args: | ||
document: The document to be linted. | ||
Returns: | ||
A list of dictionaries. | ||
""" | ||
scope = importmagic.Scope.from_source(document.source) | ||
unresolved, _unreferenced = scope.find_unresolved_and_unreferenced_symbols() | ||
youben11 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
diagnostics = [] | ||
|
||
# Annoyingly, we only get the text of an unresolved import, so we'll look for it ourselves | ||
for unres in unresolved: | ||
if unres not in document.source: | ||
continue | ||
|
||
for line_no, line in enumerate(document.lines): | ||
pos = line.find(unres) | ||
if pos < 0: | ||
continue | ||
|
||
diagnostics.append({ | ||
'source': SOURCE, | ||
'range': { | ||
'start': {'line': line_no, 'character': pos}, | ||
'end': {'line': line_no, 'character': pos + len(unres)} | ||
}, | ||
'message': "Unresolved import '%s'" % unres, | ||
'severity': lsp.DiagnosticSeverity.Hint, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this not be a warning / error? Or does it overlap with e.g. pyflakes results There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a hint because we actually don't know if this is a module to be imported or just a non defined variable, so better not to trigger a false positive. The unreferenced symbols are flagged with Warning because we know in advance if the symbol is either an import or a varialbe/function and the error should be consistent. |
||
}) | ||
|
||
return diagnostics | ||
|
||
|
||
@hookimpl | ||
def pyls_code_actions(config, document, context): | ||
"""Build a list of actions to be suggested to the user. Each action follow this format: | ||
{ | ||
'title': 'importmagic', | ||
'command': command ('importmagic.add_import'), | ||
'arguments': | ||
{ | ||
'uri': document.uri, | ||
'version': document.version, | ||
'startLine': start_line, | ||
'endLine': end_line, | ||
'newText': text, | ||
} | ||
} | ||
""" | ||
# Update the style configuration | ||
conf = config.plugin_settings('importmagic_lint') | ||
min_score = conf.get('minScore', 1) | ||
log.debug("Got importmagic settings: %s", conf) | ||
importmagic.Imports.set_style(**{_utils.camel_to_underscore(k): v for k, v in conf.items()}) | ||
|
||
actions = [] | ||
diagnostics = context.get('diagnostics', []) | ||
for diagnostic in diagnostics: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if it's easier to just call e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True that the diagnostics list can grow bigger than the local diagnostics and we will just pass most of them. Also we don't need to do the equality check if we call pyls_lint() as all the diagnostics will be processed. |
||
if diagnostic.get('source') != SOURCE: | ||
continue | ||
m = UNRES_RE.match(diagnostic['message']) | ||
if not m: | ||
continue | ||
|
||
unres = m.group('unresolved') | ||
# Might be slow but is cached once built | ||
index = _get_index(sys.path) | ||
|
||
for score, module, variable in sorted(index.symbol_scores(unres)[:MAX_COMMANDS], reverse=True): | ||
if score < min_score: | ||
# Skip low score results | ||
continue | ||
|
||
actions.append(generate_add_action(document, index, module, variable)) | ||
|
||
return actions | ||
|
||
|
||
def generate_add_action(document, index, module, variable): | ||
youben11 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Generate the patch we would need to apply | ||
imports = importmagic.Imports(index, document.source) | ||
if variable: | ||
imports.add_import_from(module, variable) | ||
else: | ||
imports.add_import(module) | ||
start_line, end_line, text = imports.get_update() | ||
|
||
action = { | ||
'title': _command_title(variable, module), | ||
'command': ADD_IMPORT_COMMAND, | ||
'arguments': [{ | ||
'uri': document.uri, | ||
'version': document.version, | ||
'startLine': start_line, | ||
'endLine': end_line, | ||
'newText': text | ||
}] | ||
} | ||
return action | ||
|
||
|
||
@hookimpl | ||
def pyls_execute_command(workspace, command, arguments): | ||
if command != ADD_IMPORT_COMMAND: | ||
return | ||
|
||
args = arguments[0] | ||
|
||
edit = {'documentChanges': [{ | ||
'textDocument': { | ||
'uri': args['uri'], | ||
'version': args['version'] | ||
}, | ||
'edits': [{ | ||
'range': { | ||
'start': {'line': args['startLine'], 'character': 0}, | ||
'end': {'line': args['endLine'], 'character': 0}, | ||
}, | ||
'newText': args['newText'] | ||
}] | ||
}]} | ||
workspace.apply_edit(edit) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think newer versions of clients support putting the edit in the code action:
But I'm not sure which client capability we should check. What you've got now is probably safer |
||
|
||
|
||
def _command_title(variable, module): | ||
if not variable: | ||
return 'Import "%s"' % module | ||
return 'Import "%s" from "%s"' % (variable, module) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
# Copyright 2019 Palantir Technologies, Inc. | ||
import tempfile | ||
import os | ||
from pyls import lsp, uris | ||
from pyls.plugins import importmagic_lint | ||
from pyls.workspace import Document | ||
|
||
DOC_URI = uris.from_fs_path(__file__) | ||
DOC = """ | ||
time.sleep(10) | ||
print("test") | ||
""" | ||
|
||
|
||
def temp_document(doc_text): | ||
temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False) | ||
name = temp_file.name | ||
temp_file.write(doc_text) | ||
temp_file.close() | ||
doc = Document(uris.from_fs_path(name)) | ||
|
||
return name, doc | ||
|
||
|
||
def test_importmagic_lint(): | ||
try: | ||
name, doc = temp_document(DOC) | ||
diags = importmagic_lint.pyls_lint(doc) | ||
unres_symbol = [d for d in diags if d['source'] == 'importmagic'][0] | ||
|
||
assert unres_symbol['message'] == "Unresolved import 'time.sleep'" | ||
assert unres_symbol['range']['start'] == {'line': 1, 'character': 0} | ||
assert unres_symbol['range']['end'] == {'line': 1, 'character': 10} | ||
assert unres_symbol['severity'] == lsp.DiagnosticSeverity.Hint | ||
|
||
finally: | ||
os.remove(name) | ||
|
||
|
||
def test_importmagic_actions(config): | ||
context = { | ||
'diagnostics': [ | ||
{ | ||
'range': | ||
{ | ||
'start': {'line': 1, 'character': 0}, | ||
'end': {'line': 1, 'character': 10} | ||
}, | ||
'message': "Unresolved import 'time.sleep'", | ||
'severity': lsp.DiagnosticSeverity.Hint, | ||
'source': importmagic_lint.SOURCE | ||
} | ||
] | ||
} | ||
|
||
try: | ||
name, doc = temp_document(DOC) | ||
actions = importmagic_lint.pyls_code_actions(config, doc, context) | ||
action = [a for a in actions if a['title'] == 'Import "time"'][0] | ||
arguments = action['arguments'][0] | ||
|
||
assert action['command'] == importmagic_lint.ADD_IMPORT_COMMAND | ||
assert arguments['startLine'] == 1 | ||
assert arguments['endLine'] == 1 | ||
assert arguments['newText'] == 'import time\n\n\n' | ||
|
||
finally: | ||
os.remove(name) |
Uh oh!
There was an error while loading. Please reload this page.