Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/launchpad/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
126 changes: 126 additions & 0 deletions src/launchpad/size/cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

from pathlib import Path
from typing import Dict, TextIO

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]")
89 changes: 89 additions & 0 deletions src/launchpad/size/diff.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty hash skips real changes

Medium Severity

For paths present in both builds, compute_diff skips reporting when hash and size both match. Android analysis sets hash to an empty string for merged entries (e.g. combined DEX or duplicate paths), so two builds can differ in content while still being treated as unchanged when merged sizes match.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bf5cc20. Configure here.

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,
)
90 changes: 90 additions & 0 deletions src/launchpad/size/models/diff.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading