Skip to content

Instantly share code, notes, and snippets.

@rjpower
Created April 10, 2026 16:57
Show Gist options
  • Select an option

  • Save rjpower/a77b63a8d73a16ced68c88c13a9e433f to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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