From 9bc744d497d48a3164fdc4a0b78285c5a816d3a1 Mon Sep 17 00:00:00 2001 From: Mykhailo Panarin <31699470+mpanarin@users.noreply.github.com> Date: Thu, 10 Sep 2020 17:29:10 +0300 Subject: [PATCH] Allow usage of Pylint via stdin (#831) --- pyls/plugins/pylint_lint.py | 141 ++++++++++++++++++++++++++++++- setup.py | 15 ++-- test/plugins/test_pylint_lint.py | 25 +++++- vscode-client/package.json | 5 ++ 4 files changed, 179 insertions(+), 7 deletions(-) diff --git a/pyls/plugins/pylint_lint.py b/pyls/plugins/pylint_lint.py index 9a412637..32521002 100644 --- a/pyls/plugins/pylint_lint.py +++ b/pyls/plugins/pylint_lint.py @@ -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 @@ -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 diff --git a/setup.py b/setup.py index b967efc6..6d38383a 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import sys from setuptools import find_packages, setup import versioneer import sys @@ -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', ], @@ -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 diff --git a/test/plugins/test_pylint_lint.py b/test/plugins/test_pylint_lint.py index 02d50e32..c2968ab8 100644 --- a/test/plugins/test_pylint_lint.py +++ b/test/plugins/test_pylint_lint.py @@ -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 @@ -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): @@ -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): diff --git a/vscode-client/package.json b/vscode-client/package.json index 7e4ee59f..0cca6a32 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -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,