# Copyright 2018 Google LLC. # Copyright 2017-2020 Palantir Technologies, Inc. # Copyright 2021- Python Language Server Contributors. """Linter plugin for pylint.""" import collections import logging import sys import re from subprocess import Popen, PIPE from pylint.epylint import py_run from pylsp import hookimpl, lsp try: import ujson as json except Exception: # pylint: disable=broad-except import json log = logging.getLogger(__name__) class PylintLinter: last_diags = collections.defaultdict(list) @classmethod def lint(cls, document, is_saved, flags=''): """Plugin interface to pylsp 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 pylsp_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('\\', '/') pylint_call = '{} -f json {}'.format(path, flags) log.debug("Calling pylint with '%s'", pylint_call) json_out, err = py_run(pylint_call, return_std=True) # Get strings json_out = json_out.getvalue() err = err.getvalue() if err != '': log.error("Error calling pylint: '%s'", err) # pylint prints nothing rather than [] when there are no diagnostics. # json.loads will not parse an empty string, so just return. if not json_out.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_out): # pylint lines index from 1, pylsp lines index from 0 line = diag['line'] - 1 err_range = { 'start': { 'line': line, # Index columns start from 0 'character': diag['column'], }, 'end': { 'line': line, # It's possible that we're linting an empty file. Even an empty # file might fail linting if it isn't named properly. 'character': len(document.lines[line]) if document.lines else 0, }, } 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 def _build_pylint_flags(settings): """Build arguments for calling pylint.""" pylint_args = settings.get('args') if pylint_args is None: return '' return ' '.join(pylint_args) @hookimpl def pylsp_settings(): # Default pylint to disabled because it requires a config # file to be useful. return {'plugins': {'pylint': { 'enabled': False, 'args': [], # disabled by default as it can slow down the workflow 'executable': None, }}} @hookimpl def pylsp_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: pylsp.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: pylsp.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) # pylint: disable=consider-using-with 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) # pylint: disable=consider-using-with (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: pylsp.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, 'I': lsp.DiagnosticSeverity.Information, '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