Created
April 10, 2026 16:57
-
-
Save rjpower/a77b63a8d73a16ced68c88c13a9e433f to your computer and use it in GitHub Desktop.
wheel-inspect: CLI tool to inspect Python wheel files (version, deps, file summary)
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 python3 | |
| """Inspect Python wheel files: version info, file summary, dependencies, etc.""" | |
| import argparse | |
| import os | |
| import sys | |
| import zipfile | |
| from collections import Counter | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| def parse_metadata(raw: str) -> dict[str, list[str]]: | |
| """Parse RFC 822-style wheel METADATA into a dict of key -> list of values. | |
| Skips multi-line continuation values (used for embedded license text) and | |
| only captures top-level key: value lines. | |
| """ | |
| result: dict[str, list[str]] = {} | |
| for line in raw.splitlines(): | |
| if not line or line[0] in (" ", "\t"): | |
| continue | |
| if ":" not in line: | |
| continue | |
| key, _, val = line.partition(":") | |
| key = key.strip() | |
| val = val.strip() | |
| if key and val: | |
| result.setdefault(key, []).append(val) | |
| return result | |
| @dataclass | |
| class WheelInfo: | |
| path: Path | |
| name: str | |
| version: str | |
| python_tag: str | |
| abi_tag: str | |
| platform_tag: str | |
| metadata: dict[str, list[str]] | |
| wheel_meta: dict[str, list[str]] | |
| files: list[zipfile.ZipInfo] | |
| @property | |
| def requires_python(self) -> str: | |
| return self.metadata.get("Requires-Python", [""])[0] | |
| @property | |
| def requires_dist(self) -> list[str]: | |
| return self.metadata.get("Requires-Dist", []) | |
| @property | |
| def provides_extra(self) -> list[str]: | |
| return self.metadata.get("Provides-Extra", []) | |
| @property | |
| def summary(self) -> str: | |
| return self.metadata.get("Summary", [""])[0] | |
| @property | |
| def generator(self) -> str: | |
| return self.wheel_meta.get("Generator", [""])[0] | |
| @property | |
| def tags(self) -> str: | |
| tags = self.wheel_meta.get("Tag", []) | |
| return ", ".join(tags) if tags else f"{self.python_tag}-{self.abi_tag}-{self.platform_tag}" | |
| @property | |
| def total_size(self) -> int: | |
| return sum(f.file_size for f in self.files) | |
| @property | |
| def compressed_size(self) -> int: | |
| return sum(f.compress_size for f in self.files) | |
| @property | |
| def file_count(self) -> int: | |
| return len(self.files) | |
| def ext_breakdown(self) -> list[tuple[str, int]]: | |
| counts = Counter(Path(f.filename).suffix or "(none)" for f in self.files) | |
| return counts.most_common() | |
| def top_files_by_size(self, n: int = 10) -> list[zipfile.ZipInfo]: | |
| return sorted(self.files, key=lambda f: f.file_size, reverse=True)[:n] | |
| def load_wheel(path: Path) -> WheelInfo: | |
| stem = path.stem # e.g. marin-0.99.dev20260410153808-py3-none-any | |
| parts = stem.split("-") | |
| if len(parts) < 5: | |
| raise ValueError(f"Non-standard wheel filename: {path.name}") | |
| name, version, python_tag, abi_tag, platform_tag = parts[0], parts[1], parts[2], parts[3], parts[4] | |
| with zipfile.ZipFile(path) as zf: | |
| all_files = zf.infolist() | |
| dist_info_prefix = f"{name}-{version}.dist-info/" | |
| def read(fname: str) -> str: | |
| full = dist_info_prefix + fname | |
| try: | |
| return zf.read(full).decode("utf-8", errors="replace") | |
| except KeyError: | |
| return "" | |
| metadata = parse_metadata(read("METADATA")) | |
| wheel_meta = parse_metadata(read("WHEEL")) | |
| return WheelInfo( | |
| path=path, | |
| name=name, | |
| version=version, | |
| python_tag=python_tag, | |
| abi_tag=abi_tag, | |
| platform_tag=platform_tag, | |
| metadata=metadata, | |
| wheel_meta=wheel_meta, | |
| files=all_files, | |
| ) | |
| def fmt_size(n: int) -> str: | |
| for unit in ("B", "KB", "MB", "GB"): | |
| if n < 1024: | |
| return f"{n:.1f} {unit}" | |
| n /= 1024 | |
| return f"{n:.1f} TB" | |
| def print_wheel(info: WheelInfo, verbose: bool = False) -> None: | |
| bar = "─" * 60 | |
| print(f"\n{bar}") | |
| print(f" {info.path.name}") | |
| print(bar) | |
| print(f" Name: {info.name}") | |
| print(f" Version: {info.version}") | |
| if info.summary: | |
| print(f" Summary: {info.summary}") | |
| print(f" Tags: {info.tags}") | |
| if info.requires_python: | |
| print(f" Requires-Python: {info.requires_python}") | |
| if info.generator: | |
| print(f" Generator: {info.generator}") | |
| print(f"\n Files: {info.file_count}") | |
| print(f" Uncompressed: {fmt_size(info.total_size)}") | |
| print(f" Compressed: {fmt_size(info.compressed_size)} ({100*info.compressed_size//max(info.total_size,1)}%)") | |
| ext_rows = info.ext_breakdown() | |
| if ext_rows: | |
| print(f"\n By extension:") | |
| for ext, count in ext_rows: | |
| print(f" {ext:<12} {count:>4} files") | |
| if info.requires_dist: | |
| core = [d for d in info.requires_dist if ";" not in d] | |
| optional = [d for d in info.requires_dist if ";" in d] | |
| print(f"\n Dependencies: {len(core)} core, {len(optional)} optional") | |
| if verbose: | |
| for d in sorted(set(core)): | |
| print(f" {d}") | |
| if optional: | |
| print(f" Optional:") | |
| for d in sorted(set(optional)): | |
| print(f" {d}") | |
| if info.provides_extra: | |
| extras = sorted(set(info.provides_extra)) | |
| print(f" Extras: {', '.join(extras)}") | |
| if verbose: | |
| print(f"\n Largest files:") | |
| for f in info.top_files_by_size(): | |
| print(f" {fmt_size(f.file_size):>10} {f.filename}") | |
| print() | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Inspect Python wheel files", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| parser.add_argument("wheels", nargs="+", metavar="WHEEL", help="Wheel files to inspect") | |
| parser.add_argument("-v", "--verbose", action="store_true", help="Show dependency list and largest files") | |
| args = parser.parse_args() | |
| errors = 0 | |
| for w in args.wheels: | |
| path = Path(w) | |
| if not path.exists(): | |
| print(f"ERROR: {w}: file not found", file=sys.stderr) | |
| errors += 1 | |
| continue | |
| if path.suffix != ".whl": | |
| print(f"WARNING: {w}: not a .whl file, trying anyway", file=sys.stderr) | |
| try: | |
| info = load_wheel(path) | |
| print_wheel(info, verbose=args.verbose) | |
| except Exception as exc: | |
| print(f"ERROR: {w}: {exc}", file=sys.stderr) | |
| errors += 1 | |
| sys.exit(errors) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment