commit 8e2f1e924bfe4773f742c1b572b35bf2141028f9
parent 857218c417175fc24622793ce599bd2b6a11bf19
Author: Alex Hochheiden <ahochheiden@mozilla.com>
Date: Wed, 7 Jan 2026 17:17:28 +0000
Bug 2006716 - Add `ruff-format` linter r=ahal,linter-reviewers
Differential Revision: https://phabricator.services.mozilla.com/D276897
Diffstat:
10 files changed, 211 insertions(+), 7 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
@@ -1,5 +1,5 @@
[tool.ruff]
-line-length = 99
+line-length = 88
target-version = "py39"
# See https://beta.ruff.rs/docs/rules/ for a full list of rules.
lint.select = [
@@ -21,7 +21,7 @@ lint.ignore = [
"PLC0415", # `import` should be at the top-level of a file
# These are handled by ruff format.
- "E1", "E4", "E5", "W2", "W5",
+ "E1", "E4", "E5", "W1", "W2", "W5",
]
builtins = ["gdb"]
extend-include = ["*.configure"]
@@ -133,3 +133,8 @@ exclude = [
"python/mozbuild/mozbuild/util.py" = ["F821"]
"testing/mozharness/mozharness/mozilla/testing/android.py" = ["F821"]
"testing/mochitest/runtests.py" = ["F821"]
+
+[tool.ruff.format]
+# Enable preview mode for --output-format=json to get improved line/column reporting.
+# TODO: Remove preview once these features are in the stable release.
+preview = true
diff --git a/taskcluster/kinds/source-test/mozlint.yml b/taskcluster/kinds/source-test/mozlint.yml
@@ -398,6 +398,23 @@ mscom-init:
- '**/*.rs'
- 'tools/lint/mscom-init.yml'
+py-ruff-format:
+ description: ruff format run over the gecko codebase
+ treeherder:
+ symbol: py(ruff-format)
+ run:
+ mach: lint -v -l ruff-format -f treeherder -f json:/builds/worker/mozlint.json .
+ when:
+ files-changed:
+ # The list of extensions should match tools/lint/ruff-format.yml
+ - '**/*.py'
+ - '**/moz.build'
+ - '**/*.configure'
+ - '**/*.mozbuild'
+ - 'pyproject.toml'
+ - 'tools/lint/ruff-format.yml'
+ - 'tools/lint/python/ruff.py'
+
py-ruff:
description: Run ruff over the gecko codebase
treeherder:
diff --git a/tools/lint/mach_commands.py b/tools/lint/mach_commands.py
@@ -25,7 +25,7 @@ if os.path.exists(thunderbird_excludes):
GLOBAL_EXCLUDES = ["**/node_modules", "tools/lint/test/files", ".hg", ".git"]
-VALID_FORMATTERS = {"clang-format", "eslint", "rustfmt", "stylelint"}
+VALID_FORMATTERS = {"ruff-format", "clang-format", "eslint", "rustfmt", "stylelint"}
VALID_ANDROID_FORMATTERS = {"android-format"}
# Code-review bot must index issues from the whole codebase when pushing
diff --git a/tools/lint/python/ruff.py b/tools/lint/python/ruff.py
@@ -9,7 +9,9 @@ import re
import signal
import subprocess
import sys
+from pathlib import Path
+import toml
from mozlint import result
here = os.path.abspath(os.path.dirname(__file__))
@@ -24,6 +26,16 @@ def default_bindir():
return os.path.join(sys.prefix, "bin")
+def get_pyproject_excludes(pyproject_toml: Path):
+ if not pyproject_toml.exists():
+ return []
+
+ with pyproject_toml.open() as f:
+ data = toml.load(f)
+
+ return data.get("tool", {}).get("ruff", {}).get("exclude", [])
+
+
def get_ruff_version(binary):
"""
Returns found binary's version
@@ -57,8 +69,9 @@ def run_process(cmd, log):
log.debug(line)
except KeyboardInterrupt:
proc.kill()
+ return "", -1
- return stdout
+ return stdout, proc.returncode
def lint(paths, config, log, **lintargs):
@@ -84,16 +97,26 @@ def lint(paths, config, log, **lintargs):
fix_args.append(f"--extend-ignore={','.join(warning_rules)}")
log.debug(f"Running --fix: {fix_args}")
- output = run_process(fix_args, log)
+ output, returncode = run_process(fix_args, log)
+ if returncode == 2:
+ log.error(
+ f"ruff terminated abnormally (invalid config, CLI options, or internal error): {output}"
+ )
+ return {"results": [], "fixed": 0}
matches = re.match(r"Fixed (\d+) errors?.", output)
if matches:
fixed = int(matches[1])
args += ["--output-format=json"]
log.debug(f"Running with args: {args}")
- output = run_process(args, log)
+ output, returncode = run_process(args, log)
+ if returncode == 2:
+ log.error(
+ f"ruff terminated abnormally (invalid config, CLI options, or internal error): {output}"
+ )
+ return {"results": [], "fixed": fixed}
if not output:
- return []
+ return {"results": [], "fixed": fixed}
try:
issues = json.loads(output)
@@ -122,3 +145,72 @@ def lint(paths, config, log, **lintargs):
results.append(result.from_config(config, **res))
return {"results": results, "fixed": fixed}
+
+
+def format(paths, config, log, **lintargs):
+ """Run ruff format to check/fix Python formatting."""
+ fixed = 0
+ results = []
+
+ if not paths:
+ return {"results": results, "fixed": fixed}
+
+ args = ["ruff", "format", "--force-exclude"] + paths
+
+ log.debug(f"Ruff version {get_ruff_version('ruff')}")
+
+ topsrcdir = Path(lintargs["root"])
+ pyproject_toml = topsrcdir / "pyproject.toml"
+ exclude_patterns = get_pyproject_excludes(pyproject_toml)
+
+ # Merge pyproject.toml excludes with config excludes.
+ # This is needed because ruff format's --exclude replaces rather than extends
+ # the pyproject.toml excludes (unlike ruff check which has --extend-exclude).
+ if config.get("exclude"):
+ exclude_patterns.extend(config["exclude"])
+
+ for exclude in exclude_patterns:
+ args.extend(["--exclude", exclude])
+
+ if lintargs.get("fix"):
+ # Do a first pass to fix, as JSON output doesn't include fix counts
+ log.debug(f"Running --fix: {args}")
+ output, returncode = run_process(args, log)
+ if returncode == 2:
+ log.error(
+ f"ruff terminated abnormally (invalid config, CLI options, or internal error): {output}"
+ )
+ return {"results": [], "fixed": 0}
+ match = re.search(r"(\d+) files? reformatted", output)
+ if match:
+ fixed = int(match.group(1))
+
+ args += ["--check", "--output-format=json"]
+ log.debug(f"Running with args: {args}")
+
+ output, returncode = run_process(args, log)
+ if returncode == 2 and not output:
+ log.error(
+ "ruff format terminated abnormally (invalid config, CLI options, or internal error)"
+ )
+ return {"results": [], "fixed": fixed}
+ if not output:
+ return {"results": [], "fixed": fixed}
+
+ try:
+ issues = json.loads(output)
+ except json.JSONDecodeError:
+ log.error(f"could not parse output: {output}")
+ return {"results": [], "fixed": fixed}
+
+ for issue in issues:
+ res = {
+ "path": issue["filename"],
+ "lineno": issue["location"]["row"],
+ "column": issue["location"]["column"],
+ "message": issue["message"],
+ "level": "error",
+ }
+ results.append(result.from_config(config, **res))
+
+ return {"results": results, "fixed": fixed}
diff --git a/tools/lint/ruff-format.yml b/tools/lint/ruff-format.yml
@@ -0,0 +1,20 @@
+---
+ruff-format:
+ description: Reformat python with ruff
+ # Excludes should be added to topsrcdir/pyproject.toml
+ exclude:
+ - gfx/harfbuzz/src/meson.build
+ - '**/*.mako.py'
+ - python/mozbuild/mozbuild/test/frontend/data/reader-error-syntax/moz.build
+ - testing/mozharness/configs/test/test_malformed.py
+ - testing/web-platform/tests
+ extensions:
+ - build
+ - configure
+ - mozbuild
+ - py
+ support-files:
+ - 'tools/lint/python/**'
+ - '**/pyproject.toml'
+ type: external
+ payload: python.ruff:format
diff --git a/tools/lint/test/files/ruff-format/bad.py b/tools/lint/test/files/ruff-format/bad.py
@@ -0,0 +1,6 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+print (
+ "test"
+ )
diff --git a/tools/lint/test/files/ruff-format/good.py b/tools/lint/test/files/ruff-format/good.py
@@ -0,0 +1,6 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+
+def hello():
+ print("world")
diff --git a/tools/lint/test/files/ruff-format/invalid.py b/tools/lint/test/files/ruff-format/invalid.py
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+print(
diff --git a/tools/lint/test/python.toml b/tools/lint/test/python.toml
@@ -60,6 +60,8 @@ skip-if = ["os == 'win'"]
["test_ruff.py"]
+["test_ruff_format.py"]
+
["test_rustfmt.py"]
["test_shellcheck.py"]
diff --git a/tools/lint/test/test_ruff_format.py b/tools/lint/test/test_ruff_format.py
@@ -0,0 +1,52 @@
+# 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 mozunit
+
+LINTER = "ruff-format"
+fixed = 0
+
+
+def test_lint_fix(lint, create_temp_file):
+ contents = """def is_unique(
+ s
+ ):
+ s = list(s
+ )
+ s.sort()
+
+
+ for i in range(len(s) - 1):
+ if s[i] == s[i + 1]:
+ return 0
+ else:
+ return 1
+
+
+if __name__ == "__main__":
+ print(
+ is_unique(input())
+ ) """
+
+ path = create_temp_file(contents, "bad.py")
+ lint([path], fix=True)
+ assert fixed == 1
+
+
+def test_lint_ruff_format(lint, paths):
+ results = lint(paths())
+ assert len(results) == 2
+
+ assert results[0].level == "error"
+ assert results[0].relpath.endswith("bad.py")
+ assert results[0].lineno == 4
+ assert results[0].column == 6
+
+ assert "EOF" in results[1].message
+ assert results[1].level == "error"
+ assert results[1].relpath.endswith("invalid.py")
+
+
+if __name__ == "__main__":
+ mozunit.main()