From 34d271ffbf2a662ad0c73a0eed3050d287c63c40 Mon Sep 17 00:00:00 2001 From: thescribedevelopment <284032767+thescribedevelopment@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:32:02 +0100 Subject: [PATCH] fix(modern-python): make the python shim's suggested command actually work outside a project Outside a uv project there is no venv bin/ on PATH to shadow the shims, so `uv run python script.py` resolves python via PATH lookup and hits the shim again: the exact command the shim suggests can never succeed. The python shim now detects the `uv run` context via the UV environment variable (documented; set by uv >= 0.6.0 for all subprocesses it spawns) and execs the real interpreter, found by walking PATH and skipping the shim itself via a same-file (-ef) test. A PID-based guard (exec keeps the PID) turns the two-shim-copies-on-PATH edge into a clean exit 127 instead of an exec loop, and `python -m pip` stays blocked everywhere. Bare invocations outside `uv run` behave exactly as before. Docs updated (README + setup-shims.sh claimed uv run was unaffected by construction, which only holds inside a project). Existing bats tests made hermetic against ambient UV; six new tests cover the passthrough, symlink/aliased-spelling skip, loop guard, distinct not-found error, and pip precedence. Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 2 +- .../modern-python/.claude-plugin/plugin.json | 2 +- plugins/modern-python/README.md | 2 +- plugins/modern-python/hooks/setup-shims.sh | 6 +- plugins/modern-python/hooks/shims/python | 46 +++++++-- .../hooks/shims/python-shim.bats | 99 +++++++++++++++++++ 6 files changed, 142 insertions(+), 15 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c3dbab76..4313f54a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -262,7 +262,7 @@ }, { "name": "modern-python", - "version": "1.5.2", + "version": "1.5.3", "description": "Modern Python best practices. Use when creating new Python projects, and writing Python scripts, or migrating existing projects from legacy tools.", "author": { "name": "William Tan", diff --git a/plugins/modern-python/.claude-plugin/plugin.json b/plugins/modern-python/.claude-plugin/plugin.json index 38b9190f..0fe0df73 100644 --- a/plugins/modern-python/.claude-plugin/plugin.json +++ b/plugins/modern-python/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "modern-python", - "version": "1.5.2", + "version": "1.5.3", "description": "Modern Python best practices. Use when creating new Python projects, and writing Python scripts, or migrating existing projects from legacy tools.", "author": { "name": "William Tan", diff --git a/plugins/modern-python/README.md b/plugins/modern-python/README.md index 96f42505..e1b07445 100644 --- a/plugins/modern-python/README.md +++ b/plugins/modern-python/README.md @@ -37,7 +37,7 @@ Modern Python tooling and best practices using uv, ruff, ty, and pytest. Based o ## Hook: Legacy Command Interception -This plugin includes a `SessionStart` hook that prepends PATH shims for `python`, `pip`, `pipx`, and `uv`. When Claude runs a bare `python`, `pip`, or `pipx` command, the shell resolves to the shim, which prints an error with the correct `uv` alternative and exits non-zero. `uv run` is unaffected because it prepends its managed virtualenv's `bin/` to PATH, shadowing the shims. +This plugin includes a `SessionStart` hook that prepends PATH shims for `python`, `pip`, `pipx`, and `uv`. When Claude runs a bare `python`, `pip`, or `pipx` command, the shell resolves to the shim, which prints an error with the correct `uv` alternative and exits non-zero. `uv run` is unaffected: inside a project it prepends its managed virtualenv's `bin/` to PATH, shadowing the shims; outside a project (where no venv `bin/` exists and `uv run python` resolves via PATH) the python shim detects the `uv run` context via the `UV` environment variable that uv (>= 0.6.0) sets for its subprocesses and passes through to the real interpreter. `python -m pip` stays blocked everywhere. | Intercepted Command | Suggested Alternative | |---------------------|----------------------| diff --git a/plugins/modern-python/hooks/setup-shims.sh b/plugins/modern-python/hooks/setup-shims.sh index 5e3d590c..b13e0b0d 100755 --- a/plugins/modern-python/hooks/setup-shims.sh +++ b/plugins/modern-python/hooks/setup-shims.sh @@ -4,8 +4,10 @@ set -euo pipefail # SessionStart hook: prepend shims directory to PATH so that bare # python/pip/pipx/uv-pip invocations are intercepted with uv suggestions. # -# `uv run` is unaffected because it prepends its managed virtualenv's -# bin/ to PATH, shadowing the shims. +# `uv run` is unaffected: inside a project it prepends its managed +# virtualenv's bin/ to PATH, shadowing the shims; outside a project the +# python shim detects the `uv run` context via the UV environment variable +# (set by uv >= 0.6.0 for its subprocesses) and execs the real interpreter. # Guard: only activate when uv is available command -v uv &>/dev/null || exit 0 diff --git a/plugins/modern-python/hooks/shims/python b/plugins/modern-python/hooks/shims/python index 695a54a2..dad27a09 100755 --- a/plugins/modern-python/hooks/shims/python +++ b/plugins/modern-python/hooks/shims/python @@ -5,18 +5,44 @@ set -euo pipefail # suggests the uv equivalent. Works for both names via $0. cmd="$(basename "$0")" +# pip via -m is blocked even under `uv run` (canonical `-m pip` spelling +# only — best-effort interception, not an enforcement boundary). +if [[ "${1:-}" == "-m" && "${2:-}" == "pip" ]]; then + echo "ERROR: \`$cmd -m pip\` is not supported. Use:" >&2 + echo " uv add # add a dependency" >&2 + echo " uv remove # remove a dependency" >&2 + exit 1 +fi + +# Under `uv run`, pass through to the real interpreter. uv >= 0.6.0 sets +# UV= in the environment of every subprocess it spawns. Inside a project, +# uv's venv bin/ shadows this shim, but OUTSIDE a project `uv run python` +# resolves the interpreter via PATH — there is no venv bin/ in the way — +# so without this passthrough the very command this shim suggests resolves +# right back to the shim and can never succeed. +if [[ -n "${UV:-}" ]]; then + # exec() preserves the PID, so finding our own PID here means this shim + # exec'd another copy of itself (two shim installs on PATH) — error out + # instead of exec-looping. + if [[ "${MODERN_PYTHON_SHIM_PID:-}" != "$$" ]]; then + export MODERN_PYTHON_SHIM_PID="$$" + IFS=: read -ra path_entries <<<"${PATH:-}" + for dir in "${path_entries[@]}"; do + [[ -z "$dir" ]] && dir=. # empty PATH entry means cwd in execvp semantics + [[ "$dir/$cmd" -ef "$0" ]] && continue # skip this shim itself (any spelling/symlink) + if [[ -x "$dir/$cmd" ]]; then + exec "$dir/$cmd" "$@" + fi + done + fi + echo "ERROR: running under \`uv run\` but no real $cmd was found on PATH." >&2 + echo "Try \`uv run