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
48 changes: 37 additions & 11 deletions ci/gates/action_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from __future__ import annotations

import os
import re
import shutil
import subprocess
Expand All @@ -43,16 +44,41 @@


def _binary_path() -> Path | None:
# Prefer the staged location used by the wheel; fall back to debug
# for local dev (`cargo build`).
candidates = [
ROOT / "src" / "template_python_rust_cmd" / "_bin" / BINARY_NAME,
ROOT / "target" / "release" / BINARY_NAME,
ROOT / "target" / "debug" / BINARY_NAME,
]
"""Locate `template-cli` for the runtime --help probe.

Searches, in order:
1. `CARGO_TARGET_DIR/{release,debug}/` if the env var is set —
`ci/build_wheel.py` pins this to
`~/.template-python-rust-cmd/cargo-target/wheel-build/` so the
wheel build's binary doesn't get rebuilt every time.
2. The repo's `target/{release,debug}/` for the `./test` /
`cargo build` flow.
3. PATH (`shutil.which`) for the case where the user has done
`pip install` / `uv tool install` of the built wheel and
wants the gate to validate against the installed binary.

The earlier `src/template_python_rust_cmd/_bin/` candidate was
removed when #7 dropped the package-side staging in favor of
shipping the binary as a raw wheel script. See #9.
"""
candidates: list[Path] = []

cargo_target = os.environ.get("CARGO_TARGET_DIR")
if cargo_target:
candidates.append(Path(cargo_target) / "release" / BINARY_NAME)
candidates.append(Path(cargo_target) / "debug" / BINARY_NAME)

candidates.append(ROOT / "target" / "release" / BINARY_NAME)
candidates.append(ROOT / "target" / "debug" / BINARY_NAME)

for path in candidates:
if path.is_file():
return path

on_path = shutil.which("template-cli")
if on_path:
return Path(on_path)

return None


Expand Down Expand Up @@ -101,10 +127,10 @@ def run() -> int:
# best-effort against whatever build is available locally.
print(
"action_surface: no template-cli binary found "
"(src/template_python_rust_cmd/_bin/, target/release/, "
"target/debug/). Skipping runtime surface check. Run "
"`cargo build -p template-cli` or `./test` to materialize "
"the binary and re-run."
"($CARGO_TARGET_DIR/{release,debug}/, target/release/, "
"target/debug/, PATH). Skipping runtime surface check. "
"Run `cargo build -p template-cli` or `./test` to "
"materialize the binary and re-run."
)
return 0
if not shutil.which(str(binary)) and not binary.exists():
Expand Down
2 changes: 1 addition & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Python-side test suite. Picked up by `pytest` per the configuration in
| File | What it tests |
|-------------------|------------------------------------------------------------------------|
| `test_bindings.py`| The PyO3 extension surface via `template_python_rust_cmd.bindings`. |
| `test_cli.py` | The Python CLI shim's binary-discovery logic. |
| `test_cli.py` | `template-cli` is on PATH after `pip install` (or `uv tool install`) and `template-cli --version` exits 0. Skips when the binary hasn't been installed yet — the wheel-built binary is shipped as a raw script in `<name>-<ver>.data/scripts/` (#7), not as a Python launcher, so there's no source-tree path to probe. |
| `test_version.py` | Package `__version__` is non-empty and matches the manifest. |
| `test_gates.py` | Each gate registered in `ci.py::GATE_ORDER` is importable and exposes `def run() -> int`. The contract test for the gates infra itself. |

Expand Down
66 changes: 61 additions & 5 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,65 @@
"""Sanity check: `template-cli` is on PATH after `pip install`.

The wheel ships `template-cli[.exe]` as a raw script at
`<name>-<ver>.data/scripts/` (see `ci/build_wheel.py` and #7), which
pip drops straight into the venv's `Scripts/` (Windows) or `bin/`
(POSIX) directory. The previous shape — a Python `cli.py` shim that
subprocess-launched a staged `_bin/template-cli` — was removed in #7
because on Windows the shim raced the next shell prompt ahead of the
child's stdout.

This test is a *runtime contract* check: if a downstream user does
`pip install template-python-rust-cmd`, can they then run
`template-cli --version`? It probes PATH directly rather than the
package source tree, because the wheel-side delivery mechanism (raw
script) is fundamentally not visible from the source tree anymore —
there's no `_bin/` directory to look at. See #9.
"""
from __future__ import annotations

import os
import shutil
import subprocess

import pytest

BINARY_NAME = "template-cli.exe" if os.name == "nt" else "template-cli"


@pytest.fixture(scope="module")
def cli_on_path() -> str:
binary = shutil.which("template-cli")
if binary is None:
pytest.skip(
"template-cli not on PATH; this test runs only after "
"`pip install` (or `uv tool install`) of the built wheel. "
"Run `ci/build_wheel.py` and install the result to exercise "
"this gate locally."
)
return binary


def test_cli_on_path_has_expected_name(cli_on_path: str) -> None:
assert os.path.basename(cli_on_path).lower() == BINARY_NAME.lower()

from template_python_rust_cmd.cli import packaged_binary_path

def test_cli_on_path_invokes(cli_on_path: str) -> None:
"""The PATH-resolved `template-cli` runs and exits with code 0 on --version.

def test_packaged_binary_path_points_into_package() -> None:
expected = "template-cli.exe" if os.name == "nt" else "template-cli"
assert packaged_binary_path().name == expected
assert "_bin" in str(packaged_binary_path())
Doubles as a Windows stdout-ordering smoke test: if the wheel
accidentally regressed back to a Python launcher (e.g. someone
added `[project.scripts]` again), `subprocess.run` would still
succeed but the test wouldn't catch the cmd.exe shell-prompt race
— that one needs an interactive console. The `file` / `unzip -l`
check in #2's acceptance criteria covers the static shape; this
test covers "the binary runs at all".
"""
proc = subprocess.run(
[cli_on_path, "--version"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip(), "template-cli --version produced empty stdout"
Loading