Skip to content

Commit 2a16046

Browse files
stonebigclaude
andcommitted
tests: add a pytest suite for wppm
The repo had no tests at all, while wppm ships on PyPI. This covers the areas that actually broke recently, and each group was checked by reverting the fix it guards and confirming it fails. - test_target_resolution.py: WinPython vs venv layout. Includes the case that made this necessary -- WinPython's distribution-root scripts\ holds env.bat, not an interpreter, and must not be taken for a venv Scripts dir -- and the ordering guarantee that a Scripts\python.exe never shadows a root one. - test_piptree.py: dependency trees over a synthetic site-packages, so extras, missing packages and cycles are all controlled. Also pins the marker environment as a characterisation test: markers are evaluated against the running interpreter, and a fix should have to update these deliberately rather than drift. - test_no_subprocess.py: makes the JupyterLite/Pyodide rule executable. Every spawn API is replaced by a raising stub, so anyone who makes PipData shell out gets a failure instead of silently ending portability. Includes a test that the guard itself bites. - test_distribution.py: -md must describe the target. The CLI cannot catch this, because a venv built from the running interpreter reports the same version either way, so this records which path get_installed_tools is asked about. - test_cli.py: end-to-end on a real venv -- the commands that returned "Invalid Python distribution" for any venv before the fix. Offline and hermetic: synthetic .dist-info trees for the logic, and one plain venv (ensurepip is bundled, so no network) for end-to-end. 64 tests, ~20s. Non-Windows is skipped rather than failed. Verified by mutation: reverting get_python_executable fails 9, reverting piptree's site-packages resolution fails 17, reverting the -md target fails 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 31591fb commit 2a16046

7 files changed

Lines changed: 623 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,8 @@ Issues = "https://github.com/winpython/winpython/issues"
4242

4343
[project.scripts]
4444
wppm = "wppm.wppm:main"
45+
46+
[tool.pytest.ini_options]
47+
testpaths = ["tests"]
48+
pythonpath = [".", "tests"]
49+
addopts = "-ra"

tests/conftest.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# -*- coding: utf-8 -*-
2+
"""Shared fixtures.
3+
4+
Most tests build *synthetic* distribution layouts in tmp_path: wppm's target
5+
resolution is pure path logic, so a directory tree with an empty `python.exe`
6+
in it exercises the real code without needing a real interpreter. That keeps
7+
the suite fast, offline and deterministic.
8+
9+
Only the end-to-end CLI tests need a real interpreter, and they use a plain
10+
`python -m venv` (which bundles pip via ensurepip, so still no network).
11+
"""
12+
import subprocess
13+
import sys
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
IS_WINDOWS = sys.platform == "win32"
19+
20+
windows_only = pytest.mark.skipif(
21+
not IS_WINDOWS, reason="wppm targets the Windows layout (python.exe / Scripts)"
22+
)
23+
24+
25+
def write_dist(site_packages: Path, name: str, version: str, requires=(),
26+
summary: str = "", extras=()) -> Path:
27+
"""Write a minimal .dist-info so importlib.metadata can discover the package."""
28+
dist_info = site_packages / f"{name.replace('-', '_')}-{version}.dist-info"
29+
dist_info.mkdir(parents=True, exist_ok=True)
30+
lines = ["Metadata-Version: 2.1", f"Name: {name}", f"Version: {version}"]
31+
if summary:
32+
lines.append(f"Summary: {summary}")
33+
lines += [f"Provides-Extra: {e}" for e in extras]
34+
lines += [f"Requires-Dist: {r}" for r in requires]
35+
(dist_info / "METADATA").write_text("\n".join(lines) + "\n", encoding="utf-8")
36+
(dist_info / "INSTALLER").write_text("pytest\n", encoding="utf-8")
37+
return dist_info
38+
39+
40+
@pytest.fixture
41+
def winpython_layout(tmp_path: Path) -> Path:
42+
"""WinPython/plain install: python.exe at the root, Lib/site-packages beside it.
43+
44+
Also creates the distribution-root `scripts/` sibling that holds env.bat --
45+
that directory is NOT a venv Scripts dir and must not be mistaken for one.
46+
"""
47+
root = tmp_path / "WPy64-31470b3" / "python"
48+
(root / "Lib" / "site-packages").mkdir(parents=True)
49+
(root / "Scripts").mkdir()
50+
(root / "python.exe").write_bytes(b"MZ")
51+
distro_root = root.parent
52+
(distro_root / "scripts").mkdir()
53+
(distro_root / "scripts" / "env.bat").write_text("@echo off\n", encoding="utf-8")
54+
(distro_root / "wheelhouse").mkdir()
55+
return root
56+
57+
58+
@pytest.fixture
59+
def venv_layout(tmp_path: Path) -> Path:
60+
"""venv: python.exe under Scripts/, Lib/site-packages at the root, pyvenv.cfg marker."""
61+
root = tmp_path / "myvenv"
62+
(root / "Scripts").mkdir(parents=True)
63+
(root / "Lib" / "site-packages").mkdir(parents=True)
64+
(root / "Scripts" / "python.exe").write_bytes(b"MZ")
65+
(root / "pyvenv.cfg").write_text(
66+
"home = C:\\Python\nversion = 3.14.6\n", encoding="utf-8"
67+
)
68+
return root
69+
70+
71+
@pytest.fixture(scope="session")
72+
def real_venv(tmp_path_factory) -> Path:
73+
"""A genuine venv. ensurepip is bundled, so this needs no network."""
74+
if not IS_WINDOWS:
75+
pytest.skip("wppm targets the Windows layout")
76+
target = tmp_path_factory.mktemp("real") / "venv"
77+
proc = subprocess.run(
78+
[sys.executable, "-m", "venv", str(target)],
79+
capture_output=True, text=True,
80+
)
81+
if proc.returncode != 0:
82+
pytest.skip(f"could not create a venv: {proc.stderr.strip()[:200]}")
83+
return target

