diff --git a/python/ruff-ecosystem/ruff_ecosystem/check.py b/python/ruff-ecosystem/ruff_ecosystem/check.py index 8864e95f56..f95e7ea3ed 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/check.py +++ b/python/ruff-ecosystem/ruff_ecosystem/check.py @@ -1,3 +1,6 @@ +""" +Execution, comparison, and summary of `ruff check` ecosystem checks. +""" from __future__ import annotations import asyncio @@ -11,7 +14,7 @@ from subprocess import PIPE from typing import TYPE_CHECKING, Iterator, Self, Sequence from ruff_ecosystem import logger -from ruff_ecosystem.markdown import markdown_project_section, markdown_details +from ruff_ecosystem.markdown import markdown_details, markdown_project_section from ruff_ecosystem.types import ( Comparison, Diff, @@ -33,40 +36,7 @@ CHECK_DIFF_LINE_RE = re.compile( ) -async def compare_check( - ruff_baseline_executable: Path, - ruff_comparison_executable: Path, - options: CheckOptions, - cloned_repo: ClonedRepository, -) -> Comparison: - async with asyncio.TaskGroup() as tg: - baseline_task = tg.create_task( - ruff_check( - executable=ruff_baseline_executable.resolve(), - path=cloned_repo.path, - name=cloned_repo.fullname, - options=options, - ), - ) - comparison_task = tg.create_task( - ruff_check( - executable=ruff_comparison_executable.resolve(), - path=cloned_repo.path, - name=cloned_repo.fullname, - options=options, - ), - ) - - baseline_output, comparison_output = ( - baseline_task.result(), - comparison_task.result(), - ) - diff = Diff.new(baseline_output, comparison_output) - - return Comparison(diff=diff, repo=cloned_repo) - - -def summarize_check_result(result: Result) -> str: +def markdown_check_result(result: Result) -> str: # Calculate the total number of rule changes all_rule_changes = RuleChanges() for _, comparison in result.completed: @@ -154,82 +124,6 @@ def summarize_check_result(result: Result) -> str: return "\n".join(lines) -def add_permalink_to_diagnostic_line(repo: ClonedRepository, line: str) -> str: - match = CHECK_DIFF_LINE_RE.match(line) - if match is None: - return line - - pre, inner, path, lnum, post = match.groups() - url = repo.url_for(path, int(lnum)) - return f"{pre} {inner} {post}" - - -async def ruff_check( - *, executable: Path, path: Path, name: str, options: CheckOptions -) -> Sequence[str]: - """Run the given ruff binary against the specified path.""" - logger.debug(f"Checking {name} with {executable}") - ruff_args = options.to_cli_args() - - start = time.time() - proc = await create_subprocess_exec( - executable.absolute(), - *ruff_args, - ".", - stdout=PIPE, - stderr=PIPE, - cwd=path, - ) - result, err = await proc.communicate() - end = time.time() - - logger.debug(f"Finished checking {name} with {executable} in {end - start:.2f}s") - - if proc.returncode != 0: - raise RuffError(err.decode("utf8")) - - # Strip summary lines so the diff is only diagnostic lines - lines = [ - line - for line in result.decode("utf8").splitlines() - if not CHECK_SUMMARY_LINE_RE.match(line) - ] - - return lines - - -@dataclass(frozen=True) -class CheckOptions(Serializable): - """ - Ruff check options - """ - - select: str = "" - ignore: str = "" - exclude: str = "" - - # Generating fixes is slow and verbose - show_fixes: bool = False - - # Limit the number of reported lines per rule - max_lines_per_rule: int | None = 50 - - def markdown(self) -> str: - return f"select {self.select} ignore {self.ignore} exclude {self.exclude}" - - def to_cli_args(self) -> list[str]: - args = ["check", "--no-cache", "--exit-zero"] - if self.select: - args.extend(["--select", self.select]) - if self.ignore: - args.extend(["--ignore", self.ignore]) - if self.exclude: - args.extend(["--exclude", self.exclude]) - if self.show_fixes: - args.extend(["--show-fixes", "--ecosystem-ci"]) - return args - - @dataclass(frozen=True) class RuleChanges: """ @@ -357,3 +251,112 @@ def limit_rule_lines(diff: Diff, max_per_rule: int | None = 100) -> list[str]: reduced.append(f"... {hidden_count} changes omitted for rule {code}") return reduced + + +def add_permalink_to_diagnostic_line(repo: ClonedRepository, line: str) -> str: + match = CHECK_DIFF_LINE_RE.match(line) + if match is None: + return line + + pre, inner, path, lnum, post = match.groups() + url = repo.url_for(path, int(lnum)) + return f"{pre} {inner} {post}" + + +async def compare_check( + ruff_baseline_executable: Path, + ruff_comparison_executable: Path, + options: CheckOptions, + cloned_repo: ClonedRepository, +) -> Comparison: + async with asyncio.TaskGroup() as tg: + baseline_task = tg.create_task( + ruff_check( + executable=ruff_baseline_executable.resolve(), + path=cloned_repo.path, + name=cloned_repo.fullname, + options=options, + ), + ) + comparison_task = tg.create_task( + ruff_check( + executable=ruff_comparison_executable.resolve(), + path=cloned_repo.path, + name=cloned_repo.fullname, + options=options, + ), + ) + + baseline_output, comparison_output = ( + baseline_task.result(), + comparison_task.result(), + ) + diff = Diff.from_pair(baseline_output, comparison_output) + + return Comparison(diff=diff, repo=cloned_repo) + + +async def ruff_check( + *, executable: Path, path: Path, name: str, options: CheckOptions +) -> Sequence[str]: + """Run the given ruff binary against the specified path.""" + logger.debug(f"Checking {name} with {executable}") + ruff_args = options.to_cli_args() + + start = time.time() + proc = await create_subprocess_exec( + executable.absolute(), + *ruff_args, + ".", + stdout=PIPE, + stderr=PIPE, + cwd=path, + ) + result, err = await proc.communicate() + end = time.time() + + logger.debug(f"Finished checking {name} with {executable} in {end - start:.2f}s") + + if proc.returncode != 0: + raise RuffError(err.decode("utf8")) + + # Strip summary lines so the diff is only diagnostic lines + lines = [ + line + for line in result.decode("utf8").splitlines() + if not CHECK_SUMMARY_LINE_RE.match(line) + ] + + return lines + + +@dataclass(frozen=True) +class CheckOptions(Serializable): + """ + Ruff check options + """ + + select: str = "" + ignore: str = "" + exclude: str = "" + + # Generating fixes is slow and verbose + show_fixes: bool = False + + # Limit the number of reported lines per rule + max_lines_per_rule: int | None = 50 + + def markdown(self) -> str: + return f"select {self.select} ignore {self.ignore} exclude {self.exclude}" + + def to_cli_args(self) -> list[str]: + args = ["check", "--no-cache", "--exit-zero"] + if self.select: + args.extend(["--select", self.select]) + if self.ignore: + args.extend(["--ignore", self.ignore]) + if self.exclude: + args.extend(["--exclude", self.exclude]) + if self.show_fixes: + args.extend(["--show-fixes", "--ecosystem-ci"]) + return args diff --git a/python/ruff-ecosystem/ruff_ecosystem/cli.py b/python/ruff-ecosystem/ruff_ecosystem/cli.py index 010bb09b00..7c554dd957 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/cli.py +++ b/python/ruff-ecosystem/ruff_ecosystem/cli.py @@ -117,21 +117,3 @@ def parse_args() -> argparse.Namespace: ) return parser.parse_args() - - -def get_executable_path(name: str) -> str | None: - # Add suffix for Windows executables - name += ".exe" if sys.platform == "win32" else "" - - path = os.path.join(sysconfig.get_path("scripts"), name) - - # The executable in the current interpreter's scripts directory. - if os.path.exists(path): - return path - - # The executable in the global environment. - environment_path = shutil.which("ruff") - if environment_path: - return environment_path - - return None diff --git a/python/ruff-ecosystem/ruff_ecosystem/defaults.py b/python/ruff-ecosystem/ruff_ecosystem/defaults.py index 3c9c6bfc89..472860f5bb 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/defaults.py +++ b/python/ruff-ecosystem/ruff_ecosystem/defaults.py @@ -1,64 +1,67 @@ +""" +Default projects for ecosystem checks +""" from ruff_ecosystem.projects import CheckOptions, Project, Repository -# TODO: Consider exporting this as JSON +# TODO(zanieb): Consider exporting this as JSON and loading from there instead DEFAULT_TARGETS = [ - # Project(repo=Repository(owner="DisnakeDev", name="disnake", ref="master")), - # Project(repo=Repository(owner="PostHog", name="HouseWatch", ref="main")), - # Project(repo=Repository(owner="RasaHQ", name="rasa", ref="main")), - # Project(repo=Repository(owner="Snowflake-Labs", name="snowcli", ref="main")), - # Project(repo=Repository(owner="aiven", name="aiven-client", ref="main")), - # Project(repo=Repository(owner="alteryx", name="featuretools", ref="main")), - # Project( - # repo=Repository(owner="apache", name="airflow", ref="main"), - # check_options=CheckOptions(select="ALL"), - # ), - # Project(repo=Repository(owner="aws", name="aws-sam-cli", ref="develop")), - # Project(repo=Repository(owner="bloomberg", name="pytest-memray", ref="main")), + Project(repo=Repository(owner="DisnakeDev", name="disnake", ref="master")), + Project(repo=Repository(owner="PostHog", name="HouseWatch", ref="main")), + Project(repo=Repository(owner="RasaHQ", name="rasa", ref="main")), + Project(repo=Repository(owner="Snowflake-Labs", name="snowcli", ref="main")), + Project(repo=Repository(owner="aiven", name="aiven-client", ref="main")), + Project(repo=Repository(owner="alteryx", name="featuretools", ref="main")), + Project( + repo=Repository(owner="apache", name="airflow", ref="main"), + check_options=CheckOptions(select="ALL"), + ), + Project(repo=Repository(owner="aws", name="aws-sam-cli", ref="develop")), + Project(repo=Repository(owner="bloomberg", name="pytest-memray", ref="main")), Project( repo=Repository(owner="bokeh", name="bokeh", ref="branch-3.3"), check_options=CheckOptions(select="ALL"), ), - # Project(repo=Repository(owner="commaai", name="openpilot", ref="master")), - # Project(repo=Repository(owner="demisto", name="content", ref="master")), - # Project(repo=Repository(owner="docker", name="docker-py", ref="main")), - # Project(repo=Repository(owner="freedomofpress", name="securedrop", ref="develop")), - # Project(repo=Repository(owner="fronzbot", name="blinkpy", ref="dev")), - # Project(repo=Repository(owner="ibis-project", name="ibis", ref="master")), - # Project(repo=Repository(owner="ing-bank", name="probatus", ref="main")), - # Project(repo=Repository(owner="jrnl-org", name="jrnl", ref="develop")), - # Project(repo=Repository(owner="latchbio", name="latch", ref="main")), - # Project(repo=Repository(owner="lnbits", name="lnbits", ref="main")), - # Project(repo=Repository(owner="milvus-io", name="pymilvus", ref="master")), - # Project(repo=Repository(owner="mlflow", name="mlflow", ref="master")), - # Project(repo=Repository(owner="model-bakers", name="model_bakery", ref="main")), - # Project(repo=Repository(owner="pandas-dev", name="pandas", ref="main")), - # Project(repo=Repository(owner="prefecthq", name="prefect", 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="pip", ref="main")), - # Project(repo=Repository(owner="pypa", name="setuptools", ref="main")), - # Project(repo=Repository(owner="python", name="mypy", ref="master")), - # Project( - # repo=Repository( - # owner="python", - # name="typeshed", - # ref="main", - # ), - # check_options=CheckOptions(select="PYI"), - # ), - # Project(repo=Repository(owner="python-poetry", name="poetry", ref="master")), - # Project(repo=Repository(owner="reflex-dev", name="reflex", ref="main")), - # Project(repo=Repository(owner="rotki", name="rotki", ref="develop")), - # Project(repo=Repository(owner="scikit-build", name="scikit-build", ref="main")), - # Project( - # repo=Repository(owner="scikit-build", name="scikit-build-core", ref="main") - # ), - # Project(repo=Repository(owner="sphinx-doc", name="sphinx", ref="master")), - # Project(repo=Repository(owner="spruceid", name="siwe-py", ref="main")), - # Project(repo=Repository(owner="tiangolo", name="fastapi", ref="master")), - # Project(repo=Repository(owner="yandex", name="ch-backup", ref="main")), - # Project( - # repo=Repository(owner="zulip", name="zulip", ref="main"), - # check_options=CheckOptions(select="ALL"), - # ), + Project(repo=Repository(owner="commaai", name="openpilot", ref="master")), + Project(repo=Repository(owner="demisto", name="content", ref="master")), + Project(repo=Repository(owner="docker", name="docker-py", ref="main")), + Project(repo=Repository(owner="freedomofpress", name="securedrop", ref="develop")), + Project(repo=Repository(owner="fronzbot", name="blinkpy", ref="dev")), + Project(repo=Repository(owner="ibis-project", name="ibis", ref="master")), + Project(repo=Repository(owner="ing-bank", name="probatus", ref="main")), + Project(repo=Repository(owner="jrnl-org", name="jrnl", ref="develop")), + Project(repo=Repository(owner="latchbio", name="latch", ref="main")), + Project(repo=Repository(owner="lnbits", name="lnbits", ref="main")), + Project(repo=Repository(owner="milvus-io", name="pymilvus", ref="master")), + Project(repo=Repository(owner="mlflow", name="mlflow", ref="master")), + Project(repo=Repository(owner="model-bakers", name="model_bakery", ref="main")), + Project(repo=Repository(owner="pandas-dev", name="pandas", ref="main")), + Project(repo=Repository(owner="prefecthq", name="prefect", 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="pip", ref="main")), + Project(repo=Repository(owner="pypa", name="setuptools", ref="main")), + Project(repo=Repository(owner="python", name="mypy", ref="master")), + Project( + repo=Repository( + owner="python", + name="typeshed", + ref="main", + ), + check_options=CheckOptions(select="PYI"), + ), + Project(repo=Repository(owner="python-poetry", name="poetry", ref="master")), + Project(repo=Repository(owner="reflex-dev", name="reflex", ref="main")), + Project(repo=Repository(owner="rotki", name="rotki", ref="develop")), + Project(repo=Repository(owner="scikit-build", name="scikit-build", ref="main")), + Project( + repo=Repository(owner="scikit-build", name="scikit-build-core", ref="main") + ), + Project(repo=Repository(owner="sphinx-doc", name="sphinx", ref="master")), + Project(repo=Repository(owner="spruceid", name="siwe-py", ref="main")), + Project(repo=Repository(owner="tiangolo", name="fastapi", ref="master")), + Project(repo=Repository(owner="yandex", name="ch-backup", ref="main")), + Project( + repo=Repository(owner="zulip", name="zulip", ref="main"), + check_options=CheckOptions(select="ALL"), + ), ] diff --git a/python/ruff-ecosystem/ruff_ecosystem/format.py b/python/ruff-ecosystem/ruff_ecosystem/format.py index 29972a6772..8be240eb98 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/format.py +++ b/python/ruff-ecosystem/ruff_ecosystem/format.py @@ -1,3 +1,7 @@ +""" +Execution, comparison, and summary of `ruff format` ecosystem checks. +""" + from __future__ import annotations import re @@ -6,7 +10,7 @@ from asyncio import create_subprocess_exec from dataclasses import dataclass from pathlib import Path from subprocess import PIPE -from typing import TYPE_CHECKING, Self, Sequence +from typing import TYPE_CHECKING, Sequence from unidiff import PatchSet @@ -15,13 +19,13 @@ from ruff_ecosystem.markdown import markdown_project_section from ruff_ecosystem.types import Comparison, Diff, Result, RuffError if TYPE_CHECKING: - from ruff_ecosystem.projects import ClonedRepository, Project + from ruff_ecosystem.projects import ClonedRepository FORMAT_IGNORE_LINES = re.compile("^warning: `ruff format` is a work-in-progress.*") -def summarize_format_result(result: Result) -> str: +def markdown_format_result(result: Result) -> str: lines = [] total_lines_removed = total_lines_added = 0 total_files_modified = 0 @@ -58,7 +62,7 @@ def summarize_format_result(result: Result) -> str: lines.extend( markdown_project_section( title=title, - content=patch_set_with_permalinks(patch_set, comparison.repo), + content=format_patchset(patch_set, comparison.repo), options=project.format_options, project=project, ) @@ -77,6 +81,57 @@ def summarize_format_result(result: Result) -> str: 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 = [] + 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"{link_title}") + + # 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, + cloned_repo: ClonedRepository, +): + # Run format without diff to get the baseline + await ruff_format( + executable=ruff_baseline_executable.resolve(), + path=cloned_repo.path, + name=cloned_repo.fullname, + options=options, + ) + # Then get the diff from stdout + diff = await ruff_format( + executable=ruff_comparison_executable.resolve(), + path=cloned_repo.path, + name=cloned_repo.fullname, + options=options, + diff=True, + ) + + return Comparison(diff=Diff(diff), repo=cloned_repo) + + async def ruff_format( *, executable: Path, @@ -113,66 +168,6 @@ async def ruff_format( return lines -async def black_format( - *, - executable: Path, - path: Path, - name: str, -) -> Sequence[str]: - """Run the given black binary against the specified path.""" - logger.debug(f"Formatting {name} with {executable}") - black_args = [] - - start = time.time() - proc = await create_subprocess_exec( - executable.absolute(), - *black_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 != 0: - raise RuffError(err.decode("utf8")) - - lines = result.decode("utf8").splitlines() - return [line for line in lines if not FORMAT_IGNORE_LINES.match(line)] - - -async def compare_format( - ruff_baseline_executable: Path, - ruff_comparison_executable: Path, - options: FormatOptions, - cloned_repo: ClonedRepository, -): - # Run format without diff to get the baseline - await ruff_format( - executable=ruff_baseline_executable.resolve(), - path=cloned_repo.path, - name=cloned_repo.fullname, - options=options, - ) - # Then get the diff from stdout - diff = await ruff_format( - executable=ruff_comparison_executable.resolve(), - path=cloned_repo.path, - name=cloned_repo.fullname, - options=options, - diff=True, - ) - - return create_format_comparison(cloned_repo, FormatDiff(lines=diff)) - - -def create_format_comparison(repo: ClonedRepository, diff: str) -> FormatComparison: - return FormatComparison(diff=diff, repo=repo) - - @dataclass(frozen=True) class FormatOptions: """ @@ -182,55 +177,3 @@ class FormatOptions: def to_cli_args(self) -> list[str]: args = ["format", "--diff"] return args - - -@dataclass(frozen=True) -class FormatDiff(Diff): - """A diff from ruff format.""" - - lines: list[str] - - def __bool__(self: Self) -> bool: - """Return true if this diff is non-empty.""" - return bool(self.lines) - - @property - def added(self) -> set[str]: - return set(line for line in self.lines if line.startswith("+")) - - @property - def removed(self) -> set[str]: - return set(line for line in self.lines if line.startswith("-")) - - -@dataclass(frozen=True) -class FormatComparison(Comparison): - diff: FormatDiff - repo: ClonedRepository - - -@dataclass(frozen=True) -class FormatResult(Result): - comparisons: tuple[Project, FormatComparison] - - -def patch_set_with_permalinks(patch_set: PatchSet, repo: ClonedRepository) -> str: - lines = [] - for file_patch in patch_set: - for hunk in file_patch: - # Note: 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"{link_title}") - - # 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) diff --git a/python/ruff-ecosystem/ruff_ecosystem/main.py b/python/ruff-ecosystem/ruff_ecosystem/main.py index e271957650..f2875707ef 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/main.py +++ b/python/ruff-ecosystem/ruff_ecosystem/main.py @@ -6,8 +6,8 @@ from pathlib import Path from typing import TypeVar from ruff_ecosystem import logger -from ruff_ecosystem.check import compare_check, summarize_check_result -from ruff_ecosystem.format import compare_format, summarize_format_result +from ruff_ecosystem.check import compare_check, markdown_check_result +from ruff_ecosystem.format import compare_format, markdown_format_result from ruff_ecosystem.projects import ( Project, RuffCommand, @@ -38,6 +38,7 @@ async def main( logger.debug("Using cache directory %s", cache) logger.debug("Checking %s targets", len(targets)) + # Limit parallelism to avoid high memory consumption semaphore = asyncio.Semaphore(max_parallelism) async def limited_parallelism(coroutine: T) -> T: @@ -61,15 +62,15 @@ async def main( ) comparisons_by_target = dict(zip(targets, comparisons, strict=True)) - errors, successes = [], [] + # Split comparisons into errored / completed + errored, completed = [], [] for target, comparison in comparisons_by_target.items(): if isinstance(comparison, Exception): - errors.append((target, comparison)) - continue + errored.append((target, comparison)) + else: + completed.append((target, comparison)) - successes.append((target, comparison)) - - result = Result(completed=successes, errored=errors) + result = Result(completed=completed, errored=errored) match format: case OutputFormat.json: @@ -77,9 +78,9 @@ async def main( case OutputFormat.markdown: match command: case RuffCommand.check: - print(summarize_check_result(result)) + print(markdown_check_result(result)) case RuffCommand.format: - print(summarize_format_result(result)) + print(markdown_format_result(result)) case _: raise ValueError(f"Unknown target Ruff command {command}") case _: diff --git a/python/ruff-ecosystem/ruff_ecosystem/markdown.py b/python/ruff-ecosystem/ruff_ecosystem/markdown.py index 16e5117051..618d86589e 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/markdown.py +++ b/python/ruff-ecosystem/ruff_ecosystem/markdown.py @@ -3,7 +3,9 @@ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: - from ruff_ecosystem.projects import Project + from unidiff import PatchSet + + from ruff_ecosystem.projects import ClonedRepository, Project def markdown_project_section( diff --git a/python/ruff-ecosystem/ruff_ecosystem/projects.py b/python/ruff-ecosystem/ruff_ecosystem/projects.py index 069bb7ae23..c58fa70bf8 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/projects.py +++ b/python/ruff-ecosystem/ruff_ecosystem/projects.py @@ -1,3 +1,7 @@ +""" +Abstractions and utilities for working with projects to run ecosystem checks on. +""" + from __future__ import annotations from asyncio import create_subprocess_exec @@ -36,7 +40,7 @@ class ProjectSetupError(Exception): @dataclass(frozen=True) class Repository(Serializable): """ - A remote GitHub repository + A remote GitHub repository. """ owner: str @@ -75,13 +79,7 @@ class Repository(Serializable): f"Failed to checkout {self.ref}: {stderr.decode()}" ) - return ClonedRepository( - name=self.name, - owner=self.owner, - ref=self.ref, - path=checkout_dir, - commit_hash=await self._get_head_commit(checkout_dir), - ) + return await ClonedRepository.from_path(checkout_dir, self) logger.debug(f"Cloning {self.owner}:{self.name} to {checkout_dir}") command = [ @@ -113,35 +111,13 @@ class Repository(Serializable): logger.debug( f"Finished cloning {self.fullname} with status {status_code}", ) - return ClonedRepository( - name=self.name, - owner=self.owner, - ref=self.ref, - path=checkout_dir, - commit_hash=await self._get_head_commit(checkout_dir), - ) - - @staticmethod - async def _get_head_commit(checkout_dir: Path) -> str: - """ - Return the commit sha for the repository in the checkout directory. - """ - process = await create_subprocess_exec( - *["git", "rev-parse", "HEAD"], - cwd=checkout_dir, - stdout=PIPE, - ) - stdout, _ = await process.communicate() - if await process.wait() != 0: - raise ProjectSetupError(f"Failed to retrieve commit sha at {checkout_dir}") - - return stdout.decode().strip() + return await ClonedRepository.from_path(checkout_dir, self) @dataclass(frozen=True) class ClonedRepository(Repository, Serializable): """ - A cloned GitHub repository, which includes the hash of the cloned commit. + A cloned GitHub repository, which includes the hash of the current commit. """ commit_hash: str @@ -166,3 +142,29 @@ class ClonedRepository(Repository, Serializable): @property def url(self: Self) -> str: return f"https://github.com/{self.owner}/{self.name}@{self.commit_hash}" + + @classmethod + async def from_path(cls, path: Path, repo: Repository): + return cls( + name=repo.name, + owner=repo.owner, + ref=repo.ref, + path=path, + commit_hash=await cls._get_head_commit(path), + ) + + @staticmethod + async def _get_head_commit(checkout_dir: Path) -> str: + """ + Return the commit sha for the repository in the checkout directory. + """ + process = await create_subprocess_exec( + *["git", "rev-parse", "HEAD"], + cwd=checkout_dir, + stdout=PIPE, + ) + stdout, _ = await process.communicate() + if await process.wait() != 0: + raise ProjectSetupError(f"Failed to retrieve commit sha at {checkout_dir}") + + return stdout.decode().strip() diff --git a/python/ruff-ecosystem/ruff_ecosystem/types.py b/python/ruff-ecosystem/ruff_ecosystem/types.py index 1bfffb75e0..60b9bcb3c6 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/types.py +++ b/python/ruff-ecosystem/ruff_ecosystem/types.py @@ -46,7 +46,10 @@ class Diff(Serializable): return len(self.removed) @classmethod - def new(cls, baseline: Sequence[str], comparison: Sequence[str]): + def from_pair(cls, baseline: Sequence[str], comparison: Sequence[str]): + """ + Construct a diff from before and after. + """ return cls(difflib.ndiff(baseline, comparison)) def jsonable(self) -> Any: @@ -55,15 +58,23 @@ class Diff(Serializable): @dataclass(frozen=True) class Result(Serializable): + """ + The result of an ecosystem check for a collection of projects. + """ + errored: list[tuple[Project, Exception]] completed: list[tuple[Project, Comparison]] @dataclass(frozen=True) class Comparison(Serializable): + """ + The result of a completed ecosystem comparison for a single project. + """ + diff: Diff repo: ClonedRepository class RuffError(Exception): - """An error reported by ruff.""" + """An error reported by Ruff."""