Skip to content

Commit 416a5ed

Browse files
stonebigclaude
andcommitted
wppm --roots: keep only what nothing else pulls in
Given a requirements file, drop every entry another entry already brings in, sort the rest, and comment out what went and why. Given no file, the same question over what is installed. An optional dependency counts only where its extra is asked for; a mutual pair keeps both members. Reading a wheelhouse now skips an archive carrying no metadata instead of losing the directory with it, and keeps the newest version of a package where several are present, so the answer stops depending on the order the files were listed in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 35bd8ac commit 416a5ed

7 files changed

Lines changed: 414 additions & 13 deletions

File tree

README_PYPI.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,36 @@ levels deep — is one command:
9595
$ wppm -p ".[.]" -l9
9696
```
9797

98+
## What did you actually ask for?
99+
100+
`-p` and `-r` answer for one package. `--roots` answers for a whole list: it keeps only
101+
the entries nothing else in that list already pulls in, sorted, and comments out the rest
102+
with the reason.
103+
104+
```console
105+
$ wppm requirements_slim.txt --roots -v -t D:\WPy64\python
106+
# requirements_slim.txt, sorted, with every entry
107+
# another one already pulls in commented out: 160 entries -> 112.
108+
109+
...
110+
#numpy # <- baresql, clarabel, cvxpy, dask[array,dataframe,diagnostics], datashader, ...
111+
#scikit-learn # <- imbalanced-learn, mlxtend, prince, skrub, umap-learn
112+
#whatthepatch # <- spyder
113+
```
114+
115+
Dropped entries come back as comments, so re-asking for one is uncommenting it, and the
116+
notes in the source file are carried over. With no file, the question becomes "of
117+
everything installed here, what did anything actually ask for?":
118+
119+
```console
120+
$ wppm --roots -t D:\WPy64\python
121+
```
122+
123+
An optional dependency only counts where its extra is asked for, and a mutual pair keeps
124+
both members -- dropping either would take the other with it. With `-ws` the facts come
125+
from a wheelhouse instead of an installation, so a list can be pruned before anything is
126+
built; where the wheelhouse holds several versions of a package, the newest one answers.
127+
98128
## Everything is available as JSON
99129

100130
Any of `-p`, `-r`, `-ls`, `-md` accepts `-j` / `--json`, so the same answers can gate a
@@ -185,7 +215,7 @@ anything into it.
185215
```text
186216
usage: wppm [-h] [-v] [--register] [--unregister] [--fix] [--movable]
187217
[-ws WHEELSOURCE] [-wd WHEELDRAIN] [-ls] [-lsa] [-md] [-p] [-r]
188-
[-l LEVELS] [-j] [-t TARGET] [-i] [-u]
218+
[-roots] [-l LEVELS] [-j] [-t TARGET] [-i] [-u]
189219
[package(s) or lockfile ...]
190220
191221
WinPython Package Manager: handle a Python distribution (WinPython or not) and its packages (17.10.20260808)
@@ -208,8 +238,9 @@ options:
208238
-md markdown summary of the installation
209239
-p show Package (!= missing) dependencies of the given package[option], [.]=all: wppm -p pandas[.]
210240
-r show Reverse (!= constraining) dependancies of the given package[option]: wppm -r pytest![test]
241+
-roots, --roots keep only what no other entry pulls in, sorted: wppm --roots, wppm requirements.txt --roots -v
211242
-l LEVELS show 'LEVELS' levels of dependencies (with -p, -r): wppm -p pandas -l1
212-
-j, --json machine-readable JSON output (with -p, -r, -ls, -md): wppm -p pandas[.] -j
243+
-j, --json machine-readable JSON output (with -p, -r, -ls, -md, --roots): wppm -p pandas[.] -j
213244
-t TARGET path to target Python distribution (default: current environment)
214245
-i, --install install a given package wheel or pylock file (use pip for more features)
215246
-u, --uninstall uninstall package (use pip for more features)

tests/test_piptree.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,41 @@ def test_environment_exposes_every_marker_field_packaging_needs(self, pip):
118118
}
119119

120120

