Skip to content

Commit 55da723

Browse files
committed
srcreq_manifest.py: inventory the local wheelhouse
Says what the shipped lockfiles still install and what nothing references any more, so the folder stops being append-only. --delete removes a named bucket; every pylock being in git is what makes that safe.
1 parent 1e75102 commit 55da723

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

‎srcreq_manifest.py‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Inventory the local wheelhouse, and say which files nothing needs any more.
2+
3+
python srcreq_manifest.py # report + write both lists
4+
python srcreq_manifest.py --cycle winpython/portable/cycle_2026_04
5+
python srcreq_manifest.py --delete dead # actually remove a bucket
6+
7+
The wheelhouse is the index a local build resolves against
8+
(`pip install --no-index --find-links=...`), so what is in it decides what gets
9+
built. Left alone it only grows: versions superseded cycles ago, and packages
10+
no requirement file mentions any longer.
11+
12+
What a build still needs is not a guess -- the shipped lockfiles say it exactly.
13+
A (name, version) pair is `used` when a lockfile installs it, `superseded` when
14+
the project is still in use but that version is not, and `dead` when no
15+
lockfile and no requirement or constraint file names the project at all.
16+
17+
Deleting is safe because the lockfiles are in git: any past distribution can be
18+
refetched with `pip download --require-hashes -r <its pylock>`. Nothing is
19+
deleted without --delete, which names the bucket explicitly.
20+
21+
Hashes come from the lockfiles rather than from the files, so this reads no
22+
package bytes. The build's web-vs-local double lock is what makes that sound:
23+
it already proves the local copies match what PyPI serves.
24+
"""
25+
from __future__ import annotations
26+
27+
import argparse
28+
import re
29+
import sys
30+
import tomllib
31+
from collections import defaultdict
32+
from datetime import date
33+
from pathlib import Path
34+
35+
WHEEL = ".whl"
36+
SDIST = (".tar.gz", ".zip", ".tar.bz2")
37+
REQUIREMENT_FILES = ("constraints.txt", "requirements_slim.txt", "requirements_slimf.txt",
38+
"dot_requirements.txt", "requirements_whl.txt", "mandatory_requirements.txt")
39+
# A lockfile whose name still carries a release level (b0, b1, ...) is from a
40+
# superseded build of its cycle; the shipped ones have none.
41+
SUPERSEDED_LOCK = re.compile(r"b\d+(_wheels)?\.toml$")
42+
BUCKETS = ("superseded", "dead", "unparsed")
43+
44+
45+
def normalize(name: str) -> str:
46+
"""PEP 503 normalized project name."""
47+
return re.sub(r"[-_.]+", "-", name).lower()
48+
49+
50+
def parse_filename(path: Path, known: set[str]) -> tuple[str, str] | None:
51+
"""(normalized name, version) for a wheel or sdist file name, else None.
52+
53+
Wheel names are unambiguous. Sdist names are not -- a project name may
54+
contain the same '-' that separates it from the version -- so the longest
55+
known project name prefixing the file wins, and only when nothing matches
56+
do we fall back to splitting at the first '-' before a digit.
57+
"""
58+
name = path.name
59+
if name.endswith(WHEEL):
60+
parts = name[: -len(WHEEL)].split("-")
61+
return (normalize(parts[0]), parts[1]) if len(parts) >= 3 else None
62+
63+
for suffix in SDIST:
64+
if name.endswith(suffix):
65+
stem = name[: -len(suffix)]
66+
break
67+
else:
68+
return None
69+
70+
candidates = [k for k in known if normalize(stem).startswith(k + "-")]
71+
if candidates:
72+
best = max(candidates, key=len)
73+
return best, stem[len(best) + 1:]
74+
match = re.match(r"^(.*?)-(\d.*)$", stem)
75+
return (normalize(match.group(1)), match.group(2)) if match else None
76+
77+
78+
def read_locks(cycle_dirs: list[Path]) -> tuple[dict, dict, list[Path]]:
79+
"""Artifacts the shipped lockfiles install, and which were built from sdist."""
80+
locks = sorted(p for d in cycle_dirs for p in d.glob("pylock.*.toml")
81+
if not SUPERSEDED_LOCK.search(p.name))
82+
wanted: dict[tuple[str, str], set[str]] = defaultdict(set)
83+
from_sdist: dict[tuple[str, str], set[str]] = defaultdict(set)
84+
for lock in locks:
85+
data = tomllib.loads(lock.read_text(encoding="utf-8"))
86+
for pkg in data.get("packages", []):
87+
key = (normalize(pkg["name"]), pkg["version"])
88+
for artifact in pkg.get("wheels", []):
89+
if digest := artifact.get("hashes", {}).get("sha256"):
90+
wanted[key].add(digest)
91+
if sdist := pkg.get("sdist"):
92+
if digest := sdist.get("hashes", {}).get("sha256"):
93+
wanted[key].add(digest)
94+
from_sdist[key].add(lock.name)
95+
return wanted, from_sdist, locks
96+
97+
98+
def read_declared(repo: Path) -> set[str]:
99+
"""Project names any current requirement or constraint file mentions."""
100+
names: set[str] = set()
101+
for filename in REQUIREMENT_FILES:
102+
path = repo / filename
103+
if not path.is_file():
104+
continue
105+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
106+
if line := line.split("#")[0].strip():
107+
if name := re.split(r"[<>=!~;\[ ]", line)[0].strip():
108+
names.add(normalize(name))
109+
return names
110+
111+
112+
def gigabytes(paths) -> float:
113+
return sum(p.stat().st_size for p in paths) / 2 ** 30
114+
115+
116+
def main(argv: list[str]) -> int:
117+
parser = argparse.ArgumentParser(description=__doc__,
118+
formatter_class=argparse.RawDescriptionHelpFormatter)
119+
parser.add_argument("--srcreq", type=Path, default=Path(r"C:\WinP\packages.srcreq"),
120+
help="the wheelhouse to inventory")
121+
parser.add_argument("--cycle", type=Path, action="append", dest="cycles",
122+
help="cycle directory to read lockfiles from; repeatable, "
123+
"defaults to the two newest under winpython/portable")
124+
parser.add_argument("--repo", type=Path, default=Path("."))
125+
parser.add_argument("--out", type=Path, default=Path("."))
126+
parser.add_argument("--delete", choices=BUCKETS, action="append", dest="delete",
127+
help="remove the files in this bucket; repeatable")
128+
args = parser.parse_args(argv)
129+
130+
cycles = args.cycles or sorted((args.repo / "winpython/portable").glob("cycle_*"))[-2:]
131+
if not args.srcreq.is_dir():
132+
print(f"no such wheelhouse: {args.srcreq}", file=sys.stderr)
133+
return 2
134+
missing = [d for d in cycles if not d.is_dir()]
135+
if missing:
136+
print(f"no such cycle directory: {missing[0]}", file=sys.stderr)
137+
return 2
138+
139+
wanted, from_sdist, locks = read_locks(cycles)
140+
if not wanted:
141+
print(f"no lockfiles found under {', '.join(map(str, cycles))}", file=sys.stderr)
142+
return 2
143+
live = {name for name, _ in wanted} | read_declared(args.repo)
144+
145+
files = sorted(p for p in args.srcreq.iterdir() if p.is_file())
146+
buckets: dict[str, list[Path]] = defaultdict(list)
147+
labels: dict[Path, tuple[str, str]] = {}
148+
for path in files:
149+
parsed = parse_filename(path, live)
150+
if parsed is None:
151+
buckets["unparsed"].append(path)
152+
continue
153+
labels[path] = parsed
154+
name, version = parsed
155+
buckets["used" if (name, version) in wanted
156+
else "superseded" if name in live else "dead"].append(path)
157+
158+
on_disk = set(labels.values())
159+
absent = sorted(set(wanted) - on_disk)
160+
today = date.today().isoformat()
161+
162+
manifest = args.out / "srcreq_manifest.txt"
163+
sdist_note = [f"# {n}=={v} built from sdist by {', '.join(sorted(w))}"
164+
for (n, v), w in sorted(from_sdist.items())]
165+
manifest.write_text("\n".join([
166+
f"# packages.srcreq manifest, generated {today}",
167+
f"# from {len(locks)} shipped lockfiles in {', '.join(d.as_posix() for d in cycles)}",
168+
f"# {len(wanted)} (name, version) pairs; regenerate with srcreq_manifest.py",
169+
"#",
170+
"# An inventory and a verification list, not a one-command restore: one",
171+
"# pip download only fetches artifacts matching the interpreter running it,",
172+
"# so a wheelhouse serving several Pythons needs one pass per target, each",
173+
"# against that flavor's own pylock:",
174+
"#",
175+
"# pip download --dest <folder> --no-deps --require-hashes -r <pylock.toml>",
176+
"#",
177+
*(["# Every line installs from a wheel, with these exceptions:", *sdist_note]
178+
if sdist_note else ["# No lockfile here builds anything from an sdist."]),
179+
"",
180+
*(f"{name}=={version} " +
181+
" ".join(f"--hash=sha256:{h}" for h in sorted(wanted[(name, version)])).rstrip()
182+
for name, version in sorted(wanted)),
183+
]) + "\n", encoding="utf-8", newline="\n")
184+
185+
prune = args.out / "srcreq_prune.txt"
186+
reasons = {
187+
"superseded": "older version of a package still in use",
188+
"dead": "no lockfile and no requirement file names this project",
189+
"unparsed": "not a wheel or sdist name -- look before removing",
190+
}
191+
lines = [
192+
f"# packages.srcreq prune candidates, generated {today}",
193+
f"# wheelhouse holds {len(files)} files, {gigabytes(files):.1f} GB",
194+
f"# {len(buckets['used'])} files are installed by a shipped lockfile and are NOT listed",
195+
"#",
196+
"# Safe to remove: every pylock is in git, so any past distribution refetches",
197+
"# with pip download --require-hashes. Remove with --delete <bucket>.",
198+
]
199+
for bucket in BUCKETS:
200+
if entries := buckets[bucket]:
201+
lines += ["", f"# --- {bucket}: {reasons[bucket]}",
202+
f"# {len(entries)} files, {gigabytes(entries):.2f} GB", ""]
203+
lines += [p.name for p in sorted(entries, key=lambda p: (labels.get(p, ("", ""))[0], p.name))]
204+
prune.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
205+
206+
print(f"lockfiles read : {len(locks)} from {', '.join(d.name for d in cycles)}")
207+
print(f"pairs to keep : {len(wanted)}"
208+
+ (f" ** {len(absent)} NOT on disk **" if absent else " all present on disk"))
209+
for name, version in absent[:10]:
210+
print(f" missing {name}=={version}")
211+
print(f"wheelhouse : {len(files)} files, {gigabytes(files):.1f} GB")
212+
for bucket in ("used", *BUCKETS):
213+
entries = buckets[bucket]
214+
print(f" {bucket:<13}: {len(entries):>5} files, {gigabytes(entries):>5.2f} GB")
215+
reclaimable = [p for b in BUCKETS for p in buckets[b]]
216+
if reclaimable:
217+
print(f"reclaimable : {gigabytes(reclaimable):.1f} GB "
218+
f"({gigabytes(reclaimable) / max(gigabytes(files), 1e-9):.0%})")
219+
220+
for bucket in args.delete or []:
221+
entries = buckets[bucket]
222+
freed = gigabytes(entries)
223+
for path in entries:
224+
path.unlink()
225+
print(f"deleted {len(entries)} files from {bucket}, {freed:.2f} GB freed")
226+
227+
print(f"\nwrote {manifest}\n {prune}")
228+
return 0
229+
230+
231+
if __name__ == "__main__":
232+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)