fune/tools/lint/python/ruff.py
Andrew Halberstadt 8a4d48a70d Bug 1811850 - [lint] Replace flake8 linter with ruff, r=linter-reviewers,sylvestre
Ruff is a very fast linter implemented in Rust and it can act as a drop-in
replacement for flake8. When running the same set of rules across all files
in mozilla-central (without mozlint), flake8 takes 900 seconds whereas ruff
takes 0.9 seconds.

Ruff also implements rules from other popular Python linters such as pylint,
isort and pyupgrade. There are even plans to implement feature parity with
black in the future. Ultimately, it can become our one stop shop for all Python
linting and formatting.

This stack will swap out all our Python lint tools for ruff (excluding black
for now).

Differential Revision: https://phabricator.services.mozilla.com/D172313
2023-03-17 01:53:58 +00:00

177 lines
5.3 KiB
Python

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import json
import os
import platform
import re
import signal
import subprocess
import sys
from pathlib import Path
import mozfile
from mozlint import result
from mozprocess.processhandler import ProcessHandler
here = os.path.abspath(os.path.dirname(__file__))
RUFF_REQUIREMENTS_PATH = os.path.join(here, "ruff_requirements.txt")
RUFF_NOT_FOUND = """
Could not find ruff! Install ruff and try again.
$ pip install -U --require-hashes -r {}
""".strip().format(
RUFF_REQUIREMENTS_PATH
)
RUFF_INSTALL_ERROR = """
Unable to install correct version of ruff!
Try to install it manually with:
$ pip install -U --require-hashes -r {}
""".strip().format(
RUFF_REQUIREMENTS_PATH
)
def default_bindir():
# We use sys.prefix to find executables as that gets modified with
# virtualenv's activate_this.py, whereas sys.executable doesn't.
if platform.system() == "Windows":
return os.path.join(sys.prefix, "Scripts")
else:
return os.path.join(sys.prefix, "bin")
def get_ruff_version(binary):
"""
Returns found binary's version
"""
try:
output = subprocess.check_output(
[binary, "--version"],
stderr=subprocess.STDOUT,
text=True,
)
except subprocess.CalledProcessError as e:
output = e.output
matches = re.match(r"ruff ([0-9\.]+)", output)
if matches:
return matches[1]
print("Error: Could not parse the version '{}'".format(output))
def setup(root, log, **lintargs):
virtualenv_bin_path = lintargs.get("virtualenv_bin_path")
binary = mozfile.which("ruff", path=(virtualenv_bin_path, default_bindir()))
if binary and os.path.isfile(binary):
log.debug(f"Looking for ruff at {binary}")
version = get_ruff_version(binary)
versions = [
line.split()[0].strip()
for line in open(RUFF_REQUIREMENTS_PATH).readlines()
if line.startswith("ruff==")
]
if [f"ruff=={version}"] == versions:
log.debug("ruff is present with expected version {}".format(version))
return 0
else:
log.debug("ruff is present but unexpected version {}".format(version))
virtualenv_manager = lintargs["virtualenv_manager"]
try:
virtualenv_manager.install_pip_requirements(RUFF_REQUIREMENTS_PATH, quiet=True)
except subprocess.CalledProcessError:
print(RUFF_INSTALL_ERROR)
return 1
class RuffProcess(ProcessHandler):
def __init__(self, config, *args, **kwargs):
self.config = config
self.stderr = []
kwargs["stream"] = False
kwargs["universal_newlines"] = True
kwargs["processStderrLine"] = lambda line: print(line, file=sys.stderr)
ProcessHandler.__init__(self, *args, **kwargs)
def run(self, *args, **kwargs):
orig = signal.signal(signal.SIGINT, signal.SIG_IGN)
ProcessHandler.run(self, *args, **kwargs)
signal.signal(signal.SIGINT, orig)
def run_process(config, cmd):
proc = RuffProcess(config, cmd)
proc.run()
try:
proc.wait()
except KeyboardInterrupt:
proc.kill()
return "\n".join(proc.output)
def lint(paths, config, log, **lintargs):
fixed = 0
results = []
if not paths:
return {"results": results, "fixed": fixed}
# Currently ruff only lints non `.py` files if they are explicitly passed
# in. So we need to find any non-py files manually. This can be removed
# after https://github.com/charliermarsh/ruff/issues/3410 is fixed.
exts = [e for e in config["extensions"] if e != "py"]
non_py_files = []
for path in paths:
p = Path(path)
if not p.is_dir():
continue
for ext in exts:
non_py_files.extend([str(f) for f in p.glob(f"**/*.{ext}")])
args = ["ruff", "check", "--force-exclude"] + paths + non_py_files
if config["exclude"]:
args.append(f"--extend-exclude={','.join(config['exclude'])}")
if lintargs.get("fix"):
# Do a first pass with --fix-only as the json format doesn't return the
# number of fixed issues.
output = run_process(config, args + ["--fix-only"])
matches = re.match(r"Fixed (\d+) errors?.", output)
if matches:
fixed = int(matches[1])
output = run_process(config, args + ["--format=json"])
if not output:
return []
try:
issues = json.loads(output)
except json.JSONDecodeError:
log.error(f"could not parse output: {output}")
return []
warning_rules = set(config.get("warning-rules", []))
for issue in issues:
res = {
"path": issue["filename"],
"lineno": issue["location"]["row"],
"column": issue["location"]["column"],
"lineoffset": issue["end_location"]["row"] - issue["location"]["row"],
"message": issue["message"],
"rule": issue["code"],
"level": "warning" if issue["code"] in warning_rules else "error",
}
if issue["fix"]:
res["hint"] = issue["fix"]["message"]
results.append(result.from_config(config, **res))
return {"results": results, "fixed": fixed}