121+
class TestWheelhouse:
122+
"""Reading a directory of wheels instead of an installation."""
123+
124+
@pytest.fixture
125+
def wheelhouse(self, tmp_path):
126+
import zipfile
127+
house = tmp_path / "wheels"
128+
house.mkdir()
129+
130+
def wheel(name, version, requires=()):
131+
with zipfile.ZipFile(house / f"{name}-{version}-py3-none-any.whl", "w") as zf:
132+
lines = ["Metadata-Version: 2.1", f"Name: {name}", f"Version: {version}"]
133+
lines += [f"Requires-Dist: {r}" for r in requires]
134+
zf.writestr(f"{name}-{version}.dist-info/METADATA", "\n".join(lines) + "\n")
135+
136+
wheel("solo", "1.0")
137+
wheel("twice", "1.0", requires=["solo"])
138+
wheel("twice", "2.0")
139+
(house / "not-a-package.tar.gz").write_bytes(b"not a tarball at all")
140+
return house
141+
142+
def test_an_unreadable_archive_does_not_lose_the_others(self, wheelhouse):
143+
pip = piptree.PipData(None, str(wheelhouse))
144+
assert {"solo", "twice"} <= set(pip.distro)
145+
146+
def test_only_the_newest_version_of_a_package_is_kept(self, wheelhouse):
147+
pip = piptree.PipData(None, str(wheelhouse))
148+
assert pip.distro["twice"]["version"] == "2.0"
149+
150+
def test_the_newest_version_brings_its_own_dependencies(self, wheelhouse):
151+
"""twice 1.0 needs solo, twice 2.0 does not: the answer must be 2.0's."""
152+
pip = piptree.PipData(None, str(wheelhouse))
153+
assert pip.dependency_closure("twice") == set()
154+
155+
121156
class TestCycles:
122157
def test_mutual_dependency_terminates(self, tmp_path):
123158
"""A <-> B must not recurse forever."""

tests/test_roots.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# -*- coding: utf-8 -*-
2+
"""--roots: keep only the entries no other entry already pulls in.
3+
4+
Same synthetic site-packages trick as test_piptree.py -- the point is the
5+
graph, not the packages.
6+
"""
7+
import json
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from wppm import piptree, utils, wppm as wppm_module
15+
16+
from conftest import write_dist, windows_only
17+
18+
REPO_ROOT = Path(__file__).resolve().parent.parent
19+
20+
21+
@pytest.fixture
22+
def graph(tmp_path):
23+
"""app -> lib -> helper, app[fancy] -> fancylib, and a standalone orphan."""
24+
root = tmp_path / "dist"
25+
site = root / "Lib" / "site-packages"
26+
site.mkdir(parents=True)
27+
(root / "python.exe").write_bytes(b"MZ")
28+
write_dist(site, "app", "1.0", requires=["lib>=1.0", 'fancylib; extra == "fancy"'],
29+
extras=["fancy"])
30+
write_dist(site, "lib", "1.5", requires=["helper"])
31+
write_dist(site, "helper", "0.3")
32+
write_dist(site, "fancylib", "2.0")
33+
write_dist(site, "orphan", "9.9")
34+
return root
35+
36+
37+
@pytest.fixture
38+
def pip(graph):
39+
return piptree.PipData(str(graph))
40+
41+
42+
class TestSplitRequirement:
43+
@pytest.mark.parametrize("text, expected", [
44+
("numpy", ("numpy", [])),
45+
("numpy==2.0", ("numpy", [])),
46+
("numpy >= 2.0", ("numpy", [])),
47+
("Pillow", ("pillow", [])),
48+
("mypy[mypyc]", ("mypy", ["mypyc"])),
49+
("dask[array,dataframe]>=2.0", ("dask", ["array", "dataframe"])),
50+
("scipy; python_version > '3.10'", ("scipy", [])),
51+
])
52+
def test_parses(self, text, expected):
53+
assert piptree.PipData.split_requirement(text) == expected
54+
55+
56+
class TestClosure:
57+
def test_follows_the_chain(self, pip):
58+
assert pip.dependency_closure("app") == {"lib", "helper"}
59+
60+
def test_ignores_an_extra_nobody_asked_for(self, pip):
61+
assert "fancylib" not in pip.dependency_closure("app")
62+
63+
def test_follows_an_extra_that_is_asked_for(self, pip):
64+
assert "fancylib" in pip.dependency_closure("app", "fancy")
65+
66+
def test_leaf_reaches_nothing(self, pip):
67+
assert pip.dependency_closure("helper") == set()
68+
69+
70+
class TestRoots:
71+
def test_installed_set_keeps_only_what_nothing_requires(self, pip):
72+
assert pip.roots()["kept"] == ["app", "fancylib", "orphan"]
73+
74+
def test_drops_an_entry_another_entry_pulls_in(self, pip):
75+
result = pip.roots(["app", "lib", "orphan"])
76+
assert result["kept"] == ["app", "orphan"]
77+
assert result["dropped"] == {"lib": ["app"]}
78+
79+
def test_names_every_puller_of_a_dropped_entry(self, pip):
80+
assert pip.roots(["app", "lib", "helper"])["dropped"]["helper"] == ["app", "lib"]
81+
82+
def test_keeps_an_entry_whose_puller_is_not_listed(self, pip):
83+
"""Nothing listed pulls helper in, so it stays."""
84+
assert pip.roots(["helper", "orphan"])["kept"] == ["helper", "orphan"]
85+
86+
def test_an_extra_only_dependency_stays_unless_the_extra_is_asked_for(self, pip):
87+
assert "fancylib" in pip.roots(["app", "fancylib"])["kept"]
88+
assert "fancylib" in pip.roots(["app[fancy]", "fancylib"])["dropped"]
89+
90+
def test_keeps_the_entry_as_written(self, pip):
91+
assert pip.roots(["app[fancy]", "lib"])["kept"] == ["app[fancy]"]
92+
93+
def test_reports_a_repeated_entry_once(self, pip):
94+
result = pip.roots(["orphan", "orphan"])
95+
assert result["kept"] == ["orphan"]
96+
assert result["duplicates"] == ["orphan"]
97+
98+
def test_a_repeat_keeps_the_fuller_spelling(self, pip):
99+
assert pip.roots(["app", "app[fancy]"])["kept"] == ["app[fancy]"]
100+
101+
def test_an_entry_the_target_lacks_is_kept_and_reported(self, pip):
102+
result = pip.roots(["orphan", "nosuchpackage"])
103+
assert result["unknown"] == ["nosuchpackage"]
104+
assert "nosuchpackage" in result["kept"]
105+
106+
def test_sorting_is_case_insensitive(self, pip):
107+
assert pip.roots(["orphan", "App", "fancylib"])["kept"] == ["App", "fancylib", "orphan"]
108+
109+
def test_empty_input_gives_empty_output(self, pip):
110+
assert pip.roots([]) == {"kept": [], "dropped": {}, "duplicates": [], "unknown": []}
111+
112+
113+
class TestMutualDependency:
114+
@pytest.fixture
115+
def cycle(self, tmp_path):
116+
root = tmp_path / "cyc"
117+
site = root / "Lib" / "site-packages"
118+
site.mkdir(parents=True)
119+
(root / "python.exe").write_bytes(b"MZ")
120+
write_dist(site, "aaa", "1.0", requires=["bbb"])
121+
write_dist(site, "bbb", "1.0", requires=["aaa"])
122+
write_dist(site, "ccc", "1.0", requires=["aaa"])
123+
return piptree.PipData(str(root))
124+
125+
def test_a_mutual_pair_keeps_both(self, cycle):
126+
"""Dropping either would take the other with it."""
127+
assert cycle.roots(["aaa", "bbb"])["kept"] == ["aaa", "bbb"]
128+
129+
def test_something_outside_the_cycle_still_drops_it(self, cycle):
130+
result = cycle.roots(["aaa", "bbb", "ccc"])
131+
assert result["kept"] == ["ccc"]
132+
assert set(result["dropped"]) == {"aaa", "bbb"}
133+
134+
135+
class TestRendering:
136+
def test_dropped_entries_come_back_as_comments(self, pip):
137+
lines = wppm_module.roots_as_requirements(pip.roots(["app", "lib"]))
138+
assert "app" in lines
139+
assert "#lib" in lines
140+
141+
def test_verbose_says_who_pulls_each_one_in(self, pip):
142+
lines = wppm_module.roots_as_requirements(pip.roots(["app", "lib"]), verbose=True)
143+
assert "#lib # <- app" in lines
144+
145+
def test_source_comments_are_preserved(self, pip):
146+
lines = wppm_module.roots_as_requirements(pip.roots(["app"]), comments=["# a note"])
147+
assert "# a note" in lines
148+
149+
def test_header_counts_the_entries(self, pip):
150+
header = "\n".join(wppm_module.roots_as_requirements(pip.roots(["app", "lib", "orphan"]))[:2])
151+
assert "3 entries -> 2" in header
152+
153+
154+
class TestReadRequirements:
155+
def test_splits_entries_from_comments(self, tmp_path):
156+
path = tmp_path / "r.txt"
157+
path.write_text("# a note\n\nnumpy\n pandas \n#disabled\n", encoding="utf-8")
158+
assert utils.read_requirements(path) == (["numpy", "pandas"], ["# a note", "#disabled"])
159+
160+
161+
@windows_only
162+
class TestCli:
163+
def wppm(self, *args):
164+
proc = subprocess.run(
165+
[sys.executable, "-X", "utf8", "-m", "wppm", *args],
166+
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=300,
167+
encoding="utf-8", errors="replace",
168+
)
169+
assert proc.returncode == 0, f"exit {proc.returncode}\n{proc.stdout}\n{proc.stderr}"
170+
return proc.stdout
171+
172+
def test_roots_of_a_target(self, graph):
173+
out = self.wppm("-t", str(graph), "--roots")
174+
assert "app" in out.splitlines()
175+
assert "#lib" in out.splitlines()
176+
177+
def test_roots_of_a_requirements_file(self, graph, tmp_path):
178+
req = tmp_path / "req.txt"
179+
req.write_text("# keep me\nlib\napp\n", encoding="utf-8")
180+
out = self.wppm("-t", str(graph), str(req), "--roots")
181+
assert "app" in out.splitlines()
182+
assert "#lib" in out.splitlines()
183+
assert "# keep me" in out.splitlines()
184+
185+
def test_json_output_parses(self, graph):
186+
data = json.loads(self.wppm("-t", str(graph), "--roots", "-j"))
187+
assert set(data) == {"kept", "dropped", "duplicates", "unknown"}
188+
assert data["kept"] == ["app", "fancylib", "orphan"]

