diff --git a/graphify/hooks.py b/graphify/hooks.py index d284903e5..211c074be 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -67,10 +67,17 @@ # POSIX launcher: parse the shebang. head -c + tr strip NUL bytes first — # when the launcher is a Windows binary reached without its .exe suffix, # a raw `head -1` reads binary into the command substitution and the - # shell warns about ignored null bytes on every commit. + # shell warns about ignored null bytes on every commit. Gate on a + # leading '#!': a launcher can also be a binary trampoline with no + # shebang at all (uv tool installs on Windows), and its bytes must + # never reach the shebang parse (#2852). case "$GRAPHIFY_BIN" in - *.exe) _SHEBANG="" ;; - *) _SHEBANG=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000' | head -n 1 | sed 's/^#![[:space:]]*//') ;; + *.exe) _GFY_HEAD="" ;; + *) _GFY_HEAD=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000') ;; + esac + case "$_GFY_HEAD" in + '#!'*) _SHEBANG=$(printf '%s\\n' "$_GFY_HEAD" | head -n 1 | sed 's/^#![[:space:]]*//') ;; + *) _SHEBANG="" ;; esac case "$_SHEBANG" in */env\\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;; @@ -86,6 +93,29 @@ fi fi fi +# Fourth probe: uv tool environments. `uv tool install` (the README's +# recommended method) puts graphify in an isolated venv that no ambient +# python can import, and on Windows its launcher on PATH is a binary +# trampoline with no shebang to parse — so the probes above can all miss a +# healthy install and the hook dies at the last-resort fallback (#2852). +# Scan the uv tool envs directly; UV_TOOL_DIR overrides the default +# location. A tool env is adopted only if its python passes the probe, so a +# co-installed tool without graphify never satisfies it. +if [ -z "$GRAPHIFY_PYTHON" ]; then + for _GFY_TOOLS in \ + "${UV_TOOL_DIR:-}" \ + "$HOME/.local/share/uv/tools" \ + "$HOME/AppData/Roaming/uv/tools"; do + [ -n "$_GFY_TOOLS" ] || continue + for _GFY_CAND in "$_GFY_TOOLS"/*/bin/python "$_GFY_TOOLS"/*/Scripts/python.exe; do + [ -x "$_GFY_CAND" ] || continue + if "$_GFY_CAND" -c "$_GFY_PROBE" 2>/dev/null; then + GRAPHIFY_PYTHON="$_GFY_CAND" + break 2 + fi + done + done +fi # Last resort: try python3 / python (works for system/venv installs on PATH). if [ -z "$GRAPHIFY_PYTHON" ]; then if command -v python3 >/dev/null 2>&1 && python3 -c "$_GFY_PROBE" 2>/dev/null; then diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 3d8bcfc2a..e00d7e298 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -450,6 +450,148 @@ def test_probe_prefers_sibling_python_exe_on_windows_layouts(): assert "/python.exe" in _PYTHON_DETECT +# ── #2852: interpreter resolution under uv tool installs ─────────────────── + +def _detect_run(tmp_path, home, stub_bin, env_extra=None): + """Run the emitted _PYTHON_DETECT under a real sh in a controlled + environment — dead pin, no .graphify_python, an unparseable launcher first + on PATH, and a python3 that cannot import graphify (#2852's uv-tool + Windows machine reproduced on POSIX) — and report what GRAPHIFY_PYTHON + resolved to. Behavior of the emitted script, not the source string + (per the #2126/#2641 convention).""" + from graphify.hooks import _PYTHON_DETECT + script = tmp_path / "detect_run.sh" + script.write_text( + _PYTHON_DETECT + '\necho "RESOLVED=$GRAPHIFY_PYTHON"\n', + encoding="utf-8", newline="\n", + ) + env = dict(os.environ) + env["HOME"] = str(home) + env.pop("UV_TOOL_DIR", None) + if env_extra: + env.update(env_extra) + env["PATH"] = str(stub_bin) + os.pathsep + env["PATH"] + return subprocess.run( + ["sh", script.name], capture_output=True, text=True, + cwd=str(tmp_path), env=env, + ) + + +def _broken_uv_machine(tmp_path): + """#2852's machine: the only graphify-importable python lives in the uv + tool venv; the launcher on PATH is a binary trampoline reached WITHOUT its + .exe suffix (Git-Bash command -v); ambient python3 cannot import graphify. + Returns (home, stub_bin).""" + home = tmp_path / "home" + stub_bin = tmp_path / "stubbin" + stub_bin.mkdir(parents=True) + launcher = stub_bin / "graphify" + launcher.write_bytes(b"MZ\x90\x00\x03" + bytes(range(60))) + launcher.chmod(0o755) + # Ambient pythons answer the probe with "no module named graphify" — + # under uv tool install no system python can see the isolated venv (#2852). + for name in ("python3", "python"): + py = stub_bin / name + py.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8", newline="\n") + py.chmod(0o755) + return home, stub_bin + + +def _tool_venv(home, tool, rel, ok): + """Create a fake uv tool env python under /.local/share/uv/tools; + ok=False simulates a venv without graphify (the probe must reject it).""" + py = home / ".local" / "share" / "uv" / "tools" / tool / rel + py.parent.mkdir(parents=True, exist_ok=True) + py.write_text( + "#!/bin/sh\nexit 0\n" if ok else "#!/bin/sh\nexit 1\n", + encoding="utf-8", newline="\n", + ) + py.chmod(0o755) + return py + + +@pytest.mark.skipif(shutil.which("sh") is None, reason="sh required to run emitted probe chain") +def test_uv_tool_env_rescues_hook_when_pin_and_launcher_fail(tmp_path): + """#2852: `uv tool install` puts graphify in an isolated venv no ambient + python can import, and on Windows the launcher is a binary trampoline with + no shebang to parse. With the pin dead (git-template hooks, a pin rejected + by the allowlist, or a venv moved by an upgrade), every earlier probe + missed and the hook no-op'd with only a warning. The uv tool-env scan must + adopt the venv whose python passes the probe — and keep walking past + sibling tool envs that do not (glob order puts aaa-tool first).""" + home, stub_bin = _broken_uv_machine(tmp_path) + other = _tool_venv(home, "aaa-plain-tool", "bin/python", ok=False) + mine = _tool_venv(home, "graphifyy", "bin/python", ok=True) + res = _detect_run(tmp_path, home, stub_bin) + assert res.returncode == 0, res.stderr + assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr + assert f"RESOLVED={other}" not in res.stdout + assert "could not locate" not in res.stderr + + +@pytest.mark.skipif(shutil.which("sh") is None or os.name == "nt", + reason="sh required; a sh-script named python.exe only execs on POSIX") +def test_uv_tool_env_honors_uv_tool_dir_and_windows_layout(tmp_path): + """UV_TOOL_DIR overrides the default location, and the Windows layout + (`\\Scripts\\python.exe`) is scanned too — the reporter's exact + machine (#2852).""" + home, stub_bin = _broken_uv_machine(tmp_path) + tools = tmp_path / "custom-tools" + py = tools / "graphifyy" / "Scripts" / "python.exe" + py.parent.mkdir(parents=True) + py.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n") + py.chmod(0o755) + res = _detect_run(tmp_path, home, stub_bin, env_extra={"UV_TOOL_DIR": str(tools)}) + assert res.returncode == 0, res.stderr + assert f"RESOLVED={py}" in res.stdout, res.stdout + res.stderr + + +@pytest.mark.skipif(shutil.which("sh") is None, reason="sh required to run emitted probe chain") +def test_uv_tool_env_without_graphify_still_fails_loudly(tmp_path): + """The scan must not adopt a tool env whose python lacks graphify; the + chain still ends in the loud 'could not locate' warning on stderr — never + a bare silent exit (#2852's diagnosis ask).""" + home, stub_bin = _broken_uv_machine(tmp_path) + _tool_venv(home, "aaa-tool", "bin/python", ok=False) + res = _detect_run(tmp_path, home, stub_bin) + assert "could not locate" in res.stderr + # the sentinel must NOT print: the chain exited at the warning + assert res.stdout == "" + + +@pytest.mark.skipif(shutil.which("sh") is None, reason="sh required to run emitted probe chain") +def test_shebang_parse_requires_leading_hash_bang(tmp_path): + """The launcher read must gate on a leading '#!': a non-script launcher + whose first line merely NAMES a working python must not be adopted as the + interpreter. Pre-gate code parsed any first line, so trampoline bytes + (and here, a decoy path) reached the shebang parse (#2852).""" + home, stub_bin = _broken_uv_machine(tmp_path) + mine = _tool_venv(home, "graphifyy", "bin/python", ok=True) + decoy = stub_bin / "fakepy" + decoy.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n") + decoy.chmod(0o755) + launcher = stub_bin / "graphify" + launcher.write_text( + f"{decoy}\nnot a script — the first line just names a python\n", + encoding="utf-8", newline="\n", + ) + launcher.chmod(0o755) + res = _detect_run(tmp_path, home, stub_bin) + assert res.returncode == 0, res.stderr + assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr + assert "fakepy" not in res.stdout + + +def test_uv_tool_probe_present_in_emitted_detect(): + """Static companion to the runtime tests above (same convention as the + NUL-safe read): the emitted chain must scan uv tool envs and honor + UV_TOOL_DIR.""" + from graphify.hooks import _PYTHON_DETECT + assert "UV_TOOL_DIR" in _PYTHON_DETECT + assert '"$HOME/.local/share/uv/tools"' in _PYTHON_DETECT + assert '"$HOME/AppData/Roaming/uv/tools"' in _PYTHON_DETECT + + def _extract_case_pattern(marker: str) -> str: """Pull the `*[!...]*` glob portion of a real case arm out of _PYTHON_DETECT by a unique anchor, so tests run against the emitted text, not a copy."""