-
Notifications
You must be signed in to change notification settings - Fork 284
Add pylint support. #385
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
Merged
Merged
Add pylint support. #385
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
# Copyright 2018 Google LLC. | ||
"""Linter plugin for pylint.""" | ||
import collections | ||
import json | ||
import sys | ||
|
||
from pylint.epylint import py_run | ||
from pyls import hookimpl, lsp | ||
|
||
|
||
class PylintLinter(object): | ||
last_diags = collections.defaultdict(list) | ||
|
||
@classmethod | ||
def lint(cls, document, is_saved, flags=''): | ||
"""Plugin interface to pyls linter. | ||
|
||
Args: | ||
document: The document to be linted. | ||
is_saved: Whether or not the file has been saved to disk. | ||
flags: Additional flags to pass to pylint. Not exposed to | ||
pyls_lint, but used for testing. | ||
|
||
Returns: | ||
A list of dicts with the following format: | ||
|
||
{ | ||
'source': 'pylint', | ||
'range': { | ||
'start': { | ||
'line': start_line, | ||
'character': start_column, | ||
}, | ||
'end': { | ||
'line': end_line, | ||
'character': end_column, | ||
}, | ||
} | ||
'message': msg, | ||
'severity': lsp.DiagnosticSeverity.*, | ||
} | ||
""" | ||
if not is_saved: | ||
# Pylint can only be run on files that have been saved to disk. | ||
# Rather than return nothing, return the previous list of | ||
# diagnostics. If we return an empty list, any diagnostics we'd | ||
# previously shown will be cleared until the next save. Instead, | ||
# continue showing (possibly stale) diagnostics until the next | ||
# save. | ||
return cls.last_diags[document.path] | ||
|
||
# py_run will call shlex.split on its arguments, and shlex.split does | ||
# not handle Windows paths (it will try to perform escaping). Turn | ||
# backslashes into forward slashes first to avoid this issue. | ||
path = document.path | ||
if sys.platform.startswith('win'): | ||
path = path.replace('\\', '/') | ||
out, _err = py_run( | ||
'{} -f json {}'.format(path, flags), return_std=True | ||
) | ||
|
||
# pylint prints nothing rather than [] when there are no diagnostics. | ||
# json.loads will not parse an empty string, so just return. | ||
json_str = out.getvalue() | ||
if not json_str.strip(): | ||
cls.last_diags[document.path] = [] | ||
return [] | ||
|
||
# Pylint's JSON output is a list of objects with the following format. | ||
# | ||
# { | ||
# "obj": "main", | ||
# "path": "foo.py", | ||
# "message": "Missing function docstring", | ||
# "message-id": "C0111", | ||
# "symbol": "missing-docstring", | ||
# "column": 0, | ||
# "type": "convention", | ||
# "line": 5, | ||
# "module": "foo" | ||
# } | ||
# | ||
# The type can be any of: | ||
# | ||
# * convention | ||
# * error | ||
# * fatal | ||
# * refactor | ||
# * warning | ||
diagnostics = [] | ||
for diag in json.loads(json_str): | ||
# pylint lines index from 1, pyls lines index from 0 | ||
line = diag['line'] - 1 | ||
# But both index columns from 0 | ||
col = diag['column'] | ||
|
||
# It's possible that we're linting an empty file. Even an empty | ||
# file might fail linting if it isn't named properly. | ||
end_col = len(document.lines[line]) if document.lines else 0 | ||
|
||
err_range = { | ||
'start': { | ||
'line': line, | ||
'character': col, | ||
}, | ||
'end': { | ||
'line': line, | ||
'character': end_col, | ||
}, | ||
} | ||
|
||
if diag['type'] == 'convention': | ||
severity = lsp.DiagnosticSeverity.Information | ||
elif diag['type'] == 'error': | ||
severity = lsp.DiagnosticSeverity.Error | ||
elif diag['type'] == 'fatal': | ||
severity = lsp.DiagnosticSeverity.Error | ||
elif diag['type'] == 'refactor': | ||
severity = lsp.DiagnosticSeverity.Hint | ||
elif diag['type'] == 'warning': | ||
severity = lsp.DiagnosticSeverity.Warning | ||
|
||
diagnostics.append({ | ||
'source': 'pylint', | ||
'range': err_range, | ||
'message': '[{}] {}'.format(diag['symbol'], diag['message']), | ||
'severity': severity, | ||
'code': diag['message-id'] | ||
}) | ||
cls.last_diags[document.path] = diagnostics | ||
return diagnostics | ||
|
||
|
||
@hookimpl | ||
def pyls_lint(document, is_saved): | ||
return PylintLinter.lint(document, is_saved) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
# Copyright 2018 Google LLC. | ||
import contextlib | ||
import os | ||
import tempfile | ||
|
||
from pyls import lsp, uris | ||
from pyls.workspace import Document | ||
from pyls.plugins import pylint_lint | ||
|
||
DOC_URI = uris.from_fs_path(__file__) | ||
DOC = """import sys | ||
|
||
def hello(): | ||
\tpass | ||
|
||
import json | ||
""" | ||
|
||
DOC_SYNTAX_ERR = """def hello() | ||
pass | ||
""" | ||
|
||
|
||
@contextlib.contextmanager | ||
def temp_document(doc_text): | ||
try: | ||
temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False) | ||
name = temp_file.name | ||
temp_file.write(doc_text) | ||
temp_file.close() | ||
yield Document(uris.from_fs_path(name)) | ||
finally: | ||
os.remove(name) | ||
|
||
|
||
def write_temp_doc(document, contents): | ||
with open(document.path, 'w') as temp_file: | ||
temp_file.write(contents) | ||
|
||
|
||
def test_pylint(): | ||
with temp_document(DOC) as doc: | ||
diags = pylint_lint.pyls_lint(doc, True) | ||
|
||
msg = '[unused-import] Unused import sys' | ||
unused_import = [d for d in diags if d['message'] == msg][0] | ||
|
||
assert unused_import['range']['start'] == {'line': 0, 'character': 0} | ||
assert unused_import['severity'] == lsp.DiagnosticSeverity.Warning | ||
|
||
|
||
def test_syntax_error_pylint(): | ||
with temp_document(DOC_SYNTAX_ERR) as doc: | ||
diag = pylint_lint.pyls_lint(doc, True)[0] | ||
|
||
assert diag['message'].startswith('[syntax-error] invalid syntax') | ||
# Pylint doesn't give column numbers for invalid syntax. | ||
assert diag['range']['start'] == {'line': 0, 'character': 0} | ||
assert diag['severity'] == lsp.DiagnosticSeverity.Error | ||
|
||
|
||
def test_lint_free_pylint(): | ||
# Can't use temp_document because it might give us a file that doesn't | ||
# match pylint's naming requirements. We should be keeping this file clean | ||
# though, so it works for a test of an empty lint. | ||
assert not pylint_lint.pyls_lint( | ||
Document(uris.from_fs_path(__file__)), True) | ||
|
||
|
||
def test_lint_caching(): | ||
# Pylint can only operate on files, not in-memory contents. We cache the | ||
# diagnostics after a run so we can continue displaying them until the file | ||
# is saved again. | ||
# | ||
# We use PylintLinter.lint directly here rather than pyls_lint so we can | ||
# pass --disable=invalid-name to pylint, since we want a temporary file but | ||
# need to ensure that pylint doesn't give us invalid-name when our temp | ||
# file has capital letters in its name. | ||
|
||
flags = '--disable=invalid-name' | ||
with temp_document(DOC) as doc: | ||
# Start with a file with errors. | ||
diags = pylint_lint.PylintLinter.lint(doc, True, flags) | ||
assert diags | ||
|
||
# Fix lint errors and write the changes to disk. Run the linter in the | ||
# in-memory mode to check the cached diagnostic behavior. | ||
write_temp_doc(doc, '') | ||
assert pylint_lint.PylintLinter.lint(doc, False, flags) == diags | ||
|
||
# Now check the on-disk behavior. | ||
assert not pylint_lint.PylintLinter.lint(doc, True, flags) | ||
|
||
# Make sure the cache was properly cleared. | ||
assert not pylint_lint.PylintLinter.lint(doc, False, flags) | ||
|
||
|
||
def test_per_file_caching(): | ||
# Ensure that diagnostics are cached per-file. | ||
with temp_document(DOC) as doc: | ||
assert pylint_lint.pyls_lint(doc, True) | ||
|
||
assert not pylint_lint.pyls_lint( | ||
Document(uris.from_fs_path(__file__)), False) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.