Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
-tl prints the list, not a report
The output is meant to become the requirements file, so plainly it is
that file: the entries, and the notes the source carried, which are its
author's and not ours. Counts and warnings go to stderr, where a
redirection leaves them behind; -v puts the reasoning back on stdout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Loading branch information
stonebig and claude committed Aug 13, 2026
commit 4be3d3218ee1ac18f664922456774500fde45da8
24 changes: 17 additions & 7 deletions README_PYPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,23 @@ $ wppm -p ".[.]" -l9

## What did you actually ask for?

`-p` and `-r` answer for one package. `-tl` answers for a whole list: it keeps only
the entries nothing else in that list already pulls in, sorted, and comments out the rest
with the reason.
`-p` and `-r` answer for one package. `-tl` answers for a whole list: it keeps only the
entries nothing else in that list already pulls in. Plainly, it prints that list and
nothing else -- so the output *is* the new file, and the counts go to stderr where a
redirection leaves them behind:

```console
$ wppm requirements_slim.txt --top-level -v -t D:\WPy64\python
$ wppm requirements_slim.txt -tl -t D:\WPy64\python > requirements_slim_new.txt
160 entries -> 112 kept, 48 already pulled in
8 repeated, collapsed: brotli, openai, pympler, pytest, python-barcode, ...
```

The notes the source file carried are kept, since they are its author's. `-v` adds the
reasoning: where the list came from, and every dropped entry commented out with what
pulls it in, so re-asking for one is uncommenting it.

```console
$ wppm requirements_slim.txt -tl -v -t D:\WPy64\python
# requirements_slim.txt, sorted, with every entry
# another one already pulls in commented out: 160 entries -> 112.

Expand All @@ -112,9 +123,8 @@ $ wppm requirements_slim.txt --top-level -v -t D:\WPy64\python
#whatthepatch # <- spyder
```

Dropped entries come back as comments, so re-asking for one is uncommenting it, and the
notes in the source file are carried over. With no file, the question becomes "of
everything installed here, what did anything actually ask for?":
With no file, the question becomes "of everything installed here, what did anything
actually ask for?":

