Skip to content
Open
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
36 changes: 33 additions & 3 deletions graphify/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 }" ;;
Expand All @@ -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
Expand Down
142 changes: 142 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <home>/.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
(`<tool>\\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."""
Expand Down