blob: 34686758309d21089de0685cf14b963f422a4685 [file] [edit]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Formats Python files with autoflake, isort, and black.
Writes a JSON object to the --output file that maps each path whose contents
changed to its formatted contents.
The tools make conflicting style changes, so each file must go through them in
order. Running the tools once per file is slow when checking thousands of files,
so instead this script copies the files into a temporary directory and runs
each tool once over all the copies.
"""
import argparse
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
from collections.abc import Sequence
from importlib import metadata
from pathlib import Path
def _run(cmd: list[str], copy_root: Path) -> None:
# Run in copy_root with relative paths so that error messages refer to the
# original paths.
res = subprocess.run(
cmd,
cwd=copy_root,
# Black caches results by absolute path, so give it a throwaway cache
# rather than filling the user's cache with temporary paths.
env={**os.environ, "BLACK_CACHE_DIR": str(copy_root / ".black-cache")},
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if res.returncode != 0:
sys.stderr.write(res.stdout)
sys.exit(res.returncode)
def _autoflake(
args: argparse.Namespace, files: Sequence[str], copy_root: Path
) -> None:
jobs = min(len(files), os.cpu_count() or 1)
_run(
[
args.python,
args.autoflake,
f"--jobs={jobs}",
"--in-place",
"--remove-unused-variables",
"--remove-all-unused-imports",
"--remove-duplicate-keys",
"--ignore-init-module-imports",
*files,
],
copy_root,
)
def _isort(files: Sequence[str], copy_root: Path) -> None:
import isort
configs: dict[str, isort.Config] = {}
for f in files:
# isort's config and its classification of first-party imports depend
# on where a file lives, so sort each copy as if it were the original
# file, like `isort --filename <f> -` does.
directory = os.path.dirname(f)
if directory not in configs:
# Overriding isort's default skip list keeps it from skipping files
# under directories with common names like "build".
configs[directory] = isort.Config(
settings_path=directory, skip=[".venvs"]
)
copy = copy_root / f
try:
with open(copy, encoding="utf-8", newline="") as file:
original = file.read()
# autoflake strips byte order marks, and isort treats them as code.
content = original.removeprefix("\ufeff")
output = io.StringIO()
try:
isort.api.sort_stream(
io.StringIO(content),
output,
config=configs[directory],
file_path=Path(f),
# Sort imports above any `isort: skip_file` comment, like
# the CLI.
raise_on_skip=False,
)
except isort.exceptions.FileSkipped:
# The file matches the config's skip settings.
continue
except Exception as e:
raise RuntimeError(f"isort failed on {f}") from e
if output.getvalue() != original:
with open(copy, "w", encoding="utf-8", newline="") as file:
file.write(output.getvalue())
def _black(
args: argparse.Namespace, files: Sequence[str], copy_root: Path
) -> None:
_run(
[
args.black,
"--config",
args.pyproject_toml,
"--quiet",
*files,
],
copy_root,
)
def _relative_path(path: str) -> str:
# Absolute paths or paths containing ".." would escape the temporary copy
# directory, so the tools would modify the original files.
if os.path.isabs(path) or ".." in Path(path).parts:
raise argparse.ArgumentTypeError(f"must be a relative path: {path}")
return path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--python", required=True, type=os.path.abspath)
parser.add_argument("--autoflake", required=True, type=os.path.abspath)
parser.add_argument("--isort", required=True, type=os.path.abspath)
parser.add_argument("--black", required=True, type=os.path.abspath)
parser.add_argument(
"--pyproject-toml",
required=True,
type=os.path.abspath,
help="black's config file",
)
parser.add_argument("--output", required=True, help="JSON output file")
parser.add_argument("files", nargs="+", type=_relative_path)
args = parser.parse_args()
# Run isort from source like third_party/pylibs/isort/main.py.
# TODO(b/353376647): Remove the metadata workaround along with the one in
# third_party/pylibs/isort/main.py.
sys.path.append(str(Path(args.isort).parent / "src"))
try:
metadata.version("isort")
except metadata.PackageNotFoundError:
metadata.version = lambda _: "unknown" # type: ignore[assignment]
with tempfile.TemporaryDirectory() as tmp_dir:
copy_root = Path(tmp_dir)
for f in args.files:
(copy_root / f).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(f, copy_root / f)
_autoflake(args, args.files, copy_root)
_isort(args.files, copy_root)
_black(args, args.files, copy_root)
formatted = {}
for f in args.files:
content = (copy_root / f).read_bytes()
if content != Path(f).read_bytes():
formatted[f] = content.decode("utf-8")
with open(args.output, "w", encoding="utf-8") as output:
json.dump(formatted, output, ensure_ascii=False)
if __name__ == "__main__":
main()