250 lines
8.4 KiB
Python
250 lines
8.4 KiB
Python
# Copyright 2017-2020 Palantir Technologies, Inc.
|
|
# Copyright 2021- Python Language Server Contributors.
|
|
|
|
"""Linter pluging for flake8"""
|
|
|
|
import logging
|
|
import os.path
|
|
import re
|
|
import sys
|
|
from pathlib import PurePath
|
|
from subprocess import PIPE, Popen
|
|
|
|
from flake8.plugins.pyflakes import FLAKE8_PYFLAKES_CODES
|
|
|
|
from pylsp import hookimpl, lsp
|
|
from pylsp.plugins.pyflakes_lint import PYFLAKES_ERROR_MESSAGES
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
FIX_IGNORES_RE = re.compile(r"([^a-zA-Z0-9_,]*;.*(\W+||$))")
|
|
UNNECESSITY_CODES = {
|
|
"F401", # `module` imported but unused
|
|
"F504", # % format unused named arguments
|
|
"F522", # .format(...) unused named arguments
|
|
"F523", # .format(...) unused positional arguments
|
|
"F841", # local variable `name` is assigned to but never used
|
|
}
|
|
# NOTE: If the user sets the flake8 executable with workspace configuration, the
|
|
# error codes in this set may be inaccurate.
|
|
ERROR_CODES = (
|
|
# Errors from the pyflakes plugin of flake8
|
|
{FLAKE8_PYFLAKES_CODES.get(m.__name__, "E999") for m in PYFLAKES_ERROR_MESSAGES}
|
|
# Syntax error from flake8 itself
|
|
| {"E999"}
|
|
)
|
|
|
|
if sys.platform == "win32":
|
|
from subprocess import CREATE_NO_WINDOW
|
|
else:
|
|
# CREATE_NO_WINDOW flag only available on Windows.
|
|
# Set constant as default `Popen` `creationflag` kwarg value (`0`)
|
|
CREATE_NO_WINDOW = 0
|
|
|
|
|
|
@hookimpl
|
|
def pylsp_settings():
|
|
# Default flake8 to disabled
|
|
return {"plugins": {"flake8": {"enabled": False}}}
|
|
|
|
|
|
@hookimpl
|
|
def pylsp_lint(workspace, document):
|
|
with workspace.report_progress("lint: flake8"):
|
|
config = workspace._config
|
|
settings = config.plugin_settings("flake8", document_path=document.path)
|
|
log.debug("Got flake8 settings: %s", settings)
|
|
|
|
ignores = settings.get("ignore", [])
|
|
per_file_ignores = settings.get("perFileIgnores")
|
|
|
|
if per_file_ignores:
|
|
prev_file_pat = None
|
|
for path in per_file_ignores:
|
|
try:
|
|
file_pat, errors = path.split(":")
|
|
prev_file_pat = file_pat
|
|
except ValueError:
|
|
# It's legal to just specify another error type for the same
|
|
# file pattern:
|
|
if prev_file_pat is None:
|
|
log.warning("skipping a Per-file-ignore with no file pattern")
|
|
continue
|
|
file_pat = prev_file_pat
|
|
errors = path
|
|
if PurePath(document.path).match(file_pat):
|
|
ignores.extend(errors.split(","))
|
|
|
|
opts = {
|
|
"config": settings.get("config"),
|
|
"exclude": settings.get("exclude"),
|
|
"extend-ignore": settings.get("extendIgnore"),
|
|
"extend-select": settings.get("extendSelect"),
|
|
"filename": settings.get("filename"),
|
|
"hang-closing": settings.get("hangClosing"),
|
|
"ignore": ignores or None,
|
|
"max-complexity": settings.get("maxComplexity"),
|
|
"max-line-length": settings.get("maxLineLength"),
|
|
"indent-size": settings.get("indentSize"),
|
|
"select": settings.get("select"),
|
|
}
|
|
|
|
# flake takes only absolute path to the config. So we should check and
|
|
# convert if necessary
|
|
if opts.get("config") and not os.path.isabs(opts.get("config")):
|
|
opts["config"] = os.path.abspath(
|
|
os.path.expanduser(os.path.expandvars(opts.get("config")))
|
|
)
|
|
log.debug("using flake8 with config: %s", opts["config"])
|
|
|
|
# Call the flake8 utility then parse diagnostics from stdout
|
|
flake8_executable = settings.get("executable", "flake8")
|
|
|
|
args = build_args(opts)
|
|
|
|
# ensure the same source is used for flake8 execution and result parsing;
|
|
# single source access improves performance as it is only one disk access
|
|
source = document.source
|
|
output = run_flake8(flake8_executable, args, document, source)
|
|
return parse_stdout(source, output)
|
|
|
|
|
|
def run_flake8(flake8_executable, args, document, source):
|
|
"""Run flake8 with the provided arguments, logs errors
|
|
from stderr if any.
|
|
"""
|
|
# a quick temporary fix to deal with Atom
|
|
args = [
|
|
(i if not i.startswith("--ignore=") else FIX_IGNORES_RE.sub("", i))
|
|
for i in args
|
|
if i is not None
|
|
]
|
|
|
|
if document.path and document.path.startswith(document._workspace.root_path):
|
|
args.extend(
|
|
[
|
|
"--stdin-display-name",
|
|
os.path.relpath(document.path, document._workspace.root_path),
|
|
]
|
|
)
|
|
|
|
# if executable looks like a path resolve it
|
|
if not os.path.isfile(flake8_executable) and os.sep in flake8_executable:
|
|
flake8_executable = os.path.abspath(
|
|
os.path.expanduser(os.path.expandvars(flake8_executable))
|
|
)
|
|
|
|
log.debug("Calling %s with args: '%s'", flake8_executable, args)
|
|
popen_kwargs = {"creationflags": CREATE_NO_WINDOW}
|
|
if cwd := document._workspace.root_path:
|
|
popen_kwargs["cwd"] = cwd
|
|
try:
|
|
cmd = [flake8_executable]
|
|
cmd.extend(args)
|
|
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, **popen_kwargs)
|
|
except OSError:
|
|
log.debug(
|
|
"Can't execute %s. Trying with '%s -m flake8'",
|
|
flake8_executable,
|
|
sys.executable,
|
|
)
|
|
cmd = [sys.executable, "-m", "flake8"]
|
|
cmd.extend(args)
|
|
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, **popen_kwargs)
|
|
(stdout, stderr) = p.communicate(source.encode())
|
|
if stderr:
|
|
log.error("Error while running flake8 '%s'", stderr.decode())
|
|
return stdout.decode()
|
|
|
|
|
|
def build_args(options):
|
|
"""Build arguments for calling flake8.
|
|
|
|
Args:
|
|
options: dictionary of argument names and their values.
|
|
"""
|
|
args = ["-"] # use stdin
|
|
for arg_name, arg_val in options.items():
|
|
if arg_val is None:
|
|
continue
|
|
arg = None
|
|
if isinstance(arg_val, list):
|
|
arg = "--{}={}".format(arg_name, ",".join(arg_val))
|
|
elif isinstance(arg_val, bool):
|
|
if arg_val:
|
|
arg = f"--{arg_name}"
|
|
else:
|
|
arg = f"--{arg_name}={arg_val}"
|
|
args.append(arg)
|
|
return args
|
|
|
|
|
|
def parse_stdout(source, stdout):
|
|
"""
|
|
Build a diagnostics from flake8's output, it should extract every result and format
|
|
it into a dict that looks like this:
|
|
{
|
|
'source': 'flake8',
|
|
'code': code, # 'E501'
|
|
'range': {
|
|
'start': {
|
|
'line': start_line,
|
|
'character': start_column,
|
|
},
|
|
'end': {
|
|
'line': end_line,
|
|
'character': end_column,
|
|
},
|
|
},
|
|
'message': msg,
|
|
'severity': lsp.DiagnosticSeverity.*,
|
|
}
|
|
|
|
Args:
|
|
document: The document to be linted.
|
|
stdout: output from flake8
|
|
Returns:
|
|
A list of dictionaries.
|
|
"""
|
|
|
|
document_lines = source.splitlines(True)
|
|
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("Flake8 output parser can't parse line '%s'", raw_line)
|
|
continue
|
|
|
|
parsed_line = parsed_line.groups()
|
|
if len(parsed_line) != 5:
|
|
log.debug("Flake8 output parser can't parse line '%s'", raw_line)
|
|
continue
|
|
|
|
_, line, character, code, msg = parsed_line
|
|
line = int(line) - 1
|
|
character = int(character) - 1
|
|
# show also the code in message
|
|
msg = code + " " + msg
|
|
severity = lsp.DiagnosticSeverity.Warning
|
|
if code in ERROR_CODES:
|
|
severity = lsp.DiagnosticSeverity.Error
|
|
diagnostic = {
|
|
"source": "flake8",
|
|
"code": code,
|
|
"range": {
|
|
"start": {"line": line, "character": character},
|
|
"end": {
|
|
"line": line,
|
|
# no way to determine the column
|
|
"character": len(document_lines[line]),
|
|
},
|
|
},
|
|
"message": msg,
|
|
"severity": severity,
|
|
}
|
|
if code in UNNECESSITY_CODES:
|
|
diagnostic["tags"] = [lsp.DiagnosticTag.Unnecessary]
|
|
diagnostics.append(diagnostic)
|
|
|
|
return diagnostics
|