""" Execution, comparison, and summary of `ruff check` ecosystem checks. """ from __future__ import annotations import asyncio import re import time from asyncio import create_subprocess_exec from collections import Counter import dataclasses from dataclasses import dataclass, field from pathlib import Path from subprocess import PIPE from typing import TYPE_CHECKING, Iterable, Iterator, Self, Sequence from ruff_ecosystem import logger from ruff_ecosystem.markdown import ( markdown_details, markdown_project_section, markdown_plus_minus, ) from ruff_ecosystem.types import ( Comparison, Diff, Result, RuffError, Serializable, ) if TYPE_CHECKING: from ruff_ecosystem.projects import ClonedRepository, Project # Matches lines that are summaries rather than diagnostics CHECK_SUMMARY_LINE_RE = re.compile(r"^(Found \d+ error.*)|(.* fixable with .*)$") # Parses a diagnostic line (in a diff) to retrieve path and line number CHECK_DIFF_LINE_RE = re.compile( r"^(?P
[+-]) (?P(?P[^:]+):(?P\d+):\d+:) (?P.*)$",
)

CHECK_DIAGNOSTIC_LINE_RE = re.compile(
    r"^(?P[+-])? ?(?P.*): (?P[A-Z]{1,4}[0-9]{3,4})(?P \[\*\])? (?P.*)"
)

CHECK_VIOLATION_FIX_INDICATOR = " [*]"

GITHUB_MAX_COMMENT_LENGTH = 65536  # characters


def markdown_check_result(result: Result) -> str:
    # Calculate the total number of rule changes
    all_rule_changes = RuleChanges()
    project_diffs = {
        project: CheckDiff.from_simple_diff(comparison.diff)
        for project, comparison in result.completed
    }
    project_rule_changes: dict[Project, RuleChanges] = {}
    for project, diff in project_diffs.items():
        project_rule_changes[project] = changes = RuleChanges.from_diff(diff)
        all_rule_changes.update(changes)

    lines = []
    total_removed = all_rule_changes.total_removed_violations()
    total_added = all_rule_changes.total_added_violations()
    total_added_fixes = all_rule_changes.total_added_fixes()
    total_removed_fixes = all_rule_changes.total_removed_fixes()
    total_changes = (
        total_added + total_removed + total_added_fixes + total_removed_fixes
    )
    error_count = len(result.errored)
    total_affected_rules = len(all_rule_changes.rule_codes())

    if total_affected_rules == 0 and error_count == 0:
        return "\u2705 ecosystem check detected no linter changes."

    # Summarize the total changes
    changes = (
        f"{markdown_plus_minus(total_added, total_removed)} violations, "
        f"{markdown_plus_minus(total_added_fixes, total_removed_fixes)} fixes"
        f"in {len(result.completed)} projects"
    )
    if error_count:
        s = "s" if error_count != 1 else ""
        changes += f"; {error_count} project error{s}"

    lines.append(
        f"\u2139\ufe0f ecosystem check **detected linter changes**. ({changes})"
    )
    lines.append("")

    # Limit the number of items displayed per rule to between 5 and 50
    max_display_per_rule = max(
        5,
        # Calculate the number of affected rules that we would display to increase
        # the maximum if there are less rules affected
        50 // total_affected_rules,
    )

    # Display per project changes
    for project, comparison in result.completed:
        # TODO: This is not a performant way to check the length but the whole
        #       GitHub comment length issue needs to be addressed anyway
        if len(" ".join(lines)) > GITHUB_MAX_COMMENT_LENGTH // 3:
            lines.append("")
            lines.append(
                "_... Truncated remaining completed projected reports due to GitHub comment length restrictions_"
            )
            lines.append("")
            break

        if not comparison.diff:
            continue  # Skip empty diffs

        diff = project_diffs[project]
        rule_changes = project_rule_changes[project]
        project_removed_violations = rule_changes.total_removed_violations()
        project_added_violations = rule_changes.total_added_violations()
        project_added_fixes = rule_changes.total_added_fixes()
        project_removed_fixes = rule_changes.total_removed_fixes()
        project_changes = (
            project_added_violations
            + project_removed_violations
            + project_added_fixes
            + project_removed_fixes
        )

        # Limit the number of items displayed per project to between 10 and 50
        # based on the number of total changes present in this project
        max_display_per_project = max(10, int((project_changes / total_changes) * 50))

        # Display the diff
        displayed_changes_per_rule = Counter()
        displayed_changes = 0

        # Wrap with `
` for code-styling with support for links
        diff_lines = ["
"]
        for line in diff.parsed_lines:
            rule_code = line.rule_code

            # Limit the number of changes we'll show per rule code
            if displayed_changes_per_rule[rule_code] > max_display_per_rule:
                continue

            diff_lines.append(
                add_permalink_to_diagnostic_line(comparison.repo, line.to_string())
            )

            displayed_changes_per_rule[rule_code] += 1
            displayed_changes += 1

            # If we just reached the maximum... display an omission line
            if displayed_changes_per_rule[rule_code] > max_display_per_rule:
                hidden_count = (
                    rule_changes.added_violations[rule_code]
                    + rule_changes.removed_violations[rule_code]
                    + rule_changes.added_fixes[rule_code]
                    + rule_changes.removed_fixes[rule_code]
                    - max_display_per_rule
                )
                diff_lines.append(
                    f"... {hidden_count} additional changes omitted for rule {rule_code}"
                )

            if displayed_changes >= max_display_per_project:
                break

        if project_changes > max_display_per_project:
            hidden_count = project_changes - displayed_changes
            diff_lines.append(
                f"... {hidden_count} additional changes omitted for project"
            )

        diff_lines.append("
") title = ( f"+{project_added_violations} " f"-{project_removed_violations} violations, " f"+{project_added_fixes} " f"-{project_removed_fixes} fixes" ) lines.extend( markdown_project_section( title=title, content=diff_lines, options=project.check_options, project=project, ) ) for project, error in result.errored: lines.extend( markdown_project_section( title="error", content=f"```\n{error}```", options=project.check_options, project=project, ) ) # Display a summary table of changed rules table_lines = [] table_lines.append("| code | total | + violation | - violation | + fix | - fix |") table_lines.append("| ---- | ------- | --------- | -------- | ----- | ---- |") for rule, total in sorted( all_rule_changes.total_changes_by_rule(), key=lambda item: item[1], # Sort by the total changes reverse=True, ): added_violations, removed_violations, added_fixes, removed_fixes = ( all_rule_changes.added_violations[rule], all_rule_changes.removed_violations[rule], all_rule_changes.added_fixes[rule], all_rule_changes.removed_fixes[rule], ) table_lines.append( f"| {rule} | {total} | {added_violations} | {removed_violations} " f"| {added_fixes} | {removed_fixes} |" ) lines.extend( markdown_details( summary=f"Changes by rule ({total_affected_rules} rules affected)", preface="", content=table_lines, ) ) return "\n".join(lines) @dataclass(frozen=True) class RuleChanges: """ The number of additions and removals by rule code """ added_violations: Counter = field(default_factory=Counter) removed_violations: Counter = field(default_factory=Counter) added_fixes: Counter = field(default_factory=Counter) removed_fixes: Counter = field(default_factory=Counter) def rule_codes(self) -> set[str]: return ( set(self.added_violations.keys()) .union(self.removed_violations.keys()) .union(self.added_fixes.keys()) .union(self.removed_fixes.keys()) ) def __add__(self, other: Self) -> Self: if not isinstance(other, type(self)): return NotImplemented new = RuleChanges() new.update(self) new.update(other) return new def update(self, other: Self) -> Self: self.added_violations.update(other.added_violations) self.removed_violations.update(other.removed_violations) self.added_fixes.update(other.added_fixes) self.removed_fixes.update(other.removed_fixes) return self def total_added_violations(self) -> int: return sum(self.added_violations.values()) def total_removed_violations(self) -> int: return sum(self.removed_violations.values()) def total_added_fixes(self) -> int: return sum(self.added_fixes.values()) def total_removed_fixes(self) -> int: return sum(self.removed_fixes.values()) def total_changes_by_rule(self) -> Iterator[str, int]: """ Yields the sum of changes for each rule """ totals = Counter() totals.update(self.added_violations) totals.update(self.removed_violations) totals.update(self.added_fixes) totals.update(self.removed_fixes) yield from totals.items() @classmethod def from_diff(cls: type[Self], diff: CheckDiff) -> Self: """ Parse a diff from `ruff check` to determine the additions and removals for each rule """ rule_changes = cls() for line in diff.parsed_lines: if line.is_added: if line in diff.fix_only_lines: if line.fix_available: rule_changes.added_fixes[line.rule_code] += 1 else: rule_changes.removed_fixes[line.rule_code] += 1 else: rule_changes.added_violations[line.rule_code] += 1 elif line.is_removed: if line in diff.fix_only_lines: if line.fix_available: rule_changes.removed_fixes[line.rule_code] += 1 else: rule_changes.added_fixes[line.rule_code] += 1 else: rule_changes.removed_violations[line.rule_code] += 1 return rule_changes def __bool__(self): return bool(self.added_violations or self.removed_violations) @dataclass(frozen=True) class DiagnosticLine: is_added: bool | None is_removed: bool | None fix_available: bool rule_code: str location: str message: str def to_string(self) -> str: """ Construct the line from the components """ line = "" if self.is_added: line += "+ " elif self.is_removed: line += "- " line += f"{self.location}: {self.rule_code} " if self.fix_available: line += "[*] " line += self.message return line def with_fix_available(self) -> DiagnosticLine: return DiagnosticLine(**{**dataclasses.asdict(self), "fix_available": True}) def without_fix_available(self) -> DiagnosticLine: return DiagnosticLine(**{**dataclasses.asdict(self), "fix_available": False}) def without_diff(self) -> DiagnosticLine: return DiagnosticLine( **{**dataclasses.asdict(self), "is_added": None, "is_removed": None} ) @classmethod def try_from_string(cls: type[Self], line: str) -> Self | None: """ Parse the rule code from a diagnostic line string """ match = CHECK_DIAGNOSTIC_LINE_RE.match(line) if match is None: # Handle case where there are no regex match e.g. # + "?application=AIRFLOW&authenticator=TEST_AUTH&role=TEST_ROLE&warehouse=TEST_WAREHOUSE" # noqa: E501, ERA001 # Which was found in local testing return None match_items = match.groupdict() return DiagnosticLine( location=match_items["location"], is_removed=match_items.get("diff") == "-", is_added=match_items.get("diff") == "+", fix_available=match_items.get("fixable") is not None, rule_code=match_items["code"], message=match_items["message"], ) class CheckDiff(Diff): """ Extends the normal diff with diagnostic parsing """ def __init__( self, lines: Iterable[str], parsed_lines: list[DiagnosticLine], fix_only_lines: set[DiagnosticLine], ) -> None: self.parsed_lines = parsed_lines self.fix_only_lines = fix_only_lines super().__init__(lines) @classmethod def from_simple_diff(cls, diff: Diff) -> CheckDiff: """ Parse a simple diff to include check-specific analyses. """ # Drop unchanged lines diff = diff.without_unchanged_lines() # Sort without account for the leading + / - sorted_lines = list(sorted(diff, key=lambda line: line[2:])) # Parse the lines, drop lines that cannot be parsed parsed_lines: list[DiagnosticLine] = list( filter( None, (DiagnosticLine.try_from_string(line) for line in sorted_lines), ) ) # Calculate which lines only changed fix availability fix_only: set[DiagnosticLine] = set() # TODO(zanieb): There has to be a cleaner way to express this logic # We check if each added line is available in the removed set with fix # availability toggled and vice-versa for line in parsed_lines: other_set = diff.removed if line.is_added else diff.added toggled = ( line.without_fix_available() if line.fix_available else line.with_fix_available() ) if toggled.without_diff().to_string() in other_set: fix_only.add(line) return CheckDiff( lines=sorted_lines, parsed_lines=parsed_lines, fix_only_lines=fix_only ) 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