```console
$ wppm --top-level -t D:\WPy64\python
Expand Down
65 changes: 47 additions & 18 deletions tests/test_top_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,22 +133,37 @@ def test_something_outside_the_cycle_still_drops_it(self, cycle):


class TestRendering:
def test_dropped_entries_come_back_as_comments(self, pip):
lines = wppm_module.top_level_as_requirements(pip.top_level(["app", "lib"]))
assert "app" in lines
assert "#lib" in lines
def test_plain_output_is_the_list_and_nothing_else(self, pip):
"""Redirect it and what lands in the file is the file."""
assert wppm_module.top_level_as_requirements(pip.top_level(["app", "lib"])) == ["app"]

def test_verbose_says_who_pulls_each_one_in(self, pip):
def test_verbose_comments_out_what_went_and_why(self, pip):
lines = wppm_module.top_level_as_requirements(pip.top_level(["app", "lib"]), verbose=True)
assert "app" in lines
assert "#lib # <- app" in lines

def test_source_comments_are_preserved(self, pip):
def test_verbose_heads_the_list_with_its_counts(self, pip):
lines = wppm_module.top_level_as_requirements(pip.top_level(["app", "lib", "orphan"]), verbose=True)
assert "3 entries -> 2" in "\n".join(lines[:2])

def test_source_notes_are_kept_even_plainly(self, pip):
"""They are the author's own lines, not our commentary."""
lines = wppm_module.top_level_as_requirements(pip.top_level(["app"]), comments=["# a note"])
assert "# a note" in lines

def test_header_counts_the_entries(self, pip):
header = "\n".join(wppm_module.top_level_as_requirements(pip.top_level(["app", "lib", "orphan"]))[:2])
assert "3 entries -> 2" in header

class TestSummary:
def test_counts_what_happened(self, pip):
notes = wppm_module.top_level_summary(pip.top_level(["app", "lib", "orphan"]))
assert "3 entries -> 2 kept, 1 already pulled in" in notes[0]

def test_reports_repeats(self, pip):
notes = wppm_module.top_level_summary(pip.top_level(["orphan", "orphan"]))
assert any("repeated" in note for note in notes)

def test_reports_what_the_target_lacks(self, pip):
notes = wppm_module.top_level_summary(pip.top_level(["orphan", "nosuchpackage"]))
assert any("nosuchpackage" in note for note in notes)


class TestReadRequirements:
Expand All @@ -160,27 +175,41 @@ def test_splits_entries_from_comments(self, tmp_path):

@windows_only
class TestCli:
def wppm(self, *args):
def run(self, *args):
proc = subprocess.run(
[sys.executable, "-X", "utf8", "-m", "wppm", *args],
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=300,
encoding="utf-8", errors="replace",
)
assert proc.returncode == 0, f"exit {proc.returncode}\n{proc.stdout}\n{proc.stderr}"
return proc.stdout
return proc

def wppm(self, *args):
return self.run(*args).stdout

def test_top_level_of_a_target(self, graph):
out = self.wppm("-t", str(graph), "--top-level")
assert "app" in out.splitlines()
assert "#lib" in out.splitlines()
assert self.wppm("-t", str(graph), "--top-level").splitlines() == ["app", "fancylib", "orphan"]

def test_the_short_flag_does_the_same(self, graph):
assert self.wppm("-t", str(graph), "-tl") == self.wppm("-t", str(graph), "--top-level")

def test_verbose_adds_the_reasoning(self, graph):
assert "#lib # <- app" in self.wppm("-t", str(graph), "--top-level", "-v").splitlines()

def test_top_level_of_a_requirements_file(self, graph, tmp_path):
req = tmp_path / "req.txt"
req.write_text("# keep me\nlib\napp\n", encoding="utf-8")
out = self.wppm("-t", str(graph), str(req), "--top-level")
assert "app" in out.splitlines()
assert "#lib" in out.splitlines()
assert "# keep me" in out.splitlines()
out = self.wppm("-t", str(graph), str(req), "--top-level").splitlines()
assert "app" in out
assert "#lib" not in out
assert "# keep me" in out

def test_the_counts_go_to_stderr_not_into_the_list(self, graph, tmp_path):
req = tmp_path / "req.txt"
req.write_text("lib\napp\n", encoding="utf-8")
proc = self.run("-t", str(graph), str(req), "--top-level")
assert proc.stdout.splitlines() == ["app"]
assert "2 entries -> 1 kept" in proc.stderr

def test_json_output_parses(self, graph):
data = json.loads(self.wppm("-t", str(graph), "--top-level", "-j"))
Expand Down
45 changes: 30 additions & 15 deletions wppm/wppm.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,31 +277,44 @@ def install_bdist_direct(self, package, install_options=None):
package = Package(fname)
self._print_done()

def few(names, limit=6):
"""First few names of a list, and how many more there were."""
return ", ".join(names[:limit]) + (f", and {len(names) - limit} more" if len(names) > limit else "")

def top_level_as_requirements(result, comments=(), source=None, verbose=False):
"""Render piptree.top_level() as a requirements file that says why it is short.
"""Render piptree.top_level() as the requirements file it proposes.

Dropped entries stay as comments, so re-asking for one is uncommenting it.
Plainly, it is that file and nothing else: the entries, plus the notes the
source itself carried, so redirecting the output replaces the source
without losing what its author wrote in it. -v adds the reasoning -- where
the list came from, and every dropped entry commented out with what pulls
it in, so re-asking for one is uncommenting it.
"""
def few(names, limit=6):
return ", ".join(names[:limit]) + (f", and {len(names) - limit} more" if len(names) > limit else "")

kept, dropped = result["kept"], result["dropped"]
lines = [f"# {Path(source).name if source else 'installed packages'}, sorted, with every entry",
f"# another one already pulls in commented out: {len(kept) + len(dropped)} entries -> {len(kept)}."]
if result["unknown"]:
lines.append(f"# {len(result['unknown'])} not installed in the target, so left unresolved: {few(result['unknown'])}")
if result["duplicates"]:
lines.append(f"# repeated in the source: {few(result['duplicates'], 12)}")
lines += [""] + kept
if dropped:
lines = []
if verbose:
lines += [f"# {Path(source).name if source else 'installed packages'}, sorted, with every entry",
f"# another one already pulls in commented out: {len(kept) + len(dropped)} entries -> {len(kept)}.",
""]
lines += kept
if verbose and dropped:
lines += ["", "# ---- already pulled in by an entry above ----"]
for text, pullers in dropped.items():
why = f" # <- {', '.join(pullers[:5])}{', ...' if len(pullers) > 5 else ''}" if verbose else ""
lines.append(f"#{text}{why}")
lines.append(f"#{text} # <- {', '.join(pullers[:5])}{', ...' if len(pullers) > 5 else ''}")
if comments:
lines += ["", "# ---- notes kept from the source ----"] + list(comments)
return lines

def top_level_summary(result):
"""What the caller should know about the answer, rather than of it."""
kept, dropped = result["kept"], result["dropped"]
notes = [f"{len(kept) + len(dropped)} entries -> {len(kept)} kept, {len(dropped)} already pulled in"]
if result["duplicates"]:
notes.append(f"{len(result['duplicates'])} repeated, collapsed: {few(result['duplicates'], 12)}")
if result["unknown"]:
notes.append(f"{len(result['unknown'])} not installed in the target, so left unresolved: {few(result['unknown'])}")
return notes

def main(test=False):
# package summaries may contain characters the console codepage can't encode (emoji): don't crash
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
Expand Down Expand Up @@ -367,6 +380,8 @@ def main(test=False):
sys.exit()
for line in top_level_as_requirements(result, comments, source, args.verbose):
print(line)
for note in top_level_summary(result): # stderr: a redirected list stays a list
print(note, file=sys.stderr)
sys.exit()
elif args.list:
pip = piptree.PipData(targetpython, args.wheelsource)
Expand Down