Skip to content

Commit bd1df91

Browse files
committed
Allow usage of pylint via stdin
Mimics flake8 stdin usage. This makes it possible for pylint to run on unsaved files works on pylint>=2.5.0 I didn't notice a performance degradation in terms of running pylint itself. Of course, there *is* a performance hit overall, as pylint will be run on every change, and it can be fairly slow
1 parent 283073b commit bd1df91

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

@@ -53,7 +54,9 @@
5354
'pycodestyle>=2.6.0,<2.7.0',
5455
'pydocstyle>=2.0.0',
5556
'pyflakes>=2.2.0,<2.3.0',
56-
'pylint',
57+
# pylint >= 2.5.0 is required for working through stdin and only
58+
# available with python3
59+
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint',
5760
'rope>=0.10.5',
5861
'yapf',
5962
],
@@ -63,12 +66,14 @@
6366
'pycodestyle': ['pycodestyle>=2.6.0,<2.7.0'],
6467
'pydocstyle': ['pydocstyle>=2.0.0'],
6568
'pyflakes': ['pyflakes>=2.2.0,<2.3.0'],
66-
'pylint': ['pylint'],
69+
'pylint': [
70+
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint'],
6771
'rope': ['rope>0.10.5'],
6872
'yapf': ['yapf'],
69-
'test': ['versioneer', 'pylint', 'pytest', 'mock', 'pytest-cov',
70-
'coverage', 'numpy', 'pandas', 'matplotlib',
71-
'pyqt5;python_version>="3"', 'flaky'],
73+
'test': ['versioneer',
74+
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint',
75+
'pytest', 'mock', 'pytest-cov', 'coverage', 'numpy', 'pandas',
76+
'matplotlib', 'pyqt5;python_version>="3"', 'flaky'],
7277
},
7378

7479
# 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)