Skip to content

Allow usage of Pylint via stdin #831

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 4 commits into from
Sep 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion pyls/plugins/pylint_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import collections
import logging
import sys
import re
from subprocess import Popen, PIPE

from pylint.epylint import py_run
from pyls import hookimpl, lsp
Expand Down Expand Up @@ -154,12 +156,149 @@ def _build_pylint_flags(settings):
def pyls_settings():
# Default pylint to disabled because it requires a config
# file to be useful.
return {'plugins': {'pylint': {'enabled': False, 'args': []}}}
return {'plugins': {'pylint': {
'enabled': False,
'args': [],
# disabled by default as it can slow down the workflow
'executable': None,
}}}


@hookimpl
def pyls_lint(config, document, is_saved):
"""Run pylint linter."""
settings = config.plugin_settings('pylint')
log.debug("Got pylint settings: %s", settings)
# pylint >= 2.5.0 is required for working through stdin and only
# available with python3
if settings.get('executable') and sys.version_info[0] >= 3:
flags = build_args_stdio(settings)
pylint_executable = settings.get('executable', 'pylint')
return pylint_lint_stdin(pylint_executable, document, flags)
flags = _build_pylint_flags(settings)
return PylintLinter.lint(document, is_saved, flags=flags)


def build_args_stdio(settings):
"""Build arguments for calling pylint.

:param settings: client settings
:type settings: dict

:return: arguments to path to pylint
:rtype: list
"""
pylint_args = settings.get('args')
if pylint_args is None:
return []
return pylint_args


def pylint_lint_stdin(pylint_executable, document, flags):
"""Run pylint linter from stdin.

This runs pylint in a subprocess with popen.
This allows passing the file from stdin and as a result
run pylint on unsaved files. Can slowdown the workflow.

:param pylint_executable: path to pylint executable
:type pylint_executable: string
:param document: document to run pylint on
:type document: pyls.workspace.Document
:param flags: arguments to path to pylint
:type flags: list

:return: linting diagnostics
:rtype: list
"""
pylint_result = _run_pylint_stdio(pylint_executable, document, flags)
return _parse_pylint_stdio_result(document, pylint_result)


def _run_pylint_stdio(pylint_executable, document, flags):
"""Run pylint in popen.

:param pylint_executable: path to pylint executable
:type pylint_executable: string
:param document: document to run pylint on
:type document: pyls.workspace.Document
:param flags: arguments to path to pylint
:type flags: list

:return: result of calling pylint
:rtype: string
"""
log.debug("Calling %s with args: '%s'", pylint_executable, flags)
try:
cmd = [pylint_executable]
cmd.extend(flags)
cmd.extend(['--from-stdin', document.path])
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
except IOError:
log.debug("Can't execute %s. Trying with 'python -m pylint'", pylint_executable)
cmd = ['python', '-m', 'pylint']
cmd.extend(flags)
cmd.extend(['--from-stdin', document.path])
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
(stdout, stderr) = p.communicate(document.source.encode())
if stderr:
log.error("Error while running pylint '%s'", stderr.decode())
return stdout.decode()


def _parse_pylint_stdio_result(document, stdout):
"""Parse pylint results.

:param document: document to run pylint on
:type document: pyls.workspace.Document
:param stdout: pylint results to parse
:type stdout: string

:return: linting diagnostics
:rtype: list
"""
diagnostics = []
lines = stdout.splitlines()
for raw_line in lines:
parsed_line = re.match(r'(.*):(\d*):(\d*): (\w*): (.*)', raw_line)
if not parsed_line:
log.debug("Pylint output parser can't parse line '%s'", raw_line)
continue

parsed_line = parsed_line.groups()
if len(parsed_line) != 5:
log.debug("Pylint output parser can't parse line '%s'", raw_line)
continue

_, line, character, code, msg = parsed_line
line = int(line) - 1
character = int(character)
severity_map = {
'C': lsp.DiagnosticSeverity.Information,
'E': lsp.DiagnosticSeverity.Error,
'F': lsp.DiagnosticSeverity.Error,
'R': lsp.DiagnosticSeverity.Hint,
'W': lsp.DiagnosticSeverity.Warning,
}
severity = severity_map[code[0]]
diagnostics.append(
{
'source': 'pylint',
'code': code,
'range': {
'start': {
'line': line,
'character': character
},
'end': {
'line': line,
# no way to determine the column
'character': len(document.lines[line]) - 1
}
},
'message': msg,
'severity': severity,
}
)

return diagnostics
15 changes: 10 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python
import sys
from setuptools import find_packages, setup
import versioneer
import sys
Expand Down Expand Up @@ -59,7 +60,9 @@
'pycodestyle>=2.6.0,<2.7.0',
'pydocstyle>=2.0.0',
'pyflakes>=2.2.0,<2.3.0',
'pylint',
# pylint >= 2.5.0 is required for working through stdin and only
# available with python3
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint',
'rope>=0.10.5',
'yapf',
],
Expand All @@ -69,12 +72,14 @@
'pycodestyle': ['pycodestyle>=2.6.0,<2.7.0'],
'pydocstyle': ['pydocstyle>=2.0.0'],
'pyflakes': ['pyflakes>=2.2.0,<2.3.0'],
'pylint': ['pylint'],
'pylint': [
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint'],
'rope': ['rope>0.10.5'],
'yapf': ['yapf'],
'test': ['versioneer', 'pylint', 'pytest', 'mock', 'pytest-cov',
'coverage', 'numpy', 'pandas', 'matplotlib',
'pyqt5;python_version>="3"', 'flaky'],
'test': ['versioneer',
'pylint>=2.5.0' if sys.version_info.major >= 3 else 'pylint',
'pytest', 'mock', 'pytest-cov', 'coverage', 'numpy', 'pandas',
'matplotlib', 'pyqt5;python_version>="3"', 'flaky'],
},

# To provide executable scripts, use entry points in preference to the
Expand Down
25 changes: 24 additions & 1 deletion test/plugins/test_pylint_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import tempfile

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

if IS_PY3:
# test running pylint in stdin
config.plugin_settings('pylint')['executable'] = 'pylint'
diags = pylint_lint.pyls_lint(config, doc, True)

msg = 'Unused import sys (unused-import)'
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


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

# test running pylint in stdin
config.plugin_settings('pylint')['executable'] = 'pylint'
diag = pylint_lint.pyls_lint(config, doc, True)[0]

assert diag['message'].startswith('invalid syntax')
# Pylint doesn't give column numbers for invalid syntax.
assert diag['range']['start'] == {'line': 0, 'character': 12}
assert diag['severity'] == lsp.DiagnosticSeverity.Error


@py2_only
def test_syntax_error_pylint_py2(config, workspace):
Expand Down
5 changes: 5 additions & 0 deletions vscode-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@
"uniqueItems": false,
"description": "Arguments to pass to pylint."
},
"pyls.plugins.pylint.executable": {
"type": "string",
"default": null,
"description": "Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3."
},
"pyls.plugins.rope_completion.enabled": {
"type": "boolean",
"default": true,
Expand Down