tests/test_cli.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# -*- coding: utf-8 -*-
2+
"""End-to-end: run the wppm CLI against a real venv.
3+
4+
These are the commands that returned "OSError: Invalid Python distribution"
5+
for any venv before the target-resolution fix.
6+
"""
7+
import json
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from conftest import windows_only
15+
16+
REPO_ROOT = Path(__file__).resolve().parent.parent
17+
18+
pytestmark = windows_only
19+
20+
21+
def wppm(*args, expect_ok=True):
22+
"""Run `python -m wppm ...` from the repo, not from an installed copy."""
23+
proc = subprocess.run(
24+
[sys.executable, "-X", "utf8", "-m", "wppm", *args],
25+
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=300,
26+
encoding="utf-8", errors="replace",
27+
)
28+
if expect_ok:
29+
assert proc.returncode == 0, f"exit {proc.returncode}\n{proc.stdout}\n{proc.stderr}"
30+
return proc
31+
32+
33+
class TestListing:
34+
def test_lists_packages_of_a_venv(self, real_venv):
35+
out = wppm("-t", str(real_venv), "-ls").stdout
36+
assert "pip" in out
37+
38+
def test_scripts_target_gives_the_same_listing(self, real_venv):
39+
"""-t <venv>\\Scripts used to print nothing at all."""
40+
root = wppm("-t", str(real_venv), "-ls").stdout
41+
scripts = wppm("-t", str(real_venv / "Scripts"), "-ls").stdout
42+
assert scripts.strip() == root.strip()
43+
assert "pip" in scripts
44+
45+
def test_json_listing_is_valid_json(self, real_venv):
46+
out = wppm("-t", str(real_venv), "-ls", "pip", "-j").stdout
47+
assert any(row["package"] == "pip" for row in json.loads(out))
48+
49+
def test_filters_by_expression(self, real_venv):
50+
out = wppm("-t", str(real_venv), "-ls", "^pip$", "-j").stdout
51+
assert [r["package"] for r in json.loads(out)] == ["pip"]
52+
53+
def test_does_not_list_the_running_interpreters_packages(self, real_venv):
54+
"""A -t listing must describe the target, not whatever runs wppm."""
55+
names = {r["package"] for r in json.loads(
56+
wppm("-t", str(real_venv), "-ls", "-j").stdout)}
57+
assert "pytest" not in names
58+
59+
60+
class TestTrees:
61+
def test_downward_tree(self, real_venv):
62+
assert "pip==" in wppm("-t", str(real_venv), "-p", "pip", "-l1").stdout
63+
64+
def test_upward_tree(self, real_venv):
65+
assert "pip==" in wppm("-t", str(real_venv), "-r", "pip", "-l1").stdout
66+
67+
def test_tree_json_parses(self, real_venv):
68+
out = wppm("-t", str(real_venv), "-p", "pip", "-l1", "-j").stdout
69+
assert json.loads(out)
70+
71+
72+
class TestMarkdown:
73+
def test_generates_a_package_index(self, real_venv):
74+
out = wppm("-t", str(real_venv), "-md").stdout
75+
assert "### Python packages" in out
76+
assert "pip" in out
77+
78+
def test_reports_plain_python_not_winpython(self, real_venv):
79+
"""WINPYVER2 is unset here, so it must not claim to be WinPython."""
80+
assert "## Python" in wppm("-t", str(real_venv), "-md").stdout
81+
82+
def test_tools_section_reports_the_target_python_version(self, real_venv):
83+
"""Sanity check only: this venv comes from the running interpreter, so it
84+
cannot tell target from runner -- test_distribution.py does that.
85+
"""
86+
data = json.loads(wppm("-t", str(real_venv), "-md", "-j").stdout)
87+
tools = {t["name"]: t["version"] for t in data["tools"]}
88+
expected = subprocess.run(
89+
[str(real_venv / "Scripts" / "python.exe"), "-c",
90+
"import platform;print(platform.python_version())"],
91+
capture_output=True, text=True,
92+
).stdout.strip()
93+
assert tools["Python"].startswith(expected)
94+
95+
def test_json_manifest_has_the_expected_shape(self, real_venv):
96+
data = json.loads(wppm("-t", str(real_venv), "-md", "-j").stdout)
97+
assert set(data) >= {"distribution", "tools", "packages", "wheelhouse"}
98+
assert set(data["distribution"]) >= {"name", "version", "python_version"}
99+
100+
101+
class TestBadTarget:
102+
def test_rejects_a_directory_that_is_not_a_python(self, tmp_path):
103+
proc = wppm("-t", str(tmp_path), "-md", expect_ok=False)
104+
assert proc.returncode != 0
105+
assert "Invalid Python distribution" in proc.stdout + proc.stderr
106+
107+
108+
class TestHelp:
109+
def test_help_mentions_the_version(self):
110+
from wppm import __version__
111+
assert __version__ in wppm("-h").stdout

tests/test_distribution.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# -*- coding: utf-8 -*-
2+
"""Distribution and the -md package index.
3+
4+
The CLI tests cannot catch "-md described the wrong interpreter", because a
5+
venv made from the running Python reports the same version either way. These
6+
check which path the code actually asks about.
7+
"""
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from wppm import utils, wppm
13+
14+
from conftest import write_dist
15+
16+
17+
@pytest.fixture
18+
def target(tmp_path, monkeypatch):
19+
"""A synthetic distribution, with the spawning probes stubbed out."""
20+
root = tmp_path / "WPy64-1234" / "python"
21+
site = root / "Lib" / "site-packages"
22+
site.mkdir(parents=True)
23+
(root / "python.exe").write_bytes(b"MZ")
24+
write_dist(site, "somepkg", "1.2.3", summary="A package")
25+
monkeypatch.setattr(utils, "get_python_infos", lambda path: ("9.9", 64))
26+
return root
27+
28+
29+
@pytest.fixture
30+
def tools_probe(monkeypatch):
31+
"""Record the path -md asks get_installed_tools about."""
32+
seen = []
33+
34+
def fake(path=None):
35+
seen.append(path)
36+
return [("Python", "http://www.python.org/", "9.9.9", "stub")]
37+
38+
monkeypatch.setattr(utils, "get_installed_tools", fake)
39+
return seen
40+
41+
42+
class TestPackageIndexTarget:
43+
def test_tools_are_read_from_the_target_not_the_running_interpreter(
44+
self, target, tools_probe
45+
):
46+
"""Regression: -md used to describe whatever interpreter ran wppm."""
47+
wppm.Distribution(str(target)).get_package_index_data()
48+
assert len(tools_probe) == 1
49+
asked = Path(tools_probe[0])
50+
assert target in asked.parents or asked == target / "python.exe"
51+
52+
def test_explicit_directory_argument_still_wins(self, target, tools_probe, tmp_path):
53+
other = tmp_path / "other"
54+
other.mkdir()
55+
(other / "python.exe").write_bytes(b"MZ")
56+
wppm.Distribution(str(target)).get_package_index_data(
57+
python_executable_directory=str(other)
58+
)
59+
assert Path(tools_probe[0]) == other / "python.exe"
60+
61+
def test_packages_come_from_the_target(self, target, tools_probe):
62+
data = wppm.Distribution(str(target)).get_package_index_data()
63+
assert [p["name"] for p in data["packages"]] == ["somepkg"]
64+
65+
def test_identity_is_plain_python_without_winpyver2(
66+
self, target, tools_probe, monkeypatch
67+
):
68+
monkeypatch.delenv("WINPYVER2", raising=False)
69+
data = wppm.Distribution(str(target)).get_package_index_data()
70+
assert data["distribution"]["name"] == "Python"
71+
72+
def test_identity_is_winpython_when_told_so(self, target, tools_probe):
73+
data = wppm.Distribution(str(target)).get_package_index_data(
74+
winpyver2="3.14.7.0", flavor="slim", release_level="b3"
75+
)
76+
assert data["distribution"]["name"] == "WinPython"
77+
assert data["distribution"]["version"] == "3.14.7.0slim"
78+
79+
def test_explicit_arguments_beat_the_environment(
80+
self, target, tools_probe, monkeypatch
81+
):
82+
"""The build passes these as env vars today; arguments must take priority."""
83+
monkeypatch.setenv("WINPYVER2", "0.0.0.0")
84+
monkeypatch.setenv("WINPYFLAVOR", "wrong")
85+
data = wppm.Distribution(str(target)).get_package_index_data(
86+
winpyver2="3.14.7.0", flavor="slim"
87+
)
88+
assert data["distribution"]["version"] == "3.14.7.0slim"
89+
90+
91+
class TestVersionProbesDoNotCrash:
92+
"""Every version probe used to end in an unguarded splitlines()[0]."""
93+
94+
def test_first_line_falls_back_when_output_is_empty(self):
95+
assert utils.first_line("") == "?"
96+
assert utils.first_line("\n \n") == "?"
97+
98+
def test_first_line_skips_blank_leading_lines(self):
99+
assert utils.first_line("\n\n3.14.2\nrest") == "3.14.2"
100+
101+
def test_installed_tools_survives_a_silent_probe(self, tmp_path, monkeypatch):
102+
"""A tool that prints nothing must not take the whole -md down."""
103+
root = tmp_path / "python"
104+
root.mkdir()
105+
(root / "python.exe").write_bytes(b"MZ")
106+
monkeypatch.setattr(utils, "exec_shell_cmd", lambda *a, **k: "")
107+
tools = utils.get_installed_tools(str(root))
108+
assert [t[0] for t in tools] == ["Python"]
109+
assert tools[0][2] == "?"

tests/test_no_subprocess.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# -*- coding: utf-8 -*-
2+
"""piptree must never spawn a process.
3+
4+
It runs inside JupyterLite/Pyodide, where process spawning does not exist.
5+
This is the executable form of the rule stated in piptree's module docstring:
6+
if someone makes PipData shell out to the target interpreter, these fail.
7+
"""
8+
import subprocess
9+
10+
import pytest
11+
12+
from wppm import piptree
13+
14+
from conftest import write_dist
15+
16+
17+
class Spawned(Exception):
18+
"""Raised instead of starting a process."""
19+
20+
21+
@pytest.fixture
22+
def no_spawn(monkeypatch):
23+
def boom(*args, **kwargs):
24+
raise Spawned("piptree must not spawn a process (it runs in Pyodide)")
25+
26+
for name in ("Popen", "run", "call", "check_call", "check_output"):
27+
monkeypatch.setattr(subprocess, name, boom)
28+
return boom
29+
30+
31+
@pytest.fixture
32+
def target(tmp_path):
33+
root = tmp_path / "dist"
34+
site = root / "Lib" / "site-packages"
35+
site.mkdir(parents=True)
36+
(root / "python.exe").write_bytes(b"MZ")
37+
write_dist(site, "app", "1.0", summary="An app", requires=["lib"])
38+
write_dist(site, "lib", "2.0", summary="A lib")
39+
return root
40+
41+
42+
def test_building_pipdata_spawns_nothing(no_spawn, target):
43+
assert piptree.PipData(str(target)).distro
44+
45+
46+
def test_pip_list_spawns_nothing(no_spawn, target):
47+
pip = piptree.PipData(str(target))
48+
assert len(pip.pip_list(full=True)) == 2
49+
50+
51+
def test_dependency_trees_spawn_nothing(no_spawn, target):
52+
pip = piptree.PipData(str(target))
53+
assert "lib==2.0" in pip.down("app", "", 2)
54+
assert "app==1.0" in pip.up("lib", "", 2)
55+
56+
57+
def test_the_guard_itself_works(no_spawn):
58+
"""Guard against a false negative: prove the patch really blocks spawning."""
59+
with pytest.raises(Spawned):
60+
subprocess.Popen(["cmd", "/c", "echo", "hi"])

0 commit comments

Comments
 (0)