Repository navigation
Expand file tree
/
Copy pathtest_cli.py
More file actions
370 lines (287 loc) · 14.1 KB
/
Copy pathtest_cli.py
File metadata and controls
370 lines (287 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""The CLI's contracts (docs/cli-style-guide.md): output snapshots (rule 47), exit codes (48), the
--json schema (50), the public API (44), the startup budget (35) and the generated CLI reference
(34). Regenerate snapshots after an intended change with:
UPDATE_SNAPSHOTS=1 uv run pytest -q tests/test_cli.py
"""
from __future__ import annotations
import inspect
import json
import os
import re
import subprocess
import sys
from dataclasses import fields
from pathlib import Path
import jsonschema
import pytest
import bundleup
from bundleup import _cli
ROOT = Path(__file__).parent.parent
SNAPSHOTS = Path(__file__).parent / "snapshots"
SCHEMA = json.loads((ROOT / "docs" / "schema" / "build-v1.json").read_text())
CHECK_SCHEMA = json.loads((ROOT / "docs" / "schema" / "check-v1.json").read_text())
UPDATE = os.environ.get("UPDATE_SNAPSHOTS") == "1"
# argparse's help layout changes between Python versions; help snapshots use the dev Python.
HELP_PYTHON = (3, 12)
help_snapshot = pytest.mark.skipif(
sys.version_info[:2] != HELP_PYTHON, reason="argparse help layout differs by Python version"
)
SCRIPT = """\
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
print("hi")
"""
# Doesn't compile on any Python: the check's syntax-error finding, in the project's own code.
BROKEN = SCRIPT.replace('print("hi")', 'print("hi"')
def cli(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]:
"""Run `python -m bundleup` the way a user's terminal would, minus colour."""
env = {k: v for k, v in os.environ.items() if k not in ("FORCE_COLOR", "BUNDLEUP_PYTHON")}
env |= {"NO_COLOR": "1", "PYTHONIOENCODING": "utf-8"}
return subprocess.run(
[sys.executable, "-m", "bundleup", *args],
cwd=cwd,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
)
def redact(text: str, tmp: Path) -> str:
"""Remove what varies between machines and runs: paths, sizes, durations, the platform."""
text = text.replace(str(tmp), "<TMP>").replace(sys.executable, "<PYTHON>")
text = re.sub(r"\d+(\.\d+)? (B|KiB|MiB|GiB)\b", "<SIZE>", text)
text = re.sub(r"in \d+\.\d+s", "in <DURATION>", text)
text = re.sub(r"Python \d+\.\d+(\.\d+)?(\+|-\d+\.\d+)?", "Python <VERSION>", text)
text = re.sub(r" on (macOS|Linux|Windows)( \w+)?", " on <PLATFORM>", text)
return text.replace("\\", "/")
def check_snapshot(name: str, text: str) -> None:
path = SNAPSHOTS / f"{name}.txt"
if UPDATE:
SNAPSHOTS.mkdir(exist_ok=True)
path.write_text(text, encoding="utf-8")
assert path.exists(), f"no snapshot {path.name}; run with UPDATE_SNAPSHOTS=1"
assert text == path.read_text(encoding="utf-8")
@pytest.fixture
def script(tmp_path: Path) -> Path:
path = tmp_path / "app.py"
path.write_text(SCRIPT)
return path
# --- output snapshots (rule 47) and exit codes (rule 48) ---------------------------------------
@help_snapshot
def test_help(tmp_path: Path) -> None:
r = cli("--help", cwd=tmp_path)
assert r.returncode == 0 and r.stderr == ""
check_snapshot("help", r.stdout)
@help_snapshot
def test_build_help(tmp_path: Path) -> None:
r = cli("build", "--help", cwd=tmp_path)
assert r.returncode == 0 and r.stderr == ""
assert len(r.stdout.splitlines()) <= 32 # rule 32: short enough to read at a glance
check_snapshot("build-help", r.stdout)
def test_version(tmp_path: Path) -> None:
r = cli("--version", cwd=tmp_path)
assert (r.returncode, r.stdout, r.stderr) == (0, f"bundleup {bundleup.__version__}\n", "")
@help_snapshot
def test_bare_command_prints_help_and_fails(tmp_path: Path) -> None:
r = cli(cwd=tmp_path)
assert r.returncode == 2 and r.stdout == ""
check_snapshot("bare", r.stderr)
def test_unknown_command_suggests_the_closest(tmp_path: Path) -> None:
r = cli("buld", cwd=tmp_path)
assert r.returncode == 2 and r.stdout == ""
check_snapshot("typo", r.stderr)
def test_success(tmp_path: Path, script: Path) -> None:
r = cli("build", "app.py", "-o", "app.pyz", cwd=tmp_path)
assert r.returncode == 0, r.stderr
assert r.stdout == "" # stdout is for machine output only (rule 10)
assert len(r.stderr.splitlines()) <= 2 # rule 11
check_snapshot("success", redact(r.stderr, tmp_path))
def test_quiet_success_prints_nothing(tmp_path: Path, script: Path) -> None:
r = cli("build", "app.py", "-o", "app.pyz", "-q", cwd=tmp_path)
assert (r.returncode, r.stdout, r.stderr) == (0, "", "")
@help_snapshot
def test_check_help(tmp_path: Path) -> None:
r = cli("check", "--help", cwd=tmp_path)
assert r.returncode == 0 and r.stderr == ""
assert len(r.stdout.splitlines()) <= 32 # rule 32
check_snapshot("check-help", r.stdout)
def test_check_success(tmp_path: Path, script: Path) -> None:
r = cli("check", "app.py", cwd=tmp_path)
assert r.returncode == 0 and r.stdout == "", r.stderr
check_snapshot("check-success", redact(r.stderr, tmp_path))
assert not (tmp_path / "dist").exists() # check never writes a bundle
def redact_compiler(text: str) -> str:
"""The compiler's wording differs between Python versions."""
return re.sub(r"(doesn't compile on Python <VERSION>): .*", r"\1: <MESSAGE>", text)
def test_check_and_build_report_code_that_does_not_compile(tmp_path: Path) -> None:
(tmp_path / "app.py").write_text(BROKEN)
r = cli("check", "app.py", cwd=tmp_path)
assert r.returncode == 1 and r.stdout == ""
check_snapshot("check-syntax-error", redact_compiler(redact(r.stderr, tmp_path)))
r = cli("build", "app.py", "-o", "app.pyz", cwd=tmp_path)
assert r.returncode == 1 and not (tmp_path / "app.pyz").exists()
check_snapshot("error-check-failed", redact_compiler(redact(r.stderr, tmp_path)))
r = cli("build", "app.py", "--json", cwd=tmp_path)
document = json.loads(r.stdout)
jsonschema.validate(document, SCHEMA)
assert [d["code"] for d in document["diagnostics"]] == ["syntax-error", "check-failed"]
assert document["diagnostics"][0]["file"] == "__bundleup_script__/app.py"
def test_check_json_matches_the_schema(tmp_path: Path, script: Path) -> None:
r = cli("check", "app.py", "--json", cwd=tmp_path)
assert r.returncode == 0 and r.stderr == ""
document = json.loads(r.stdout)
jsonschema.validate(document, CHECK_SCHEMA)
assert document["ok"] and document["diagnostics"] == []
assert [p["name"] for p in document["result"]["packages"]] == ["app"]
(tmp_path / "app.py").write_text(BROKEN)
r = cli("check", "app.py", "--json", cwd=tmp_path)
document = json.loads(r.stdout)
jsonschema.validate(document, CHECK_SCHEMA)
assert (r.returncode, document["ok"]) == (1, False)
assert document["diagnostics"][0]["line"] == 5
def test_max_size_refuses_before_writing(tmp_path: Path, script: Path) -> None:
r = cli("build", "app.py", "--max-size", "1KB", cwd=tmp_path)
assert r.returncode == 1 and "over --max-size 1 KB" in r.stderr, r.stderr
assert not (tmp_path / "dist" / "app.pyz").exists()
r = cli("build", "app.py", "--max-size", "lots", cwd=tmp_path)
assert r.returncode == 2 and "can't read the size `lots`" in r.stderr
def test_check_also_platform(tmp_path: Path, script: Path) -> None:
r = cli(
"check",
"app.py",
"--python-platform",
"windows",
"--python-platform",
"linux",
"--json",
cwd=tmp_path,
)
assert r.returncode == 0, r.stderr
jsonschema.validate(json.loads(r.stdout), CHECK_SCHEMA)
r = cli("check", "app.py", "--python-platform", "solaris", cwd=tmp_path)
assert r.returncode == 2 and "can't build for 'solaris'" in r.stderr
def test_check_matrix(tmp_path: Path, script: Path) -> None:
r = cli("check", "app.py", "--matrix", "--json", cwd=tmp_path)
assert r.returncode == 0, r.stderr
document = json.loads(r.stdout)
jsonschema.validate(document, CHECK_SCHEMA)
assert document["result"]["matrix"] and all(c["ok"] for c in document["result"]["matrix"])
r = cli("check", "app.py", "--matrix", cwd=tmp_path)
assert r.stdout.startswith("Wheels for each platform") # a listing: stdout
def test_error_not_a_project(tmp_path: Path) -> None:
r = cli("build", "nowhere", cwd=tmp_path)
assert r.returncode == 1 and r.stdout == ""
check_snapshot("error-not-a-project", redact(r.stderr, tmp_path))
def test_error_python_mismatch(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "future"\nrequires-python = ">=99"\n'
)
(tmp_path / "uv.lock").write_text("") # checked before the lock is read
r = cli("build", "--python", sys.executable, cwd=tmp_path)
assert r.returncode == 1
check_snapshot("error-python-mismatch", redact(r.stderr, tmp_path))
def test_error_no_lockfile(tmp_path: Path) -> None:
(tmp_path / "app").mkdir()
(tmp_path / "app" / "pyproject.toml").write_text('[project]\nname = "app"\n')
r = cli("build", "app", "--locked", cwd=tmp_path)
assert r.returncode == 1 and not (tmp_path / "app" / "uv.lock").exists()
check_snapshot("error-no-lockfile", redact(r.stderr, tmp_path))
def test_usage_error_entry_with_script(tmp_path: Path, script: Path) -> None:
r = cli("build", "app.py", "--entry", "app:main", cwd=tmp_path)
assert r.returncode == 2
check_snapshot("error-entry-with-script", redact(r.stderr, tmp_path))
def test_crash_is_reported_as_a_bug(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], script: Path
) -> None:
def explode(*args: object, **kwargs: object) -> None:
raise RuntimeError("boom")
monkeypatch.setattr("bundleup._build.build", explode)
assert _cli.main(["build", str(script)]) == 3
captured = capsys.readouterr()
assert captured.out == ""
check_snapshot("crash", captured.err)
assert _cli.main(["build", str(script), "-v"]) == 3
assert "Traceback" in capsys.readouterr().err # -v shows it
def test_ctrl_c_exits_130(monkeypatch: pytest.MonkeyPatch, script: Path) -> None:
def interrupt(*args: object, **kwargs: object) -> None:
raise KeyboardInterrupt
monkeypatch.setattr("bundleup._build.build", interrupt)
assert _cli.main(["build", str(script)]) == 130
# --- --json (rules 16-20, 50) -------------------------------------------------------------------
def test_json_success_matches_the_schema(tmp_path: Path, script: Path) -> None:
r = cli("build", "app.py", "-o", "app.pyz", "--json", cwd=tmp_path)
assert r.returncode == 0 and r.stderr == "" # human output is suppressed
document = json.loads(r.stdout) # exactly one document
jsonschema.validate(document, SCHEMA)
assert document["ok"] and document["result"]["output"] == "app.pyz"
assert document["result"]["version"] is None # a script has no version
def test_json_error_matches_the_schema(tmp_path: Path) -> None:
r = cli("build", "nowhere", "--json", cwd=tmp_path)
assert r.returncode == 1 and r.stderr == ""
document = json.loads(r.stdout)
jsonschema.validate(document, SCHEMA)
assert [d["code"] for d in document["diagnostics"]] == ["invalid-project"]
def test_json_usage_error(tmp_path: Path) -> None:
r = cli("build", "--nope", "--json", cwd=tmp_path)
assert r.returncode == 2 and r.stderr == ""
jsonschema.validate(json.loads(r.stdout), SCHEMA)
# --- the public API (rule 44) -------------------------------------------------------------------
def api_description() -> str:
lines = [f"__all__ = {sorted(bundleup.__all__)}"]
lines.append(f"build{inspect.signature(bundleup.build)}")
lines.append(f"check{inspect.signature(bundleup.check)}")
lines.append(f"verify{inspect.signature(bundleup.verify)}")
lines.append(f"list_cache{inspect.signature(bundleup.list_cache)}")
lines.append(f"clean_cache{inspect.signature(bundleup.clean_cache)}")
public_types = (
bundleup.BuildOptions,
bundleup.BuildResult,
bundleup.ProgressEvent,
bundleup.Diagnostic,
bundleup.CheckReport,
bundleup.PackageSize,
bundleup.VerifyReport,
bundleup.CachedBundle,
bundleup.CleanReport,
)
for cls in public_types:
lines.append(f"{cls.__name__}: " + ", ".join(f"{f.name}: {f.type}" for f in fields(cls)))
lines.append("ExitCode: " + ", ".join(f"{c.name}={c.value}" for c in bundleup.ExitCode))
for name in sorted(bundleup.__all__):
obj = getattr(bundleup, name)
if isinstance(obj, type) and issubclass(obj, bundleup.BundleupError):
lines.append(f"{name}: code={obj.code!r} exit_code={int(obj.exit_code)}")
return "\n".join(lines) + "\n"
def test_public_api_is_unchanged() -> None:
check_snapshot("api", api_description())
# --- startup budget (rule 35) and the generated reference (rule 34) ------------------------------
def test_version_and_help_import_nothing_heavy(tmp_path: Path) -> None:
for args in (["--version"], ["--help"]):
r = subprocess.run(
[sys.executable, "-X", "importtime", "-m", "bundleup", *args],
cwd=tmp_path,
capture_output=True,
text=True,
)
imported = {line.split("|")[-1].strip() for line in r.stderr.splitlines() if "|" in line}
heavy = {"bundleup._build", "packaging", "zipfile", "subprocess", "tempfile", "difflib"}
assert not heavy & imported, f"{args} imported {sorted(heavy & imported)}"
def reference_markdown() -> str:
sections = []
for name, command in _cli.parsers().items():
title = "bundleup" if name == "bundleup" else f"bundleup {name}"
sections.append(f"## `{title}`\n\n```\n{command.format_help()}```\n")
return (
"# Command-line reference\n\n"
"Generated from the parser by `tests/test_cli.py` (rule 34 of the "
"[CLI style guide](cli-style-guide.md)); don't edit by hand. Regenerate with\n"
"`UPDATE_SNAPSHOTS=1 uv run pytest -q tests/test_cli.py`.\n\n" + "\n".join(sections)
)
@help_snapshot
def test_cli_reference_is_up_to_date() -> None:
path = ROOT / "docs" / "cli-reference.md"
text = reference_markdown()
if UPDATE:
path.write_text(text, encoding="utf-8")
assert path.read_text(encoding="utf-8") == text, "docs/cli-reference.md is stale"