mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-30 05:44:56 +00:00
Allow config-file overrides in ecosystem checks (#9286)
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
This commit is contained in:
parent
f8fc309855
commit
57b6a8cedd
6 changed files with 210 additions and 60 deletions
|
@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
||||||
[project]
|
[project]
|
||||||
name = "ruff-ecosystem"
|
name = "ruff-ecosystem"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = ["unidiff==0.7.5"]
|
dependencies = ["unidiff==0.7.5", "tomli_w==1.0.0", "tomli==2.0.1"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ruff-ecosystem = "ruff_ecosystem.cli:entrypoint"
|
ruff-ecosystem = "ruff_ecosystem.cli:entrypoint"
|
||||||
|
|
|
@ -28,7 +28,12 @@ from ruff_ecosystem.types import (
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ruff_ecosystem.projects import CheckOptions, ClonedRepository, Project
|
from ruff_ecosystem.projects import (
|
||||||
|
CheckOptions,
|
||||||
|
ClonedRepository,
|
||||||
|
ConfigOverrides,
|
||||||
|
Project,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Matches lines that are summaries rather than diagnostics
|
# Matches lines that are summaries rather than diagnostics
|
||||||
|
@ -477,8 +482,10 @@ async def compare_check(
|
||||||
ruff_baseline_executable: Path,
|
ruff_baseline_executable: Path,
|
||||||
ruff_comparison_executable: Path,
|
ruff_comparison_executable: Path,
|
||||||
options: CheckOptions,
|
options: CheckOptions,
|
||||||
|
config_overrides: ConfigOverrides,
|
||||||
cloned_repo: ClonedRepository,
|
cloned_repo: ClonedRepository,
|
||||||
) -> Comparison:
|
) -> Comparison:
|
||||||
|
with config_overrides.patch_config(cloned_repo.path, options.preview):
|
||||||
async with asyncio.TaskGroup() as tg:
|
async with asyncio.TaskGroup() as tg:
|
||||||
baseline_task = tg.create_task(
|
baseline_task = tg.create_task(
|
||||||
ruff_check(
|
ruff_check(
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
"""
|
"""
|
||||||
Default projects for ecosystem checks
|
Default projects for ecosystem checks
|
||||||
"""
|
"""
|
||||||
from ruff_ecosystem.projects import CheckOptions, FormatOptions, Project, Repository
|
from ruff_ecosystem.projects import (
|
||||||
|
CheckOptions,
|
||||||
|
ConfigOverrides,
|
||||||
|
FormatOptions,
|
||||||
|
Project,
|
||||||
|
Repository,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO(zanieb): Consider exporting this as JSON and loading from there instead
|
# TODO(zanieb): Consider exporting this as JSON and loading from there instead
|
||||||
DEFAULT_TARGETS = [
|
DEFAULT_TARGETS = [
|
||||||
|
@ -45,7 +51,14 @@ DEFAULT_TARGETS = [
|
||||||
Project(repo=Repository(owner="pypa", name="build", ref="main")),
|
Project(repo=Repository(owner="pypa", name="build", ref="main")),
|
||||||
Project(repo=Repository(owner="pypa", name="cibuildwheel", ref="main")),
|
Project(repo=Repository(owner="pypa", name="cibuildwheel", ref="main")),
|
||||||
Project(repo=Repository(owner="pypa", name="pip", ref="main")),
|
Project(repo=Repository(owner="pypa", name="pip", ref="main")),
|
||||||
Project(repo=Repository(owner="pypa", name="setuptools", ref="main")),
|
Project(
|
||||||
|
repo=Repository(owner="pypa", name="setuptools", ref="main"),
|
||||||
|
# Since `setuptools` opts into the "preserve" quote style which
|
||||||
|
# require preview mode, we must disable it during the `--no-preview` run
|
||||||
|
config_overrides=ConfigOverrides(
|
||||||
|
when_no_preview={"format.quote-style": "double"}
|
||||||
|
),
|
||||||
|
),
|
||||||
Project(repo=Repository(owner="python", name="mypy", ref="master")),
|
Project(repo=Repository(owner="python", name="mypy", ref="master")),
|
||||||
Project(
|
Project(
|
||||||
repo=Repository(
|
repo=Repository(
|
||||||
|
|
|
@ -18,7 +18,7 @@ from ruff_ecosystem.markdown import markdown_project_section
|
||||||
from ruff_ecosystem.types import Comparison, Diff, Result, ToolError
|
from ruff_ecosystem.types import Comparison, Diff, Result, ToolError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ruff_ecosystem.projects import ClonedRepository, FormatOptions
|
from ruff_ecosystem.projects import ClonedRepository, ConfigOverrides, FormatOptions
|
||||||
|
|
||||||
|
|
||||||
def markdown_format_result(result: Result) -> str:
|
def markdown_format_result(result: Result) -> str:
|
||||||
|
@ -137,10 +137,17 @@ async def compare_format(
|
||||||
ruff_baseline_executable: Path,
|
ruff_baseline_executable: Path,
|
||||||
ruff_comparison_executable: Path,
|
ruff_comparison_executable: Path,
|
||||||
options: FormatOptions,
|
options: FormatOptions,
|
||||||
|
config_overrides: ConfigOverrides,
|
||||||
cloned_repo: ClonedRepository,
|
cloned_repo: ClonedRepository,
|
||||||
format_comparison: FormatComparison,
|
format_comparison: FormatComparison,
|
||||||
):
|
):
|
||||||
args = (ruff_baseline_executable, ruff_comparison_executable, options, cloned_repo)
|
args = (
|
||||||
|
ruff_baseline_executable,
|
||||||
|
ruff_comparison_executable,
|
||||||
|
options,
|
||||||
|
config_overrides,
|
||||||
|
cloned_repo,
|
||||||
|
)
|
||||||
match format_comparison:
|
match format_comparison:
|
||||||
case FormatComparison.ruff_then_ruff:
|
case FormatComparison.ruff_then_ruff:
|
||||||
coro = format_then_format(Formatter.ruff, *args)
|
coro = format_then_format(Formatter.ruff, *args)
|
||||||
|
@ -162,8 +169,10 @@ async def format_then_format(
|
||||||
ruff_baseline_executable: Path,
|
ruff_baseline_executable: Path,
|
||||||
ruff_comparison_executable: Path,
|
ruff_comparison_executable: Path,
|
||||||
options: FormatOptions,
|
options: FormatOptions,
|
||||||
|
config_overrides: ConfigOverrides,
|
||||||
cloned_repo: ClonedRepository,
|
cloned_repo: ClonedRepository,
|
||||||
) -> Sequence[str]:
|
) -> Sequence[str]:
|
||||||
|
with config_overrides.patch_config(cloned_repo.path, options.preview):
|
||||||
# Run format to get the baseline
|
# Run format to get the baseline
|
||||||
await format(
|
await format(
|
||||||
formatter=baseline_formatter,
|
formatter=baseline_formatter,
|
||||||
|
@ -189,8 +198,10 @@ async def format_and_format(
|
||||||
ruff_baseline_executable: Path,
|
ruff_baseline_executable: Path,
|
||||||
ruff_comparison_executable: Path,
|
ruff_comparison_executable: Path,
|
||||||
options: FormatOptions,
|
options: FormatOptions,
|
||||||
|
config_overrides: ConfigOverrides,
|
||||||
cloned_repo: ClonedRepository,
|
cloned_repo: ClonedRepository,
|
||||||
) -> Sequence[str]:
|
) -> Sequence[str]:
|
||||||
|
with config_overrides.patch_config(cloned_repo.path, options.preview):
|
||||||
# Run format without diff to get the baseline
|
# Run format without diff to get the baseline
|
||||||
await format(
|
await format(
|
||||||
formatter=baseline_formatter,
|
formatter=baseline_formatter,
|
||||||
|
@ -199,12 +210,15 @@ async def format_and_format(
|
||||||
name=cloned_repo.fullname,
|
name=cloned_repo.fullname,
|
||||||
options=options,
|
options=options,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Commit the changes
|
# Commit the changes
|
||||||
commit = await cloned_repo.commit(
|
commit = await cloned_repo.commit(
|
||||||
message=f"Formatted with baseline {ruff_baseline_executable}"
|
message=f"Formatted with baseline {ruff_baseline_executable}"
|
||||||
)
|
)
|
||||||
# Then reset
|
# Then reset
|
||||||
await cloned_repo.reset()
|
await cloned_repo.reset()
|
||||||
|
|
||||||
|
with config_overrides.patch_config(cloned_repo.path, options.preview):
|
||||||
# Then run format again
|
# Then run format again
|
||||||
await format(
|
await format(
|
||||||
formatter=Formatter.ruff,
|
formatter=Formatter.ruff,
|
||||||
|
@ -213,8 +227,10 @@ async def format_and_format(
|
||||||
name=cloned_repo.fullname,
|
name=cloned_repo.fullname,
|
||||||
options=options,
|
options=options,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Then get the diff from the commit
|
# Then get the diff from the commit
|
||||||
diff = await cloned_repo.diff(commit)
|
diff = await cloned_repo.diff(commit)
|
||||||
|
|
||||||
return diff
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -113,11 +113,17 @@ async def clone_and_compare(
|
||||||
|
|
||||||
match command:
|
match command:
|
||||||
case RuffCommand.check:
|
case RuffCommand.check:
|
||||||
compare, options, kwargs = (compare_check, target.check_options, {})
|
compare, options, overrides, kwargs = (
|
||||||
|
compare_check,
|
||||||
|
target.check_options,
|
||||||
|
target.config_overrides,
|
||||||
|
{},
|
||||||
|
)
|
||||||
case RuffCommand.format:
|
case RuffCommand.format:
|
||||||
compare, options, kwargs = (
|
compare, options, overrides, kwargs = (
|
||||||
compare_format,
|
compare_format,
|
||||||
target.format_options,
|
target.format_options,
|
||||||
|
target.config_overrides,
|
||||||
{"format_comparison": format_comparison},
|
{"format_comparison": format_comparison},
|
||||||
)
|
)
|
||||||
case _:
|
case _:
|
||||||
|
@ -131,6 +137,7 @@ async def clone_and_compare(
|
||||||
baseline_executable,
|
baseline_executable,
|
||||||
comparison_executable,
|
comparison_executable,
|
||||||
options,
|
options,
|
||||||
|
overrides,
|
||||||
cloned_repo,
|
cloned_repo,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,13 +5,18 @@ Abstractions and utilities for working with projects to run ecosystem checks on.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from asyncio import create_subprocess_exec
|
from asyncio import create_subprocess_exec
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from functools import cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import DEVNULL, PIPE
|
from subprocess import DEVNULL, PIPE
|
||||||
from typing import Self
|
from typing import Any, Self
|
||||||
|
|
||||||
|
import tomli
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
from ruff_ecosystem import logger
|
from ruff_ecosystem import logger
|
||||||
from ruff_ecosystem.types import Serializable
|
from ruff_ecosystem.types import Serializable
|
||||||
|
@ -26,14 +31,115 @@ class Project(Serializable):
|
||||||
repo: Repository
|
repo: Repository
|
||||||
check_options: CheckOptions = field(default_factory=lambda: CheckOptions())
|
check_options: CheckOptions = field(default_factory=lambda: CheckOptions())
|
||||||
format_options: FormatOptions = field(default_factory=lambda: FormatOptions())
|
format_options: FormatOptions = field(default_factory=lambda: FormatOptions())
|
||||||
|
config_overrides: ConfigOverrides = field(default_factory=lambda: ConfigOverrides())
|
||||||
|
|
||||||
def with_preview_enabled(self: Self) -> Self:
|
def with_preview_enabled(self: Self) -> Self:
|
||||||
return type(self)(
|
return type(self)(
|
||||||
repo=self.repo,
|
repo=self.repo,
|
||||||
check_options=self.check_options.with_options(preview=True),
|
check_options=self.check_options.with_options(preview=True),
|
||||||
format_options=self.format_options.with_options(preview=True),
|
format_options=self.format_options.with_options(preview=True),
|
||||||
|
config_overrides=self.config_overrides,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Convert bare dictionaries for `config_overrides` into the correct type
|
||||||
|
if isinstance(self.config_overrides, dict):
|
||||||
|
# Bypass the frozen attribute
|
||||||
|
object.__setattr__(
|
||||||
|
self, "config_overrides", ConfigOverrides(always=self.config_overrides)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfigOverrides(Serializable):
|
||||||
|
"""
|
||||||
|
A collection of key, value pairs to override in the Ruff configuration file.
|
||||||
|
|
||||||
|
The key describes a member to override in the toml file; '.' may be used to indicate a
|
||||||
|
nested value e.g. `format.quote-style`.
|
||||||
|
|
||||||
|
If a Ruff configuration file does not exist and overrides are provided, it will be createad.
|
||||||
|
"""
|
||||||
|
|
||||||
|
always: dict[str, Any] = field(default_factory=dict)
|
||||||
|
when_preview: dict[str, Any] = field(default_factory=dict)
|
||||||
|
when_no_preview: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
# Avoid computing this hash repeatedly since this object is intended
|
||||||
|
# to be immutable and serializing to toml is not necessarily cheap
|
||||||
|
@cache
|
||||||
|
def as_string():
|
||||||
|
return tomli_w.dumps(
|
||||||
|
{
|
||||||
|
"always": self.always,
|
||||||
|
"when_preview": self.when_preview,
|
||||||
|
"when_no_preview": self.when_no_preview,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return hash(as_string())
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def patch_config(
|
||||||
|
self,
|
||||||
|
dirpath: Path,
|
||||||
|
preview: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Temporarily patch the Ruff configuration file in the given directory.
|
||||||
|
"""
|
||||||
|
ruff_toml = dirpath / "ruff.toml"
|
||||||
|
pyproject_toml = dirpath / "pyproject.toml"
|
||||||
|
|
||||||
|
# Prefer `ruff.toml` over `pyproject.toml`
|
||||||
|
if ruff_toml.exists():
|
||||||
|
path = ruff_toml
|
||||||
|
base = []
|
||||||
|
else:
|
||||||
|
path = pyproject_toml
|
||||||
|
base = ["tool", "ruff"]
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
**self.always,
|
||||||
|
**(self.when_preview if preview else self.when_no_preview),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not overrides:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read the existing content if the file is present
|
||||||
|
if path.exists():
|
||||||
|
contents = path.read_text()
|
||||||
|
toml = tomli.loads(contents)
|
||||||
|
else:
|
||||||
|
contents = None
|
||||||
|
toml = {}
|
||||||
|
|
||||||
|
# Update the TOML, using `.` to descend into nested keys
|
||||||
|
for key, value in overrides.items():
|
||||||
|
logger.debug(f"Setting {key}={value!r} in {path}")
|
||||||
|
|
||||||
|
target = toml
|
||||||
|
names = base + key.split(".")
|
||||||
|
for name in names[:-1]:
|
||||||
|
if name not in target:
|
||||||
|
target[name] = {}
|
||||||
|
target = target[name]
|
||||||
|
target[names[-1]] = value
|
||||||
|
|
||||||
|
tomli_w.dump(toml, path.open("wb"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
# Restore the contents or delete the file
|
||||||
|
if contents is None:
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
path.write_text(contents)
|
||||||
|
|
||||||
|
|
||||||
class RuffCommand(Enum):
|
class RuffCommand(Enum):
|
||||||
check = "check"
|
check = "check"
|
||||||
|
@ -42,6 +148,8 @@ class RuffCommand(Enum):
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CommandOptions(Serializable, abc.ABC):
|
class CommandOptions(Serializable, abc.ABC):
|
||||||
|
preview: bool = False
|
||||||
|
|
||||||
def with_options(self: Self, **kwargs) -> Self:
|
def with_options(self: Self, **kwargs) -> Self:
|
||||||
"""
|
"""
|
||||||
Return a copy of self with the given options set.
|
Return a copy of self with the given options set.
|
||||||
|
@ -62,7 +170,6 @@ class CheckOptions(CommandOptions):
|
||||||
select: str = ""
|
select: str = ""
|
||||||
ignore: str = ""
|
ignore: str = ""
|
||||||
exclude: str = ""
|
exclude: str = ""
|
||||||
preview: bool = False
|
|
||||||
|
|
||||||
# Generating fixes is slow and verbose
|
# Generating fixes is slow and verbose
|
||||||
show_fixes: bool = False
|
show_fixes: bool = False
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue