mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 01:06:36 +00:00
fix(hooks): resolve the uv tool venv when pin and launcher probes miss (#2852)
Installed via `uv tool install`, the post-commit hook could not find graphify: the pin and launcher/shebang probes miss the uv tool venv, so the hook silently exit-0'd. Add a fourth fallback probe that scans $UV_TOOL_DIR and the conventional uv tool locations for a python whose env has graphify importable, firing only after the earlier probes miss and verifying importability before adopting. Also gate the launcher read on a real #! header so a binary trampoline is not misparsed as a shebang.
This commit is contained in:
committed by
safishamsi
parent
037970159d
commit
9452ef216b
+33
-3
@@ -67,10 +67,17 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
# 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 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user