Last active
October 6, 2025 13:41
-
-
Save zsol/23532b9755e7917e7097e4441e0b9aa6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run | |
| # /// script | |
| # requires-python = ">=3.13" | |
| # dependencies = [ | |
| # "rich", | |
| # "typer", | |
| # ] | |
| # /// | |
| """Compare two revisions of a uv.lock file and summarize changes.""" | |
| import json | |
| import subprocess | |
| import tomllib | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| import typer | |
| from rich.console import Console | |
| from rich.panel import Panel | |
| from rich.table import Table | |
| @dataclass(frozen=True, slots=True) | |
| class Package: | |
| """Represents a package in uv.lock.""" | |
| name: str | |
| version: str | |
| source: dict | |
| sdist: dict | None | |
| wheels: list[dict] | |
| @classmethod | |
| def from_dict(cls, data: dict) -> "Package": | |
| """Create a Package from a TOML dictionary.""" | |
| return cls( | |
| name=data["name"], | |
| version=data["version"], | |
| source=data.get("source", {}), | |
| sdist=data.get("sdist"), | |
| wheels=data.get("wheels", []), | |
| ) | |
| def has_distribution_changes(self, other: "Package") -> bool: | |
| """Check if distribution artifacts changed (source, sdist, wheels).""" | |
| if self.source != other.source: | |
| return True | |
| if self.sdist != other.sdist: | |
| return True | |
| if self.wheels != other.wheels: | |
| return True | |
| return False | |
| def describe_changes(self, other: "Package") -> list[str]: | |
| """Describe what changed between this package and another.""" | |
| changes = [] | |
| if self.source != other.source: | |
| changes.append("source") | |
| if self.sdist != other.sdist: | |
| changes.append("sdist") | |
| if self.wheels != other.wheels: | |
| changes.append("wheels") | |
| return changes | |
| def parse_lock_file(content: str) -> dict[str, Package]: | |
| """Parse a uv.lock file and return a mapping of package names to Package objects.""" | |
| data = tomllib.loads(content) | |
| packages = {} | |
| for pkg_data in data.get("package", []): | |
| pkg = Package.from_dict(pkg_data) | |
| packages[pkg.name] = pkg | |
| return packages | |
| def get_file_content(revision: str, file_path: str) -> str: | |
| """Get the content of a file at a specific git revision.""" | |
| # Get the git repo root. | |
| repo_root_result = subprocess.run( | |
| ["git", "rev-parse", "--show-toplevel"], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| repo_root = Path(repo_root_result.stdout.strip()) | |
| # Convert file_path to be relative to repo root. | |
| abs_file_path = Path(file_path).resolve() | |
| repo_relative_path = abs_file_path.relative_to(repo_root) | |
| if revision == "HEAD" or revision.startswith("HEAD"): | |
| # For HEAD or working tree, just read the file. | |
| if revision == "HEAD": | |
| result = subprocess.run( | |
| ["git", "show", f"HEAD:{repo_relative_path}"], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| return result.stdout | |
| # Working tree. | |
| return Path(file_path).read_text() | |
| # For other revisions, use git show. | |
| result = subprocess.run( | |
| ["git", "show", f"{revision}:{repo_relative_path}"], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| return result.stdout | |
| def summarize_changes( | |
| old_packages: dict[str, Package], | |
| new_packages: dict[str, Package], | |
| json_output: bool = False, | |
| ) -> None: | |
| """Print a summary of changes between two sets of packages.""" | |
| old_names = set(old_packages.keys()) | |
| new_names = set(new_packages.keys()) | |
| added = new_names - old_names | |
| removed = old_names - new_names | |
| common = old_names & new_names | |
| # Check for version changes and distribution changes. | |
| updated = [] | |
| dist_changed = [] | |
| for name in sorted(common): | |
| old_pkg = old_packages[name] | |
| new_pkg = new_packages[name] | |
| if old_pkg.version != new_pkg.version: | |
| updated.append((name, old_pkg.version, new_pkg.version)) | |
| elif old_pkg.has_distribution_changes(new_pkg): | |
| changes = old_pkg.describe_changes(new_pkg) | |
| dist_changed.append((name, old_pkg.version, changes)) | |
| # Output as JSON if requested. | |
| if json_output: | |
| output = { | |
| "summary": { | |
| "total": len(added) + len(removed) + len(updated) + len(dist_changed), | |
| "added": len(added), | |
| "removed": len(removed), | |
| "updated": len(updated), | |
| "distribution_changed": len(dist_changed), | |
| }, | |
| "added": [ | |
| {"name": name, "version": new_packages[name].version} | |
| for name in sorted(added) | |
| ], | |
| "removed": [ | |
| {"name": name, "version": old_packages[name].version} | |
| for name in sorted(removed) | |
| ], | |
| "updated": [ | |
| {"name": name, "old_version": old_version, "new_version": new_version} | |
| for name, old_version, new_version in updated | |
| ], | |
| "distribution_changed": [ | |
| {"name": name, "version": version, "changes": changes} | |
| for name, version, changes in dist_changed | |
| ], | |
| } | |
| print(json.dumps(output, indent=2)) | |
| return | |
| console = Console() | |
| # Print summary header. | |
| total_changes = len(added) + len(removed) + len(updated) + len(dist_changed) | |
| if total_changes == 0: | |
| console.print(Panel("No changes detected.", style="dim")) | |
| return | |
| summary_text = f"[bold]Total changes:[/bold] {total_changes} " | |
| summary_parts = [] | |
| if added: | |
| summary_parts.append(f"[green]{len(added)} added[/green]") | |
| if removed: | |
| summary_parts.append(f"[red]{len(removed)} removed[/red]") | |
| if updated: | |
| summary_parts.append(f"[yellow]{len(updated)} updated[/yellow]") | |
| if dist_changed: | |
| summary_parts.append(f"[blue]{len(dist_changed)} distribution changed[/blue]") | |
| summary_text += "(" + ", ".join(summary_parts) + ")" | |
| console.print(Panel(summary_text, style="bold")) | |
| console.print() | |
| # Print added packages. | |
| if added: | |
| table = Table(title="Added Packages", title_style="bold green") | |
| table.add_column("Package", style="cyan", no_wrap=True) | |
| table.add_column("Version", style="green") | |
| for name in sorted(added): | |
| pkg = new_packages[name] | |
| table.add_row(name, pkg.version) | |
| console.print(table) | |
| console.print() | |
| # Print removed packages. | |
| if removed: | |
| table = Table(title="Removed Packages", title_style="bold red") | |
| table.add_column("Package", style="cyan", no_wrap=True) | |
| table.add_column("Version", style="red") | |
| for name in sorted(removed): | |
| pkg = old_packages[name] | |
| table.add_row(name, pkg.version) | |
| console.print(table) | |
| console.print() | |
| # Print updated packages. | |
| if updated: | |
| table = Table(title="Updated Packages", title_style="bold yellow") | |
| table.add_column("Package", style="cyan", no_wrap=True) | |
| table.add_column("Old Version", style="red") | |
| table.add_column("New Version", style="green") | |
| for name, old_version, new_version in updated: | |
| table.add_row(name, old_version, new_version) | |
| console.print(table) | |
| console.print() | |
| # Print distribution changes. | |
| if dist_changed: | |
| table = Table(title="Distribution Changes", title_style="bold blue") | |
| table.add_column("Package", style="cyan", no_wrap=True) | |
| table.add_column("Version", style="dim") | |
| table.add_column("Changed", style="blue") | |
| for name, version, changes in dist_changed: | |
| table.add_row(name, version, ", ".join(changes)) | |
| console.print(table) | |
| console.print() | |
| app = typer.Typer() | |
| @app.command() | |
| def main( | |
| old_revision: str = typer.Argument( | |
| ..., help="Old revision (git ref, commit SHA, or 'WORKTREE' for working tree)" | |
| ), | |
| new_revision: str = typer.Argument( | |
| ..., help="New revision (git ref, commit SHA, or 'WORKTREE' for working tree)" | |
| ), | |
| file: str = typer.Option("uv.lock", "--file", help="Path to uv.lock file"), | |
| json_output: bool = typer.Option( | |
| False, "--json", help="Output in JSON format for machine-readable processing" | |
| ), | |
| ) -> None: | |
| """Compare two revisions of a uv.lock file and summarize changes.""" | |
| console = Console() | |
| try: | |
| # Get file contents. | |
| if old_revision == "WORKTREE": | |
| old_content = Path(file).read_text() | |
| else: | |
| old_content = get_file_content(old_revision, file) | |
| if new_revision == "WORKTREE": | |
| new_content = Path(file).read_text() | |
| else: | |
| new_content = get_file_content(new_revision, file) | |
| # Parse lock files. | |
| old_packages = parse_lock_file(old_content) | |
| new_packages = parse_lock_file(new_content) | |
| # Summarize changes. | |
| summarize_changes(old_packages, new_packages, json_output=json_output) | |
| except subprocess.CalledProcessError as e: | |
| console.print( | |
| f"[bold red]Error:[/bold red] Failed to get file content: {e.stderr}" | |
| ) | |
| raise typer.Exit(1) from e | |
| except Exception as e: | |
| console.print(f"[bold red]Error:[/bold red] {e}") | |
| raise typer.Exit(1) from e | |
| if __name__ == "__main__": | |
| app() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment