|
| 1 | +"""Compare sizes of newly-built dists against the latest release on PyPI. |
| 2 | +
|
| 3 | +Fetches file sizes for the latest Pillow release from the PyPI JSON API |
| 4 | +(no download required) and compares them to a directory of freshly-built |
| 5 | +wheels and sdist. Outputs a table to stdout (and to |
| 6 | +`$GITHUB_STEP_SUMMARY` if set). |
| 7 | +
|
| 8 | +Usage: |
| 9 | + `uv run .github/compare-dist-sizes.py <dist-dir>` |
| 10 | +""" |
| 11 | + |
| 12 | +# /// script |
| 13 | +# requires-python = ">=3.10" |
| 14 | +# dependencies = [ |
| 15 | +# "humanize", |
| 16 | +# "prettytable>=3.16", |
| 17 | +# "termcolor", |
| 18 | +# ] |
| 19 | +# /// |
| 20 | + |
| 21 | +from __future__ import annotations |
| 22 | + |
| 23 | +import argparse |
| 24 | +import json |
| 25 | +import os |
| 26 | +import re |
| 27 | +import sys |
| 28 | +import urllib.request |
| 29 | +from pathlib import Path |
| 30 | + |
| 31 | +import humanize |
| 32 | +from prettytable import PrettyTable, TableStyle |
| 33 | +from termcolor import colored |
| 34 | + |
| 35 | +PYPI_JSON_URL = "https://pypi.org/pypi/pillow/json" |
| 36 | + |
| 37 | +# Wheel filename: {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl |
| 38 | +# sdist filename: {distribution}-{version}.tar.gz |
| 39 | +WHEEL_RE = re.compile( |
| 40 | + r"^(?P<dist>[^-]+)-(?P<version>[^-]+)" |
| 41 | + r"(?:-(?P<build>\d[^-]*))?" |
| 42 | + r"-(?P<python>[^-]+)-(?P<abi>[^-]+)-(?P<platform>[^-]+)\.whl$", |
| 43 | + re.IGNORECASE, |
| 44 | +) |
| 45 | +SDIST_RE = re.compile( |
| 46 | + r"^(?P<dist>[^-]+)-(?P<version>.+)\.tar\.gz$", |
| 47 | + re.IGNORECASE, |
| 48 | +) |
| 49 | + |
| 50 | + |
| 51 | +def key_for(filename: str) -> str | None: |
| 52 | + """Return a version-independent identifier for a dist file.""" |
| 53 | + m = WHEEL_RE.match(filename) |
| 54 | + if m: |
| 55 | + build = f"-{m['build']}" if m["build"] else "" |
| 56 | + return f"wheel:{build}-{m['python']}-{m['abi']}-{m['platform']}" |
| 57 | + m = SDIST_RE.match(filename) |
| 58 | + if m: |
| 59 | + return "sdist" |
| 60 | + return None |
| 61 | + |
| 62 | + |
| 63 | +def display_for(filename: str) -> str: |
| 64 | + """Strip the `pillow-{version}-` prefix for compact table display.""" |
| 65 | + m = WHEEL_RE.match(filename) |
| 66 | + if m: |
| 67 | + build = f"{m['build']}-" if m["build"] else "" |
| 68 | + return f"{build}{m['python']}-{m['abi']}-{m['platform']}.whl" |
| 69 | + if SDIST_RE.match(filename): |
| 70 | + return "sdist (.tar.gz)" |
| 71 | + return filename |
| 72 | + |
| 73 | + |
| 74 | +def fetch_pypi_sizes() -> tuple[str, dict[str, tuple[str, int]]]: |
| 75 | + """Return (version, {key: (filename, size)}) for the latest PyPI release.""" |
| 76 | + with urllib.request.urlopen(PYPI_JSON_URL) as response: |
| 77 | + data = json.load(response) |
| 78 | + version = data["info"]["version"] |
| 79 | + sizes: dict[str, tuple[str, int]] = {} |
| 80 | + for entry in data.get("urls", []): |
| 81 | + filename = entry["filename"] |
| 82 | + key = key_for(filename) |
| 83 | + if key is None: |
| 84 | + continue |
| 85 | + sizes[key] = (filename, entry["size"]) |
| 86 | + return version, sizes |
| 87 | + |
| 88 | + |
| 89 | +def collect_local_sizes(dist_dir: Path) -> dict[str, tuple[str, int]]: |
| 90 | + sizes: dict[str, tuple[str, int]] = {} |
| 91 | + for path in sorted(dist_dir.iterdir()): |
| 92 | + if not path.is_file(): |
| 93 | + continue |
| 94 | + key = key_for(path.name) |
| 95 | + if key is None: |
| 96 | + continue |
| 97 | + sizes[key] = (path.name, path.stat().st_size) |
| 98 | + return sizes |
| 99 | + |
| 100 | + |
| 101 | +def human(n: int | None) -> str: |
| 102 | + if n is None: |
| 103 | + return "n/a" |
| 104 | + return humanize.naturalsize(n) |
| 105 | + |
| 106 | + |
| 107 | +def pct_change(before: int | None, after: int | None) -> str: |
| 108 | + if before is None or after is None: |
| 109 | + return "n/a" |
| 110 | + if before == 0: |
| 111 | + return "n/a" |
| 112 | + delta = (after - before) / before * 100 |
| 113 | + return f"{delta:+.2f}%" |
| 114 | + |
| 115 | + |
| 116 | +def render_table( |
| 117 | + baseline_label: str, |
| 118 | + baseline_sizes: dict[str, tuple[str, int]], |
| 119 | + local_sizes: dict[str, tuple[str, int]], |
| 120 | + *, |
| 121 | + markdown: bool, |
| 122 | +) -> str: |
| 123 | + color = not markdown |
| 124 | + table = PrettyTable() |
| 125 | + table.set_style(TableStyle.MARKDOWN if markdown else TableStyle.SINGLE_BORDER) |
| 126 | + table.field_names = ["File", "Size before", "Size now", "Change"] |
| 127 | + table.align = "r" |
| 128 | + table.align["File"] = "l" |
| 129 | + |
| 130 | + def pct_severity(text: str) -> str | None: |
| 131 | + """Return "good" / "warn" / "bad" based on the change percent.""" |
| 132 | + if text == "n/a": |
| 133 | + return None |
| 134 | + pct = float(text.rstrip("%")) |
| 135 | + if pct <= 0: |
| 136 | + return "good" |
| 137 | + if pct >= 5: |
| 138 | + return "bad" |
| 139 | + if pct >= 1: |
| 140 | + return "warn" |
| 141 | + return None |
| 142 | + |
| 143 | + ANSI_COLORS = {"good": "green", "warn": "yellow", "bad": "red"} |
| 144 | + EMOJI = {"good": "🟢", "warn": "🟡", "bad": "🔴"} |
| 145 | + |
| 146 | + def style(cells: list[str], role: str) -> list[str]: |
| 147 | + severity = pct_severity(cells[3]) |
| 148 | + if markdown: |
| 149 | + if severity: |
| 150 | + cells[3] = f"{EMOJI[severity]} {cells[3]}" |
| 151 | + if role == "orphan": |
| 152 | + return [f"*{c}*" for c in cells] |
| 153 | + if role == "summary": |
| 154 | + return [f"**{c}**" for c in cells] |
| 155 | + return cells |
| 156 | + if role == "orphan": |
| 157 | + return [colored(c, "dark_grey") for c in cells] |
| 158 | + bold_attrs = ["bold"] if role == "summary" else [] |
| 159 | + if severity: |
| 160 | + cells[3] = colored(cells[3], ANSI_COLORS[severity], attrs=bold_attrs) |
| 161 | + elif bold_attrs: |
| 162 | + cells[3] = colored(cells[3], attrs=bold_attrs) |
| 163 | + if bold_attrs: |
| 164 | + cells[:3] = [colored(c, attrs=bold_attrs) for c in cells[:3]] |
| 165 | + return cells |
| 166 | + |
| 167 | + keys = sorted(set(baseline_sizes) | set(local_sizes)) |
| 168 | + # Put sdist first for readability |
| 169 | + keys.sort(key=lambda k: (k != "sdist", k)) |
| 170 | + |
| 171 | + wheel_before = 0 |
| 172 | + wheel_after = 0 |
| 173 | + total_before = 0 |
| 174 | + total_after = 0 |
| 175 | + wheel_before_count = 0 |
| 176 | + wheel_after_count = 0 |
| 177 | + total_after_count = 0 |
| 178 | + for i, key in enumerate(keys): |
| 179 | + baseline_entry = baseline_sizes.get(key) |
| 180 | + local_entry = local_sizes.get(key) |
| 181 | + display_name = display_for((local_entry or baseline_entry)[0]) |
| 182 | + before = baseline_entry[1] if baseline_entry else None |
| 183 | + after = local_entry[1] if local_entry else None |
| 184 | + if after is None: |
| 185 | + # Removed since baseline: ignore in totals |
| 186 | + role = "orphan" |
| 187 | + else: |
| 188 | + # Present locally (in both, or newly added): count in totals |
| 189 | + total_after += after |
| 190 | + total_after_count += 1 |
| 191 | + if before is not None: |
| 192 | + total_before += before |
| 193 | + if key != "sdist": |
| 194 | + wheel_after += after |
| 195 | + wheel_after_count += 1 |
| 196 | + if before is not None: |
| 197 | + wheel_before += before |
| 198 | + wheel_before_count += 1 |
| 199 | + role = "data" |
| 200 | + cells = [ |
| 201 | + display_name, |
| 202 | + human(before), |
| 203 | + human(after), |
| 204 | + pct_change(before, after), |
| 205 | + ] |
| 206 | + table.add_row(style(cells, role)) |
| 207 | + |
| 208 | + if not markdown: |
| 209 | + table.add_divider() |
| 210 | + |
| 211 | + if wheel_after_count: |
| 212 | + avg_before = wheel_before // wheel_before_count if wheel_before_count else None |
| 213 | + table.add_row( |
| 214 | + style( |
| 215 | + [ |
| 216 | + f"wheel average ({wheel_after_count} wheels)", |
| 217 | + human(avg_before), |
| 218 | + human(wheel_after // wheel_after_count), |
| 219 | + pct_change(avg_before, wheel_after // wheel_after_count), |
| 220 | + ], |
| 221 | + "summary", |
| 222 | + ) |
| 223 | + ) |
| 224 | + table.add_row( |
| 225 | + style( |
| 226 | + [ |
| 227 | + f"wheel total ({wheel_after_count} wheels)", |
| 228 | + human(wheel_before), |
| 229 | + human(wheel_after), |
| 230 | + pct_change(wheel_before, wheel_after), |
| 231 | + ], |
| 232 | + "summary", |
| 233 | + ), |
| 234 | + divider=not markdown, |
| 235 | + ) |
| 236 | + |
| 237 | + if total_after_count: |
| 238 | + table.add_row( |
| 239 | + style( |
| 240 | + [ |
| 241 | + f"artifacts total ({total_after_count} artifacts)", |
| 242 | + human(total_before), |
| 243 | + human(total_after), |
| 244 | + pct_change(total_before, total_after), |
| 245 | + ], |
| 246 | + "summary", |
| 247 | + ) |
| 248 | + ) |
| 249 | + |
| 250 | + title = f"## Dist size comparison vs {baseline_label}" |
| 251 | + if color: |
| 252 | + title = colored(title, attrs=["bold"]) |
| 253 | + return f"{title}\n\n{table.get_string()}\n" |
| 254 | + |
| 255 | + |
| 256 | +def main() -> int: |
| 257 | + parser = argparse.ArgumentParser( |
| 258 | + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter |
| 259 | + ) |
| 260 | + parser.add_argument( |
| 261 | + "dist_dir", |
| 262 | + type=Path, |
| 263 | + help="Directory containing newly-built wheels and sdist", |
| 264 | + ) |
| 265 | + args = parser.parse_args() |
| 266 | + |
| 267 | + if not args.dist_dir.is_dir(): |
| 268 | + print(f"error: {args.dist_dir} is not a directory", file=sys.stderr) |
| 269 | + return 1 |
| 270 | + |
| 271 | + baseline_version, baseline_sizes = fetch_pypi_sizes() |
| 272 | + baseline_label = f"Pillow {baseline_version} on PyPI" |
| 273 | + |
| 274 | + local_sizes = collect_local_sizes(args.dist_dir) |
| 275 | + |
| 276 | + print(render_table(baseline_label, baseline_sizes, local_sizes, markdown=False)) |
| 277 | + |
| 278 | + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") |
| 279 | + if summary_path: |
| 280 | + with open(summary_path, "a", encoding="utf-8") as f: |
| 281 | + f.write( |
| 282 | + render_table(baseline_label, baseline_sizes, local_sizes, markdown=True) |
| 283 | + ) |
| 284 | + |
| 285 | + return 0 |
| 286 | + |
| 287 | + |
| 288 | +if __name__ == "__main__": |
| 289 | + sys.exit(main()) |
0 commit comments