Skip to content

Commit 9bc744d

Browse files
authored
Allow usage of Pylint via stdin (#831)
1 parent dfdfb09 commit 9bc744d

File tree

4 files changed

+179
-7
lines changed

4 files changed

+179
-7
lines changed

pyls/plugins/pylint_lint.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import collections
44
import logging
55
import sys
6+
import re
7+
from subprocess import Popen, PIPE
68

79
from pylint.epylint import py_run
810
from pyls import hookimpl, lsp
@@ -154,12 +156,149 @@ def _build_pylint_flags(settings):
154156
def pyls_settings():
155157
# Default pylint to disabled because it requires a config
156158
# file to be useful.
157-
return {'plugins': {'pylint': {'enabled': False, 'args': []}}}
159+
return {'plugins': {'pylint': {
160+
'enabled': False,
161+
'args': [],
162+
# disabled by default as it can slow down the workflow
163+
'executable': None,
164+
}}}
158165

159166

160167
@hookimpl
161168
def pyls_lint(config, document, is_saved):
169+
"""Run pylint linter."""
162170
settings = config.plugin_settings('pylint')
163171
log.debug("Got pylint settings: %s", settings)
172+
# pylint >= 2.5.0 is required for working through stdin and only
173+
# available with python3
174+
if settings.get('executable') and sys.version_info[0] >= 3:
175+
flags = build_args_stdio(settings)
176+
pylint_executable = settings.get('executable', 'pylint')
177+
return pylint_lint_stdin(pylint_executable, document, flags)
164178
flags = _build_pylint_flags(settings)
165179
return PylintLinter.lint(document, is_saved, flags=flags)
180+
181+
182+
def build_args_stdio(settings):
183+
"""Build arguments for calling pylint.
184+
185+
:param settings: client settings
186+
:type settings: dict
187+
188+
:return: arguments to path to pylint
189+
:rtype: list
190+
"""
191+
pylint_args = settings.get('args')
192+
if pylint_args is None:
193+
return []
194+
return pylint_args
195+
196+
197+
def pylint_lint_stdin(pylint_executable, document, flags):
198+
"""Run pylint linter from stdin.
199+
200+
This runs pylint in a subprocess with popen.
201+
This allows passing the file from stdin and as a result
202+
run pylint on unsaved files. Can slowdown the workflow.
203+
204+
:param pylint_executable: path to pylint executable
205+
:type pylint_executable: string
206+
:param document: document to run pylint on
207+
:type document: pyls.workspace.Document
208+
:param flags: arguments to path to pylint
209+
:type flags: list
210+
211+
:return: linting diagnostics
212+
:rtype: list
213+
"""
214+
pylint_result = _run_pylint_stdio(pylint_executable, document, flags)
215+
return _parse_pylint_stdio_result(document, pylint_result)
216+
217+
218+
def _run_pylint_stdio(pylint_executable, document, flags):
219+
"""Run pylint in popen.
220+
221+
:param pylint_executable: path to pylint executable
222+
:type pylint_executable: string
223+
:param document: document to run pylint on
224+
:type document: pyls.workspace.Document
225+
:param flags: arguments to path to pylint
226+
:type flags: list
227+
228+
:return: result of calling pylint
229+
:rtype: string
230+
"""
231+
log.debug("Calling %s with args: '%s'", pylint_executable, flags)
232+
try:
233+
cmd = [pylint_executable]
234+
cmd.extend(flags)
235+
cmd.extend(['--from-stdin', document.path])
236+
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
237+
except IOError:
238+
log.debug("Can't execute %s. Trying with 'python -m pylint'", pylint_executable)
239+
cmd = ['python', '-m', 'pylint']
240+
cmd.extend(flags)
241+
cmd.extend(['--from-stdin', document.path])
242+
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
243+
(stdout, stderr) = p.communicate(document.source.encode())
244+
if stderr:
245+
log.error("Error while running pylint '%s'", stderr.decode())
246+
return stdout.decode()
247+
248+
249+
def _parse_pylint_stdio_result(document, stdout):
250+
"""Parse pylint results.
251+
252+
:param document: document to run pylint on
253+
:type document: pyls.workspace.Document
254+
:param stdout: pylint results to parse
255+
:type stdout: string
256+
257+
:return: linting diagnostics
258+
:rtype: list
259+
"""
260+
diagnostics = []
261+
lines = stdout.splitlines()
262+
for raw_line in lines:
263+
parsed_line = re.match(r'(.*):(\d*):(\d*): (\w*): (.*)', raw_line)
264+
if not parsed_line:
265+
log.debug("Pylint output parser can't parse line '%s'", raw_line)
266+
continue
267+
268+
parsed_line = parsed_line.groups()
269+
if len(parsed_line) != 5:
270+
log.debug("Pylint output parser can't parse line '%s'", raw_line)
271+
continue
272+
273+
_, line, character, code, msg = parsed_line
274+
line = int(line) - 1
275+
character = int(character)
276+
severity_map = {
277+
'C': lsp.DiagnosticSeverity.Information,
278+
'E': lsp.DiagnosticSeverity.Error,
279+
'F': lsp.DiagnosticSeverity.Error,
280+
'R': lsp.DiagnosticSeverity.Hint,
281+
'W': lsp.DiagnosticSeverity.Warning,
282+
}
283+
severity = severity_map[code[0]]
284+
diagnostics.append(
285+
{
286+
'source': 'pylint',
287+
'code': code,
288+
'range': {
289+
'start': {
290+
'line': line,
291+
'character': character
292+
},
293+
'end': {
294+
'line': line,
295+
# no way to determine the column
296+
'character': len(document.lines[line]) - 1
297+
}
298+
},
299+
'message': msg,
300+
'severity': severity,
301+
}
302+
)
303+
304+
return diagnostics

setup.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env python
2+
import sys
23
from setuptools import find_packages, setup
34
import versioneer
45
import sys
@@ -59,7 +60,9 @@
5960
'pycodestyle>=2.6.0,<2.7.0',
6061
'pydocstyle>=2.0.0',
6162
'pyflakes>=2.2.0,<2.3.0',
62-
'pylint',
63+
# pylint >= 2.5.0 is required for working through stdin and only
64+
# available with python3
65+
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint',
6366
'rope>=0.10.5',
6467
'yapf',
6568
],
@@ -69,12 +72,14 @@
6972
'pycodestyle': ['pycodestyle>=2.6.0,<2.7.0'],
7073
'pydocstyle': ['pydocstyle>=2.0.0'],
7174
'pyflakes': ['pyflakes>=2.2.0,<2.3.0'],
72-
'pylint': ['pylint'],
75+
'pylint': [
76+
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint'],
7377
'rope': ['rope>0.10.5'],
7478
'yapf': ['yapf'],
75-
'test': ['versioneer', 'pylint', 'pytest', 'mock', 'pytest-cov',
76-
'coverage', 'numpy', 'pandas', 'matplotlib',
77-
'pyqt5;python_version>="3"', 'flaky'],
79+
'test': ['versioneer',
80+
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint',
81+
'pytest', 'mock', 'pytest-cov', 'coverage', 'numpy', 'pandas',
82+
'matplotlib', 'pyqt5;python_version>="3"', 'flaky'],
7883
},
7984

8085
# To provide executable scripts, use entry points in preference to the

test/plugins/test_pylint_lint.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
import tempfile
55

6-
from test import py2_only, py3_only
6+
from test import py2_only, py3_only, IS_PY3
77
from pyls import lsp, uris
88
from pyls.workspace import Document
99
from pyls.plugins import pylint_lint
@@ -49,6 +49,20 @@ def test_pylint(config, workspace):
4949
assert unused_import['range']['start'] == {'line': 0, 'character': 0}
5050
assert unused_import['severity'] == lsp.DiagnosticSeverity.Warning
5151

52+
if IS_PY3:
53+
# test running pylint in stdin
54+
config.plugin_settings('pylint')['executable'] = 'pylint'
55+
diags = pylint_lint.pyls_lint(config, doc, True)
56+
57+
msg = 'Unused import sys (unused-import)'
58+
unused_import = [d for d in diags if d['message'] == msg][0]
59+
60+
assert unused_import['range']['start'] == {
61+
'line': 0,
62+
'character': 0,
63+
}
64+
assert unused_import['severity'] == lsp.DiagnosticSeverity.Warning
65+
5266

5367
@py3_only
5468
def test_syntax_error_pylint_py3(config, workspace):
@@ -60,6 +74,15 @@ def test_syntax_error_pylint_py3(config, workspace):
6074
assert diag['range']['start'] == {'line': 0, 'character': 12}
6175
assert diag['severity'] == lsp.DiagnosticSeverity.Error
6276

77+
# test running pylint in stdin
78+
config.plugin_settings('pylint')['executable'] = 'pylint'
79+
diag = pylint_lint.pyls_lint(config, doc, True)[0]
80+
81+
assert diag['message'].startswith('invalid syntax')
82+
# Pylint doesn't give column numbers for invalid syntax.
83+
assert diag['range']['start'] == {'line': 0, 'character': 12}
84+
assert diag['severity'] == lsp.DiagnosticSeverity.Error
85+
6386

6487
@py2_only
6588
def test_syntax_error_pylint_py2(config, workspace):

vscode-client/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,11 @@
259259
"uniqueItems": false,
260260
"description": "Arguments to pass to pylint."
261261
},
262+
"pyls.plugins.pylint.executable": {
263+
"type": "string",
264+
"default": null,
265+
"description": "Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3."
266+
},
262267
"pyls.plugins.rope_completion.enabled": {
263268
"type": "boolean",
264269
"default": true,

0 commit comments

Comments
 (0)