Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion scripts/generate_typos_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env -S uv run python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = []
Expand Down
10 changes: 7 additions & 3 deletions template/docs/scripting-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ as a default.
inline.
- Each script starts with an `uv` script block so runtime and dependency
expectations travel with the file. Prefer the shebang
`#!/usr/bin/env -S uv run python` followed by the metadata block shown in the
example below.
`#!/usr/bin/env -S uv run --script` followed by the metadata block shown in
the example below. `uv run --script` reads the PEP 723 inline metadata block
and installs its declared dependencies before execution; `uv run python`
invokes the interpreter directly and silently ignores the metadata block, so
a directly executed script (`./script.py`) fails at import time because its
dependencies were never installed.
- External processes are invoked via
[`cuprum`](https://github.com/leynos/cuprum/) to provide typed,
allowlist-based command execution rather than ad‑hoc shell strings. Cuprum's
Expand Down Expand Up @@ -435,7 +439,7 @@ except FileNotFoundError:
## Cyclopts + cuprum + pathlib together (reference script)

```python
#!/usr/bin/env -S uv run python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["cyclopts>=2.9", "cuprum", "cmd-mox"]
Expand Down
2 changes: 1 addition & 1 deletion template/scripts/generate_typos_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env -S uv run python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = []
Expand Down
135 changes: 135 additions & 0 deletions tests/test_scripting_shebang.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Validate the PEP 723 uv script shebang standard.

``#!/usr/bin/env -S uv run python`` executes the interpreter directly and
silently ignores the PEP 723 inline metadata block (``# /// script`` ...
``# ///``), so a script invoked directly (``./script.py``) fails at import
time because its declared dependencies were never installed. The correct
shebang is ``#!/usr/bin/env -S uv run --script``, which reads the metadata
block and installs the declared dependencies before execution.

This module guards against the broken shebang reappearing anywhere in the
repository or the Copier template tree, and behaviourally proves that the
prescribed shebang works as intended.
"""

from __future__ import annotations

import shutil
import stat
import subprocess
from pathlib import Path

import pytest

REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
BROKEN_SHEBANG = "#!/usr/bin/env -S uv run python"
CORRECT_SHEBANG = "#!/usr/bin/env -S uv run --script"
SCRIPT_BLOCK_MARKER = "# /// script"
EXCLUDED_DIRECTORY_NAMES = {".git"}
LOOKAHEAD_LINES = 5


def _iter_repository_files() -> list[Path]:
"""Return every tracked-style file under the repository, excluding VCS internals."""
files: list[Path] = []
for path in REPOSITORY_ROOT.rglob("*"):
if not path.is_file():
continue
if EXCLUDED_DIRECTORY_NAMES & set(path.relative_to(REPOSITORY_ROOT).parts):
continue
files.append(path)
return files


def _find_broken_shebang_pep723_scripts() -> list[str]:
"""Return relative paths of files whose broken shebang heads a PEP 723 block.

A violation is a line exactly matching ``BROKEN_SHEBANG`` with a
``# /// script`` marker within the next few lines, which indicates the
shebang introduces a PEP 723 inline-metadata script rather than merely
appearing in prose or an unrelated command example.
"""
violations: list[str] = []
for path in _iter_repository_files():
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
lines = text.splitlines()
for index, line in enumerate(lines):
if line.strip() != BROKEN_SHEBANG:
continue
lookahead = lines[index + 1 : index + 1 + LOOKAHEAD_LINES]
if any(SCRIPT_BLOCK_MARKER in candidate for candidate in lookahead):
violations.append(str(path.relative_to(REPOSITORY_ROOT)))
return violations


def test_no_broken_uv_shebang_heads_a_pep723_script() -> None:
"""No file in the repository or template tree ships the broken shebang.

Returns
-------
None
The test passes when every PEP 723 script block is introduced by
``#!/usr/bin/env -S uv run --script`` rather than the broken
``#!/usr/bin/env -S uv run python`` form, which silently ignores the
metadata block on direct execution.
"""
violations = _find_broken_shebang_pep723_scripts()
assert violations == [], (
"expected no PEP 723 script to be headed by the broken "
f"'{BROKEN_SHEBANG}' shebang; offending files: {violations}"
)


def test_correct_uv_shebang_installs_declared_dependency(tmp_path: Path) -> None:
"""A script with the prescribed shebang installs and imports its dependency.

Parameters
----------
tmp_path : Path
Temporary directory used to host the executable script under test.

Returns
-------
None
The test passes when a directly executed script using
``#!/usr/bin/env -S uv run --script`` installs its declared
dependency, imports it successfully, and exits with code ``0``.
"""
uv_executable = shutil.which("uv")
if uv_executable is None:
pytest.skip("uv is unavailable to exercise the shebang behaviourally")

script_path = tmp_path / "shebang_probe.py"
script_source = (
f"{CORRECT_SHEBANG}\n"
"# /// script\n"
'# requires-python = ">=3.13"\n'
'# dependencies = ["packaging"]\n'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid resolving PyPI during this unit test

In network-restricted CI or local runs where uv is installed but PyPI is unavailable, this test fails before it can validate the shebang: the temp script declares packaging, so uv run --script has to resolve the package from the default PyPI index at execution time. I reproduced python -m pytest tests/test_scripting_shebang.py -q failing with Failed to fetch: https://pypi.org/simple/packaging/; use a local path dependency or skip when dependency resolution cannot run rather than making the parent test suite depend on live PyPI.

Useful? React with 👍 / 👎.

"# ///\n"
"\n"
"import packaging\n"
"\n"
'print(f"packaging-ok:{packaging.__version__}")\n'
)
script_path.write_text(script_source, encoding="utf-8")
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC)

result = subprocess.run( # noqa: S603 - argv is the freshly written temp script.
[str(script_path)],
capture_output=True,
text=True,
timeout=120,
check=False,
)

assert result.returncode == 0, (
"expected the correctly shebanged script to run and exit cleanly:\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "packaging-ok:" in result.stdout, (
"expected the script to successfully import its declared dependency:\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
Loading