Skip to content

Commit 890e5f4

Browse files
committed
Compare dist sizes vs latest PyPI release
1 parent 8261432 commit 890e5f4

2 files changed

Lines changed: 312 additions & 0 deletions

File tree

.github/compare-dist-sizes.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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())

.github/workflows/wheels.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ on:
1313
paths: &paths
1414
- ".ci/requirements-cibw.txt"
1515
- ".ci/requirements-sbom.txt"
16+
- ".github/compare-dist-sizes.py"
1617
- ".github/dependencies.json"
1718
- ".github/generate-sbom.py"
1819
- ".github/workflows/wheels*"
@@ -255,6 +256,28 @@ jobs:
255256
echo $files
256257
[ "$files" -eq $EXPECTED_DISTS ] || exit 1
257258
259+
compare-dist-sizes:
260+
needs: [build-native-wheels, windows, sdist]
261+
runs-on: ubuntu-latest
262+
name: Compare dist sizes vs PyPI
263+
steps:
264+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
265+
with:
266+
persist-credentials: false
267+
268+
- uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6.10.1
269+
with:
270+
enable-cache: false
271+
272+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
273+
with:
274+
pattern: dist-*
275+
path: dist
276+
merge-multiple: true
277+
278+
- name: Compare dist sizes vs latest PyPI release
279+
run: uv run .github/compare-dist-sizes.py dist
280+
258281
scientific-python-nightly-wheels-publish:
259282
if: github.event.repository.fork == false && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
260283
needs: count-dists

0 commit comments

Comments
 (0)