diff --git a/src/launchpad/cli.py b/src/launchpad/cli.py index ec5a84c5..c969dc2a 100644 --- a/src/launchpad/cli.py +++ b/src/launchpad/cli.py @@ -6,7 +6,7 @@ from . import __version__ from .distribution.cli import distribution_command -from .size.cli import app_icon_command, profile_dex_parsing_command, size_command +from .size.cli import app_icon_command, diff_command, profile_dex_parsing_command, size_command from .utils.console import console from .utils.logging import setup_logging @@ -67,6 +67,7 @@ def worker( cli.add_command(size_command) +cli.add_command(diff_command) cli.add_command(app_icon_command) cli.add_command(distribution_command) cli.add_command(profile_dex_parsing_command) diff --git a/src/launchpad/size/cli.py b/src/launchpad/size/cli.py index 39cdd2d2..039a3ba3 100644 --- a/src/launchpad/size/cli.py +++ b/src/launchpad/size/cli.py @@ -1,3 +1,5 @@ +import json + from pathlib import Path from typing import Dict, TextIO @@ -8,9 +10,11 @@ from launchpad.artifacts.artifact_factory import ArtifactFactory from launchpad.parsers.android.dex.dex_file_parser import DexFileParser from launchpad.parsers.android.dex.dex_mapping import DexMapping +from launchpad.size.diff import compute_diff from launchpad.size.models.android import AndroidAnalysisResults from launchpad.size.models.apple import AppleAnalysisResults from launchpad.size.models.common import BaseAnalysisResults, FileAnalysis +from launchpad.size.models.diff import SizeDiffResults from launchpad.size.runner import do_size, write_results_as_json from launchpad.utils.console import console from launchpad.utils.logging import setup_logging @@ -101,6 +105,69 @@ def size_command( raise click.Abort() +@click.command(name="diff") +@click.argument("base_path", type=click.Path(exists=True, path_type=Path), metavar="BASE") +@click.argument("head_path", type=click.Path(exists=True, path_type=Path), metavar="HEAD") +@click.option( + "-o", + "--output", + default="-", + show_default=True, + type=click.File("w"), + help="Output path for the diff.", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["json", "table"], case_sensitive=False), + default="table", + help="Output format for results.", + show_default=True, +) +@click.option( + "--limit", + type=int, + default=25, + show_default=True, + help="Max number of file changes to show in table output (0 for all).", +) +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging output.") +@click.option("--quiet", "-q", is_flag=True, help="Suppress all output except errors.") +def diff_command( + base_path: Path, + head_path: Path, + output: TextIO, + output_format: str, + limit: int, + verbose: bool, + quiet: bool, +) -> None: + """Compare two artifacts and report the size delta from BASE to HEAD.""" + setup_logging(verbose=verbose, quiet=quiet) + + if verbose and quiet: + raise click.UsageError("Cannot specify both --verbose and --quiet") + + if not quiet: + console.print("[bold blue]Size Diff[/bold blue]") + console.print(f"Base: [cyan]{base_path}[/cyan]") + console.print(f"Head: [cyan]{head_path}[/cyan]") + console.print() + + try: + base_results = do_size(base_path) + head_results = do_size(head_path) + diff = compute_diff(base_results, head_results) + + if output_format == "json": + json.dump(diff.to_dict(), output, ensure_ascii=False, separators=(",", ":")) + else: + _print_diff_as_table(diff, limit) + except Exception: + console.print_exception() + raise click.Abort() + + @click.command(name="app-icon") @click.argument("input_path", type=click.Path(exists=True, path_type=Path), metavar="INPUT_PATH") @click.option( @@ -225,3 +292,62 @@ def _format_bytes(size: int) -> str: return f"{size_float:.1f} {unit}" size_float /= 1024.0 return f"{size_float:.1f} TB" + + +def _format_delta(size: int) -> str: + """Format a signed byte delta with a color tag (green shrink, red growth).""" + if size == 0: + return "[dim]0 B[/dim]" + sign = "+" if size > 0 else "-" + color = "red" if size > 0 else "green" + return f"[{color}]{sign}{_format_bytes(abs(size))}[/{color}]" + + +def _print_diff_as_table(diff: SizeDiffResults, limit: int) -> None: + summary = Table(title=f"Size Diff: {diff.app_name}", show_header=True, header_style="bold magenta") + summary.add_column("Metric", style="cyan") + summary.add_column("Base", justify="right") + summary.add_column("Head", justify="right") + summary.add_column("Change", justify="right") + + summary.add_row( + "Install size", + _format_bytes(diff.base_install_size), + _format_bytes(diff.head_install_size), + _format_delta(diff.install_size_diff), + ) + summary.add_row( + "Download size", + _format_bytes(diff.base_download_size), + _format_bytes(diff.head_download_size), + _format_delta(diff.download_size_diff), + ) + summary.caption = f"{diff.base_label} → {diff.head_label}" + console.print(summary) + console.print() + + if diff.category_diffs: + category_table = Table(title="Category Changes", show_header=True, header_style="bold green") + category_table.add_column("Category", style="cyan") + category_table.add_column("Change", justify="right") + for category in diff.category_diffs: + category_table.add_row(category.category, _format_delta(category.size_diff)) + console.print(category_table) + console.print() + + if not diff.file_changes: + console.print("[dim]No file-level changes.[/dim]") + return + + shown = diff.file_changes if limit <= 0 else diff.file_changes[:limit] + file_table = Table(title="File Changes", show_header=True, header_style="bold green") + file_table.add_column("Change", justify="right") + file_table.add_column("Kind") + file_table.add_column("Path", style="cyan", overflow="fold") + for change in shown: + file_table.add_row(_format_delta(change.size_diff), change.kind.value, change.path) + console.print(file_table) + + hidden = len(diff.file_changes) - len(shown) + if hidden > 0: + console.print(f"[dim]... and {hidden} more file change(s). Use --limit 0 to show all.[/dim]") diff --git a/src/launchpad/size/diff.py b/src/launchpad/size/diff.py new file mode 100644 index 00000000..f096aca0 --- /dev/null +++ b/src/launchpad/size/diff.py @@ -0,0 +1,89 @@ +"""Compute size differences between two analyses.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Dict + +from launchpad.size.models.common import BaseAnalysisResults, FileInfo +from launchpad.size.models.diff import CategoryDiff, ChangeKind, FileChange, SizeDiffResults + + +def _build_label(results: BaseAnalysisResults) -> str: + app_info = getattr(results, "app_info", None) + if app_info is None: + return "unknown" + return f"{app_info.version} ({app_info.build})" + + +def _category_totals(results: BaseAnalysisResults) -> Dict[str, int]: + totals: Dict[str, int] = defaultdict(int) + for item in results.file_analysis.items: + if item.is_dir: + continue + totals[item.treemap_type.value] += item.size + return totals + + +def _file_sizes(results: BaseAnalysisResults) -> Dict[str, FileInfo]: + # Top-level files only; nested children (e.g. assets inside a .car) roll up into their parent. + return {item.path: item for item in results.file_analysis.items if not item.is_dir} + + +def compute_diff(base: BaseAnalysisResults, head: BaseAnalysisResults) -> SizeDiffResults: + """Compare two size analyses and return the deltas from ``base`` to ``head``. + + Files are matched by path. A file present in both builds with a different content + hash is reported as modified; hashes that match are omitted (no size change). + """ + base_categories = _category_totals(base) + head_categories = _category_totals(head) + + category_diffs = [ + CategoryDiff( + category=category, + head_size=head_categories.get(category, 0), + base_size=base_categories.get(category, 0), + ) + for category in base_categories.keys() | head_categories.keys() + ] + category_diffs = [c for c in category_diffs if c.size_diff != 0] + category_diffs.sort(key=lambda c: abs(c.size_diff), reverse=True) + + base_files = _file_sizes(base) + head_files = _file_sizes(head) + + file_changes: list[FileChange] = [] + for path in base_files.keys() | head_files.keys(): + base_file = base_files.get(path) + head_file = head_files.get(path) + + if base_file is None and head_file is not None: + file_changes.append(FileChange(path=path, kind=ChangeKind.ADDED, head_size=head_file.size, base_size=0)) + elif head_file is None and base_file is not None: + file_changes.append(FileChange(path=path, kind=ChangeKind.REMOVED, head_size=0, base_size=base_file.size)) + elif base_file is not None and head_file is not None: + if base_file.hash == head_file.hash and base_file.size == head_file.size: + continue + file_changes.append( + FileChange( + path=path, + kind=ChangeKind.MODIFIED, + head_size=head_file.size, + base_size=base_file.size, + ) + ) + + file_changes.sort(key=lambda f: (abs(f.size_diff), f.path), reverse=True) + + return SizeDiffResults( + app_name=getattr(getattr(head, "app_info", None), "name", "unknown"), + base_label=_build_label(base), + head_label=_build_label(head), + base_install_size=base.install_size, + head_install_size=head.install_size, + base_download_size=base.download_size, + head_download_size=head.download_size, + category_diffs=category_diffs, + file_changes=file_changes, + ) diff --git a/src/launchpad/size/models/diff.py b/src/launchpad/size/models/diff.py new file mode 100644 index 00000000..743fdeeb --- /dev/null +++ b/src/launchpad/size/models/diff.py @@ -0,0 +1,90 @@ +"""Models for size comparisons between two analyses.""" + +from __future__ import annotations + +from enum import Enum +from typing import Dict, List + +from pydantic import BaseModel, ConfigDict, Field + + +class ChangeKind(str, Enum): + """How a file changed between two analyses.""" + + ADDED = "added" + REMOVED = "removed" + MODIFIED = "modified" + + +class FileChange(BaseModel): + """A single file that was added, removed, or modified between two builds.""" + + model_config = ConfigDict(frozen=True) + + path: str = Field(..., description="Relative path in the bundle") + kind: ChangeKind = Field(..., description="Whether the file was added, removed, or modified") + head_size: int = Field(..., ge=0, description="Size in the new (head) build, 0 if removed") + base_size: int = Field(..., ge=0, description="Size in the old (base) build, 0 if added") + + @property + def size_diff(self) -> int: + """Signed size delta (head - base) in bytes.""" + return self.head_size - self.base_size + + +class CategoryDiff(BaseModel): + """Size delta for a single treemap category.""" + + model_config = ConfigDict(frozen=True) + + category: str = Field(..., description="Treemap category name") + head_size: int = Field(..., ge=0, description="Category size in the new (head) build") + base_size: int = Field(..., ge=0, description="Category size in the old (base) build") + + @property + def size_diff(self) -> int: + """Signed size delta (head - base) in bytes.""" + return self.head_size - self.base_size + + +class SizeDiffResults(BaseModel): + """Result of comparing two size analyses (base -> head).""" + + model_config = ConfigDict(frozen=True) + + app_name: str = Field(..., description="App display name (from the head build)") + base_label: str = Field(..., description="Human-readable label for the base build, e.g. version (build)") + head_label: str = Field(..., description="Human-readable label for the head build, e.g. version (build)") + + base_install_size: int = Field(..., ge=0, description="Install size of the base build in bytes") + head_install_size: int = Field(..., ge=0, description="Install size of the head build in bytes") + base_download_size: int = Field(..., ge=0, description="Download size of the base build in bytes") + head_download_size: int = Field(..., ge=0, description="Download size of the head build in bytes") + + category_diffs: List[CategoryDiff] = Field( + default_factory=list, description="Per-category size deltas, largest absolute change first" + ) + file_changes: List[FileChange] = Field( + default_factory=list, description="Per-file changes, largest absolute change first" + ) + + @property + def install_size_diff(self) -> int: + """Signed install size delta (head - base) in bytes.""" + return self.head_install_size - self.base_install_size + + @property + def download_size_diff(self) -> int: + """Signed download size delta (head - base) in bytes.""" + return self.head_download_size - self.base_download_size + + def to_dict(self) -> Dict[str, object]: + """Convert to a JSON-serializable dictionary including computed deltas.""" + data = self.model_dump() + data["install_size_diff"] = self.install_size_diff + data["download_size_diff"] = self.download_size_diff + for category, model in zip(data["category_diffs"], self.category_diffs): + category["size_diff"] = model.size_diff + for change, model in zip(data["file_changes"], self.file_changes): + change["size_diff"] = model.size_diff + return data diff --git a/tests/unit/size/test_diff.py b/tests/unit/size/test_diff.py new file mode 100644 index 00000000..996fe2f6 --- /dev/null +++ b/tests/unit/size/test_diff.py @@ -0,0 +1,111 @@ +from pathlib import Path + +from launchpad.size.diff import compute_diff +from launchpad.size.models.android import AndroidAnalysisResults, AndroidAppInfo +from launchpad.size.models.common import FileAnalysis, FileInfo +from launchpad.size.models.diff import ChangeKind +from launchpad.size.models.treemap import TreemapType + + +def _file(path: str, size: int, hash: str, treemap_type: TreemapType = TreemapType.RESOURCES) -> FileInfo: + return FileInfo( + full_path=Path(path), + path=path, + size=size, + file_type=path.split(".")[-1], + treemap_type=treemap_type, + hash=hash, + is_dir=False, + ) + + +def _results(files: list[FileInfo], version: str, build: str) -> AndroidAnalysisResults: + file_analysis = FileAnalysis(items=files) + total = file_analysis.total_size + return AndroidAnalysisResults( + analysis_version="1.0.0", + file_analysis=file_analysis, + treemap=None, + download_size=total, + install_size=total, + app_info=AndroidAppInfo(name="MyApp", version=version, build=build, app_id="com.example.myapp"), + insights=None, + ) + + +class TestComputeDiff: + def test_added_removed_modified_and_unchanged(self): + base = _results( + [ + _file("assets/logo.png", 1000, "a", TreemapType.ASSETS), + _file("lib/removed.so", 500, "b", TreemapType.NATIVE_LIBRARIES), + _file("res/stable.xml", 300, "c"), + ], + version="1.0.0", + build="100", + ) + head = _results( + [ + _file("assets/logo.png", 2000, "a2", TreemapType.ASSETS), # modified (grew) + _file("res/stable.xml", 300, "c"), # unchanged + _file("lib/added.so", 800, "d", TreemapType.NATIVE_LIBRARIES), # added + ], + version="1.1.0", + build="101", + ) + + diff = compute_diff(base, head) + + assert diff.app_name == "MyApp" + assert diff.base_label == "1.0.0 (100)" + assert diff.head_label == "1.1.0 (101)" + + # base total = 1800, head total = 3100 + assert diff.install_size_diff == 1300 + assert diff.download_size_diff == 1300 + + by_path = {c.path: c for c in diff.file_changes} + assert by_path["assets/logo.png"].kind == ChangeKind.MODIFIED + assert by_path["assets/logo.png"].size_diff == 1000 + assert by_path["lib/added.so"].kind == ChangeKind.ADDED + assert by_path["lib/added.so"].size_diff == 800 + assert by_path["lib/removed.so"].kind == ChangeKind.REMOVED + assert by_path["lib/removed.so"].size_diff == -500 + assert "res/stable.xml" not in by_path # unchanged files are omitted + + # Sorted by absolute size delta, largest first + assert [c.size_diff for c in diff.file_changes] == [1000, 800, -500] + + def test_category_diffs(self): + base = _results([_file("assets/a.png", 1000, "a", TreemapType.ASSETS)], "1.0.0", "1") + head = _results( + [ + _file("assets/a.png", 1500, "a2", TreemapType.ASSETS), + _file("lib/new.so", 400, "n", TreemapType.NATIVE_LIBRARIES), + ], + "1.0.1", + "2", + ) + + diff = compute_diff(base, head) + by_cat = {c.category: c.size_diff for c in diff.category_diffs} + assert by_cat[TreemapType.ASSETS.value] == 500 + assert by_cat[TreemapType.NATIVE_LIBRARIES.value] == 400 + + def test_identical_builds_have_no_changes(self): + files = [_file("assets/a.png", 1000, "a", TreemapType.ASSETS)] + diff = compute_diff(_results(files, "1.0.0", "1"), _results(files, "1.0.0", "1")) + + assert diff.file_changes == [] + assert diff.category_diffs == [] + assert diff.install_size_diff == 0 + + def test_to_dict_includes_computed_deltas(self): + base = _results([_file("a.txt", 100, "a")], "1.0.0", "1") + head = _results([_file("a.txt", 250, "b")], "1.0.1", "2") + + data = compute_diff(base, head).to_dict() + assert data["install_size_diff"] == 150 + assert data["download_size_diff"] == 150 + assert data["file_changes"][0]["size_diff"] == 150 + assert data["file_changes"][0]["kind"] == "modified" diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index dbaa4d4d..161f8317 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -33,6 +33,23 @@ def test_analyze_help(self) -> None: assert "size" in result.output assert "INPUT_PATH" in result.output + def test_diff_help(self) -> None: + """Test diff command help lists both artifact arguments.""" + runner = CliRunner() + result = runner.invoke(cli, ["diff", "--help"]) + + assert result.exit_code == 0 + assert "BASE" in result.output + assert "HEAD" in result.output + + def test_diff_missing_arguments(self) -> None: + """Test diff command fails when artifacts are not provided.""" + runner = CliRunner() + result = runner.invoke(cli, ["diff"]) + + assert result.exit_code != 0 + assert "Missing argument" in result.output or "Error" in result.output + def test_analyze_missing_input(self) -> None: """Test analyze command fails with missing input.""" runner = CliRunner()