Skip to content

Commit fecccd8

Browse files
author
leynos
committed
Prescribe the uv --script shebang and test it
The scripting standards document and the two generate_typos_config.py scripts (root and template copies) shebanged their PEP 723 script blocks with `#!/usr/bin/env -S uv run python`. That form executes the interpreter directly and silently ignores the inline metadata block, so a directly invoked script fails at import time because its declared dependencies were never installed. `#!/usr/bin/env -S uv run --script` reads the metadata block and installs the dependencies first, which is what every PEP 723 script actually needs. Fix all three occurrences and add a documented rationale to the scripting standards guide. Add tests/test_scripting_shebang.py, which asserts no file in the repository or template tree ships the broken shebang above a PEP 723 block, and behaviourally proves the correct shebang installs and imports a declared dependency when the script is executed directly.
1 parent 2bcb36d commit fecccd8

4 files changed

Lines changed: 144 additions & 5 deletions

File tree

scripts/generate_typos_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env -S uv run python
1+
#!/usr/bin/env -S uv run --script
22
# /// script
33
# requires-python = ">=3.13"
44
# dependencies = []

template/docs/scripting-standards.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ as a default.
3030
inline.
3131
- Each script starts with an `uv` script block so runtime and dependency
3232
expectations travel with the file. Prefer the shebang
33-
`#!/usr/bin/env -S uv run python` followed by the metadata block shown in the
34-
example below.
33+
`#!/usr/bin/env -S uv run --script` followed by the metadata block shown in
34+
the example below. `uv run --script` reads the PEP 723 inline metadata block
35+
and installs its declared dependencies before execution; `uv run python`
36+
invokes the interpreter directly and silently ignores the metadata block, so
37+
a directly executed script (`./script.py`) fails at import time because its
38+
dependencies were never installed.
3539
- External processes are invoked via
3640
[`cuprum`](https://github.com/leynos/cuprum/) to provide typed,
3741
allowlist-based command execution rather than ad‑hoc shell strings. Cuprum's
@@ -435,7 +439,7 @@ except FileNotFoundError:
435439
## Cyclopts + cuprum + pathlib together (reference script)
436440

437441
```python
438-
#!/usr/bin/env -S uv run python
442+
#!/usr/bin/env -S uv run --script
439443
# /// script
440444
# requires-python = ">=3.13"
441445
# dependencies = ["cyclopts>=2.9", "cuprum", "cmd-mox"]

template/scripts/generate_typos_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env -S uv run python
1+
#!/usr/bin/env -S uv run --script
22
# /// script
33
# requires-python = ">=3.13"
44
# dependencies = []

tests/test_scripting_shebang.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Validate the PEP 723 uv script shebang standard.
2+
3+
``#!/usr/bin/env -S uv run python`` executes the interpreter directly and
4+
silently ignores the PEP 723 inline metadata block (``# /// script`` ...
5+
``# ///``), so a script invoked directly (``./script.py``) fails at import
6+
time because its declared dependencies were never installed. The correct
7+
shebang is ``#!/usr/bin/env -S uv run --script``, which reads the metadata
8+
block and installs the declared dependencies before execution.
9+
10+
This module guards against the broken shebang reappearing anywhere in the
11+
repository or the Copier template tree, and behaviourally proves that the
12+
prescribed shebang works as intended.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import shutil
18+
import stat
19+
import subprocess
20+
from pathlib import Path
21+
22+
import pytest
23+
24+
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
25+
BROKEN_SHEBANG = "#!/usr/bin/env -S uv run python"
26+
CORRECT_SHEBANG = "#!/usr/bin/env -S uv run --script"
27+
SCRIPT_BLOCK_MARKER = "# /// script"
28+
EXCLUDED_DIRECTORY_NAMES = {".git"}
29+
LOOKAHEAD_LINES = 5
30+
31+
32+
def _iter_repository_files() -> list[Path]:
33+
"""Return every tracked-style file under the repository, excluding VCS internals."""
34+
files: list[Path] = []
35+
for path in REPOSITORY_ROOT.rglob("*"):
36+
if not path.is_file():
37+
continue
38+
if EXCLUDED_DIRECTORY_NAMES & set(path.relative_to(REPOSITORY_ROOT).parts):
39+
continue
40+
files.append(path)
41+
return files
42+
43+
44+
def _find_broken_shebang_pep723_scripts() -> list[str]:
45+
"""Return relative paths of files whose broken shebang heads a PEP 723 block.
46+
47+
A violation is a line exactly matching ``BROKEN_SHEBANG`` with a
48+
``# /// script`` marker within the next few lines, which indicates the
49+
shebang introduces a PEP 723 inline-metadata script rather than merely
50+
appearing in prose or an unrelated command example.
51+
"""
52+
violations: list[str] = []
53+
for path in _iter_repository_files():
54+
try:
55+
text = path.read_text(encoding="utf-8")
56+
except (UnicodeDecodeError, OSError):
57+
continue
58+
lines = text.splitlines()
59+
for index, line in enumerate(lines):
60+
if line.strip() != BROKEN_SHEBANG:
61+
continue
62+
lookahead = lines[index + 1 : index + 1 + LOOKAHEAD_LINES]
63+
if any(SCRIPT_BLOCK_MARKER in candidate for candidate in lookahead):
64+
violations.append(str(path.relative_to(REPOSITORY_ROOT)))
65+
return violations
66+
67+
68+
def test_no_broken_uv_shebang_heads_a_pep723_script() -> None:
69+
"""No file in the repository or template tree ships the broken shebang.
70+
71+
Returns
72+
-------
73+
None
74+
The test passes when every PEP 723 script block is introduced by
75+
``#!/usr/bin/env -S uv run --script`` rather than the broken
76+
``#!/usr/bin/env -S uv run python`` form, which silently ignores the
77+
metadata block on direct execution.
78+
"""
79+
violations = _find_broken_shebang_pep723_scripts()
80+
assert violations == [], (
81+
"expected no PEP 723 script to be headed by the broken "
82+
f"'{BROKEN_SHEBANG}' shebang; offending files: {violations}"
83+
)
84+
85+
86+
def test_correct_uv_shebang_installs_declared_dependency(tmp_path: Path) -> None:
87+
"""A script with the prescribed shebang installs and imports its dependency.
88+
89+
Parameters
90+
----------
91+
tmp_path : Path
92+
Temporary directory used to host the executable script under test.
93+
94+
Returns
95+
-------
96+
None
97+
The test passes when a directly executed script using
98+
``#!/usr/bin/env -S uv run --script`` installs its declared
99+
dependency, imports it successfully, and exits with code ``0``.
100+
"""
101+
uv_executable = shutil.which("uv")
102+
if uv_executable is None:
103+
pytest.skip("uv is unavailable to exercise the shebang behaviourally")
104+
105+
script_path = tmp_path / "shebang_probe.py"
106+
script_source = (
107+
f"{CORRECT_SHEBANG}\n"
108+
"# /// script\n"
109+
'# requires-python = ">=3.13"\n'
110+
'# dependencies = ["packaging"]\n'
111+
"# ///\n"
112+
"\n"
113+
"import packaging\n"
114+
"\n"
115+
'print(f"packaging-ok:{packaging.__version__}")\n'
116+
)
117+
script_path.write_text(script_source, encoding="utf-8")
118+
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC)
119+
120+
result = subprocess.run( # noqa: S603 - argv is the freshly written temp script.
121+
[str(script_path)],
122+
capture_output=True,
123+
text=True,
124+
timeout=120,
125+
check=False,
126+
)
127+
128+
assert result.returncode == 0, (
129+
"expected the correctly shebanged script to run and exit cleanly:\n"
130+
f"stdout: {result.stdout}\nstderr: {result.stderr}"
131+
)
132+
assert "packaging-ok:" in result.stdout, (
133+
"expected the script to successfully import its declared dependency:\n"
134+
f"stdout: {result.stdout}\nstderr: {result.stderr}"
135+
)

0 commit comments

Comments
 (0)