mirror of
https://github.com/astral-sh/ruff.git
synced 2025-07-12 23:55:08 +00:00

Adds the ability to override `ruff.toml` or `pyproject.toml` settings per-project during ecosystem checks. Exploring this as a fix for the `setuptools` project error. Also useful for including Jupyter Notebooks in the ecosystem checks, see #9293 Note the remaining `sphinx` project error is resolved in #9294
302 lines
9.3 KiB
Python
302 lines
9.3 KiB
Python
"""
|
|
Execution, comparison, and summary of `ruff format` ecosystem checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from asyncio import create_subprocess_exec
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from subprocess import PIPE
|
|
from typing import TYPE_CHECKING, Sequence
|
|
|
|
from unidiff import PatchSet
|
|
|
|
from ruff_ecosystem import logger
|
|
from ruff_ecosystem.markdown import markdown_project_section
|
|
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]:
|
|
with config_overrides.patch_config(cloned_repo.path, options.preview):
|
|
# 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
|
|
diff = await format(
|
|
formatter=Formatter.ruff,
|
|
executable=ruff_comparison_executable.resolve(),
|
|
path=cloned_repo.path,
|
|
name=cloned_repo.fullname,
|
|
options=options,
|
|
diff=True,
|
|
)
|
|
return diff
|
|
|
|
|
|
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]:
|
|
with config_overrides.patch_config(cloned_repo.path, options.preview):
|
|
# 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):
|
|
# 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
|
|
diff = await cloned_repo.diff(commit)
|
|
|
|
return diff
|
|
|
|
|
|
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"))
|
|
|
|
lines = result.decode("utf8").splitlines()
|
|
return lines
|
|
|
|
|
|
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"
|