mirror of
https://github.com/aphrody-labs/ruff.git
synced 2026-10-15 06:39:10 +00:00
Summary -- Now that we allow rule names in selectors in preview, we need to normalize them back to codes to allow running on stable. This PR implements this normalization as one of our config overrides, using the comparison executable to extract the rule mapping from `ruff rule`. Test Plan -- CI on this PR and then #27154
313 lines
9.9 KiB
Python
313 lines
9.9 KiB
Python
"""
|
|
Execution, comparison, and summary of `ruff format` ecosystem checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from asyncio import create_subprocess_exec
|
|
from collections.abc import Sequence
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from subprocess import PIPE
|
|
from typing import TYPE_CHECKING
|
|
|
|
from unidiff import PatchSet
|
|
|
|
from ruff_ecosystem import logger
|
|
from ruff_ecosystem.markdown import markdown_project_section
|
|
from ruff_ecosystem.projects import rule_name_to_code
|
|
from ruff_ecosystem.types import Comparison, Diff, Result, ToolError
|
|
|
|
if TYPE_CHECKING:
|
|
from ruff_ecosystem.projects import ClonedRepository, ConfigOverrides, FormatOptions
|
|
|
|
|
|
def markdown_format_result(result: Result) -> str:
|
|
"""
|
|
Render a `ruff format` ecosystem check result as markdown.
|
|
"""
|
|
lines: list[str] = []
|
|
total_lines_removed = total_lines_added = 0
|
|
total_files_modified = 0
|
|
projects_with_changes = 0
|
|
error_count = len(result.errored)
|
|
patch_sets: list[PatchSet] = []
|
|
|
|
for project, comparison in result.completed:
|
|
total_lines_added += comparison.diff.lines_added
|
|
total_lines_removed += comparison.diff.lines_removed
|
|
|
|
patch_set = PatchSet("\n".join(comparison.diff.lines))
|
|
patch_sets.append(patch_set)
|
|
total_files_modified += len(patch_set.modified_files)
|
|
|
|
if comparison.diff:
|
|
projects_with_changes += 1
|
|
|
|
if total_lines_removed == 0 and total_lines_added == 0 and error_count == 0:
|
|
return "\u2705 ecosystem check detected no format changes."
|
|
|
|
# Summarize the total changes
|
|
if total_lines_added == 0 and total_lines_added == 0:
|
|
# Only errors
|
|
s = "s" if error_count != 1 else ""
|
|
lines.append(
|
|
f"\u2139\ufe0f ecosystem check **encountered format errors**. (no format changes; {error_count} project error{s})"
|
|
)
|
|
else:
|
|
s = "s" if total_files_modified != 1 else ""
|
|
changes = (
|
|
f"+{total_lines_added} -{total_lines_removed} lines "
|
|
f"in {total_files_modified} file{s} in "
|
|
f"{projects_with_changes} projects"
|
|
)
|
|
|
|
if error_count:
|
|
s = "s" if error_count != 1 else ""
|
|
changes += f"; {error_count} project error{s}"
|
|
|
|
unchanged_projects = len(result.completed) - projects_with_changes
|
|
if unchanged_projects:
|
|
s = "s" if unchanged_projects != 1 else ""
|
|
changes += f"; {unchanged_projects} project{s} unchanged"
|
|
|
|
lines.append(
|
|
f"\u2139\ufe0f ecosystem check **detected format changes**. ({changes})"
|
|
)
|
|
|
|
lines.append("")
|
|
|
|
# Then per-project changes
|
|
for (project, comparison), patch_set in zip(result.completed, patch_sets):
|
|
if not comparison.diff:
|
|
continue # Skip empty diffs
|
|
|
|
files = len(patch_set.modified_files)
|
|
s = "s" if files != 1 else ""
|
|
title = f"+{comparison.diff.lines_added} -{comparison.diff.lines_removed} lines across {files} file{s}"
|
|
|
|
lines.extend(
|
|
markdown_project_section(
|
|
title=title,
|
|
content=format_patchset(patch_set, comparison.repo),
|
|
options=project.format_options,
|
|
project=project,
|
|
)
|
|
)
|
|
|
|
for project, error in result.errored:
|
|
lines.extend(
|
|
markdown_project_section(
|
|
title="error",
|
|
content=f"```\n{str(error).strip()}\n```",
|
|
options=project.format_options,
|
|
project=project,
|
|
)
|
|
)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_patchset(patch_set: PatchSet, repo: ClonedRepository) -> str:
|
|
"""
|
|
Convert a patchset to markdown, adding permalinks to the start of each hunk.
|
|
"""
|
|
lines: list[str] = []
|
|
for file_patch in patch_set:
|
|
for hunk in file_patch:
|
|
# Note: When used for `format` checks, the line number is not exact because
|
|
# we formatted the repository for a baseline; we can't know the exact
|
|
# line number in the original
|
|
# source file.
|
|
hunk_link = repo.url_for(file_patch.path, hunk.source_start)
|
|
hunk_lines = str(hunk).splitlines()
|
|
|
|
# Add a link before the hunk
|
|
link_title = file_patch.path + "~L" + str(hunk.source_start)
|
|
lines.append(f"<a href='{hunk_link}'>{link_title}</a>")
|
|
|
|
# Wrap the contents of the hunk in a diff code block
|
|
lines.append("```diff")
|
|
lines.extend(hunk_lines[1:])
|
|
lines.append("```")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
async def compare_format(
|
|
ruff_baseline_executable: Path,
|
|
ruff_comparison_executable: Path,
|
|
options: FormatOptions,
|
|
config_overrides: ConfigOverrides,
|
|
cloned_repo: ClonedRepository,
|
|
format_comparison: FormatComparison,
|
|
):
|
|
args = (
|
|
ruff_baseline_executable,
|
|
ruff_comparison_executable,
|
|
options,
|
|
config_overrides,
|
|
cloned_repo,
|
|
)
|
|
match format_comparison:
|
|
case FormatComparison.ruff_then_ruff:
|
|
coro = format_then_format(Formatter.ruff, *args)
|
|
case FormatComparison.ruff_and_ruff:
|
|
coro = format_and_format(Formatter.ruff, *args)
|
|
case FormatComparison.black_then_ruff:
|
|
coro = format_then_format(Formatter.black, *args)
|
|
case FormatComparison.black_and_ruff:
|
|
coro = format_and_format(Formatter.black, *args)
|
|
case _:
|
|
raise ValueError(f"Unknown format comparison type {format_comparison!r}.")
|
|
|
|
diff = await coro
|
|
return Comparison(diff=Diff(diff), repo=cloned_repo)
|
|
|
|
|
|
async def format_then_format(
|
|
baseline_formatter: Formatter,
|
|
ruff_baseline_executable: Path,
|
|
ruff_comparison_executable: Path,
|
|
options: FormatOptions,
|
|
config_overrides: ConfigOverrides,
|
|
cloned_repo: ClonedRepository,
|
|
) -> Sequence[str]:
|
|
# TODO(brent) Remove this workaround when human-readable rule names are stabilized.
|
|
rule_names = (
|
|
rule_name_to_code(ruff_comparison_executable.resolve())
|
|
if not options.preview
|
|
else {}
|
|
)
|
|
with config_overrides.patch_config(cloned_repo.path, options.preview, rule_names):
|
|
# Run format to get the baseline
|
|
await format(
|
|
formatter=baseline_formatter,
|
|
executable=ruff_baseline_executable.resolve(),
|
|
path=cloned_repo.path,
|
|
name=cloned_repo.fullname,
|
|
options=options,
|
|
)
|
|
# Then get the diff from stdout
|
|
return await format(
|
|
formatter=Formatter.ruff,
|
|
executable=ruff_comparison_executable.resolve(),
|
|
path=cloned_repo.path,
|
|
name=cloned_repo.fullname,
|
|
options=options,
|
|
diff=True,
|
|
)
|
|
|
|
|
|
async def format_and_format(
|
|
baseline_formatter: Formatter,
|
|
ruff_baseline_executable: Path,
|
|
ruff_comparison_executable: Path,
|
|
options: FormatOptions,
|
|
config_overrides: ConfigOverrides,
|
|
cloned_repo: ClonedRepository,
|
|
) -> Sequence[str]:
|
|
# TODO(brent) Remove this workaround when human-readable rule names are stabilized.
|
|
rule_names = (
|
|
rule_name_to_code(ruff_comparison_executable.resolve())
|
|
if not options.preview
|
|
else {}
|
|
)
|
|
with config_overrides.patch_config(cloned_repo.path, options.preview, rule_names):
|
|
# Run format without diff to get the baseline
|
|
await format(
|
|
formatter=baseline_formatter,
|
|
executable=ruff_baseline_executable.resolve(),
|
|
path=cloned_repo.path,
|
|
name=cloned_repo.fullname,
|
|
options=options,
|
|
)
|
|
|
|
# Commit the changes
|
|
commit = await cloned_repo.commit(
|
|
message=f"Formatted with baseline {ruff_baseline_executable}"
|
|
)
|
|
# Then reset
|
|
await cloned_repo.reset()
|
|
|
|
with config_overrides.patch_config(cloned_repo.path, options.preview, rule_names):
|
|
# Then run format again
|
|
await format(
|
|
formatter=Formatter.ruff,
|
|
executable=ruff_comparison_executable.resolve(),
|
|
path=cloned_repo.path,
|
|
name=cloned_repo.fullname,
|
|
options=options,
|
|
)
|
|
|
|
# Then get the diff from the commit
|
|
return await cloned_repo.diff(commit)
|
|
|
|
|
|
async def format(
|
|
*,
|
|
formatter: Formatter,
|
|
executable: Path,
|
|
path: Path,
|
|
name: str,
|
|
options: FormatOptions,
|
|
diff: bool = False,
|
|
) -> Sequence[str]:
|
|
"""Run the given ruff binary against the specified path."""
|
|
args = (
|
|
options.to_ruff_args()
|
|
if formatter == Formatter.ruff
|
|
else options.to_black_args()
|
|
)
|
|
logger.debug(f"Formatting {name} with {executable} " + " ".join(args))
|
|
|
|
if diff:
|
|
args.append("--diff")
|
|
|
|
start = time.time()
|
|
proc = await create_subprocess_exec(
|
|
executable.absolute(),
|
|
*args,
|
|
".",
|
|
stdout=PIPE,
|
|
stderr=PIPE,
|
|
cwd=path,
|
|
)
|
|
result, err = await proc.communicate()
|
|
end = time.time()
|
|
|
|
logger.debug(f"Finished formatting {name} with {executable} in {end - start:.2f}s")
|
|
|
|
if proc.returncode not in [0, 1]:
|
|
raise ToolError(err.decode("utf8"))
|
|
|
|
return result.decode("utf8").splitlines()
|
|
|
|
|
|
class FormatComparison(Enum):
|
|
ruff_then_ruff = "ruff-then-ruff"
|
|
"""
|
|
Run Ruff baseline then Ruff comparison; checks for changes in behavior when formatting previously "formatted" code
|
|
"""
|
|
|
|
ruff_and_ruff = "ruff-and-ruff"
|
|
"""
|
|
Run Ruff baseline then reset and run Ruff comparison; checks changes in behavior when formatting "unformatted" code
|
|
"""
|
|
|
|
black_then_ruff = "black-then-ruff"
|
|
"""
|
|
Run Black baseline then Ruff comparison; checks for changes in behavior when formatting previously "formatted" code
|
|
"""
|
|
|
|
black_and_ruff = "black-and-ruff"
|
|
""""
|
|
Run Black baseline then reset and run Ruff comparison; checks changes in behavior when formatting "unformatted" code
|
|
"""
|
|
|
|
|
|
class Formatter(Enum):
|
|
black = "black"
|
|
ruff = "ruff"
|