wppm/packagemetadata.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,20 @@ def get_installed_metadata(path = None) -> List[PackageMetadata]:
4040
return pkgs
4141

4242
def get_directory_metadata(directory: str) -> List[PackageMetadata]:
43-
# For each .whl/.tar.gz file in directory, extract metadata
43+
"""Metadata of every wheel and sdist in *directory*.
44+
45+
An archive that carries no metadata (a plain source tarball, say) is
46+
skipped: a wheelhouse holds what it holds, and one odd file in it must not
47+
cost the caller the other three thousand.
48+
"""
4449
pkgs = []
4550
for fname in os.listdir(directory):
46-
if fname.endswith('.whl'):
47-
# Extract METADATA from wheel
48-
meta = extract_metadata_from_wheel(os.path.join(directory, fname))
49-
pkgs.append(meta)
50-
elif fname.endswith('.tar.gz'):
51-
# Extract PKG-INFO from sdist
52-
meta = extract_metadata_from_sdist(os.path.join(directory, fname))
53-
pkgs.append(meta)
51+
extract = extract_metadata_from_wheel if fname.endswith('.whl') else extract_metadata_from_sdist
52+
if fname.endswith(('.whl', '.tar.gz')):
53+
try:
54+
pkgs.append(extract(os.path.join(directory, fname)))
55+
except (ValueError, KeyError, OSError, tarfile.TarError, zipfile.BadZipFile) as e:
56+
print(f"skipped {fname}: {e}", file=sys.stderr)
5457
return pkgs
5558

5659
def extract_metadata_from_wheel(path: str) -> PackageMetadata:

0 commit comments

Comments
 (0)