#!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 # # Repack a wheel in-place: compile package/*.py to legacy sibling *.pyc # (compileall -b: required for zipimport, which does not read PEP 3147 # __pycache__/ names), remove the .py sources, and rewrite *.dist-info/RECORD. from __future__ import annotations import argparse import base64 import hashlib import shutil import subprocess import sys import tempfile import zipfile from pathlib import Path def wheel_record_hash(data: bytes) -> str: digest = hashlib.sha256(data).digest() return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") def wheel_has_py_sources(zf: zipfile.ZipFile, package: str) -> bool: prefix = f"{package}/" return any(n.startswith(prefix) and n.endswith(".py") for n in zf.namelist()) def wheel_has_legacy_bytecode(zf: zipfile.ZipFile, package: str) -> bool: return f"{package}/__init__.pyc" in zf.namelist() def strip_py_sources(pkg_dir: Path, package: str) -> int: """Remove package/**/*.py after sibling legacy *.pyc exists (compileall -b output).""" removed = 0 for path in sorted(pkg_dir.rglob("*.py")): if not path.is_file(): continue pyc = path.with_suffix(".pyc") if not pyc.is_file(): rel = path.relative_to(pkg_dir) raise SystemExit( f"missing legacy .pyc for {package}/{rel.as_posix()}, " "refusing to delete source" ) path.unlink() removed += 1 return removed def rebuild_record(root: Path) -> None: dist_infos = sorted(root.glob("*.dist-info")) if len(dist_infos) != 1: raise SystemExit( f"expected one *.dist-info, got {[p.name for p in dist_infos]}" ) di = dist_infos[0] record_path = di / "RECORD" record_rel = f"{di.name}/RECORD" lines: list[str] = [] for path in sorted(root.rglob("*")): if not path.is_file(): continue rel = path.relative_to(root).as_posix() if rel == record_rel: continue body = path.read_bytes() lines.append(f"{rel},sha256={wheel_record_hash(body)},{len(body)}") lines.append(f"{record_rel},,") record_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def repack(whl_path: Path, *, package: str, force: bool) -> None: whl_path = whl_path.resolve() if not whl_path.is_file(): raise SystemExit(f"missing wheel: {whl_path}") with zipfile.ZipFile(whl_path) as zf: has_py = wheel_has_py_sources(zf, package) if not has_py: if not wheel_has_legacy_bytecode(zf, package): raise SystemExit( f"repack_wheel_add_pyc: wheel has no {package}/*.py and no " f"{package}/__init__.pyc (corrupt or old tool output). " f"Delete the bundled wheel and rebuild." ) if not force: print( f"repack_wheel_add_pyc: skip ({package} already bytecode-only): " f"{whl_path.name}" ) return tmpdir = tempfile.mkdtemp(prefix=f"{package}-whl-pyc-") try: root = Path(tmpdir) with zipfile.ZipFile(whl_path) as zf: zf.extractall(root) pkg_dir = root / package if not pkg_dir.is_dir(): raise SystemExit(f"wheel has no {package}/ top-level package") subprocess.run( [sys.executable, "-m", "compileall", "-q", "-f", "-b", str(pkg_dir)], cwd=str(root), check=True, ) n_py = strip_py_sources(pkg_dir, package) rebuild_record(root) out_path = whl_path.with_suffix(whl_path.suffix + ".tmp") with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as out: for path in sorted(root.rglob("*")): if path.is_file(): arcname = path.relative_to(root).as_posix() out.write(path, arcname) out_path.replace(whl_path) print( f"repack_wheel_add_pyc: bytecode-only {package} ({n_py} .py removed) -> " f"{whl_path.name}" ) finally: shutil.rmtree(tmpdir, ignore_errors=True) def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("wheel", type=Path, help="path to .whl (updated in place)") ap.add_argument( "-p", "--package", default="pip", help="top-level package directory inside the wheel (default: pip)", ) ap.add_argument( "-f", "--force", action="store_true", help="repack even if the package is already .pyc-only", ) args = ap.parse_args() repack(args.wheel, package=args.package, force=args.force) if __name__ == "__main__": main()