Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/modern-python/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/modern-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---------------------|----------------------|
Expand Down
6 changes: 4 additions & 2 deletions plugins/modern-python/hooks/setup-shims.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 36 additions & 10 deletions plugins/modern-python/hooks/shims/python
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package> # add a dependency" >&2
echo " uv remove <package> # 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 <script>.py\` or install one with \`uv python install\`." >&2
exit 127
fi

case "${1:-}" in
-m)
case "${2:-}" in
pip)
echo "ERROR: \`$cmd -m pip\` is not supported. Use:" >&2
echo " uv add <package> # add a dependency" >&2
echo " uv remove <package> # remove a dependency" >&2
;;
*)
echo "ERROR: Use \`uv run $cmd -m ${2:-<module>}\` instead of \`$cmd -m ${2:-<module>}\`" >&2
;;
esac
echo "ERROR: Use \`uv run $cmd -m ${2:-<module>}\` instead of \`$cmd -m ${2:-<module>}\`" >&2
;;
*)
echo "ERROR: Use \`uv run $cmd ${*:-}\` instead of \`$cmd ${*:-}\`" >&2
Expand Down
99 changes: 99 additions & 0 deletions plugins/modern-python/hooks/shims/python-shim.bats
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@

SHIM="${BATS_TEST_DIRNAME}/python"

setup() {
# `uv run` sets UV in the environment of commands it launches, and the shim
# keys its passthrough off that variable. Unset it (and the shim's exec
# guard) so these tests behave the same whether or not the suite itself is
# running under uv.
unset UV MODERN_PYTHON_SHIM_PID
}

teardown() {
[[ -z "${workdir:-}" ]] || rm -rf "$workdir"
}

# Create a fake interpreter named $1 inside $workdir and echo its dir.
# The fake prints its own name and arguments, then exits 42, so tests can
# verify argument forwarding and exit-code propagation.
make_fake_interpreter() {
local dir
dir="$workdir/real"
mkdir -p "$dir"
cat >"$dir/$1" <<'EOF'
#!/usr/bin/env bash
echo "FAKE ${0##*/}: $*"
exit 42
EOF
chmod +x "$dir/$1"
echo "$dir"
}

@test "exits non-zero for bare python" {
run "$SHIM"
[[ $status -ne 0 ]]
Expand Down Expand Up @@ -51,3 +79,74 @@ SHIM="${BATS_TEST_DIRNAME}/python"
[[ $status -ne 0 ]]
[[ "$output" == *"uv add"* ]]
}

@test "passes through to real python when UV is set (uv run context)" {
workdir="$(mktemp -d)"
fakedir="$(make_fake_interpreter python)"
# Shims dir stays first on PATH to prove the shim skips its own directory.
run env UV=/usr/local/bin/uv PATH="${BATS_TEST_DIRNAME}:${fakedir}:/usr/bin:/bin" \
"$SHIM" script.py --flag
[[ $status -eq 42 ]]
[[ "$output" == "FAKE python: script.py --flag" ]]
}

@test "passes through when invoked as python3 via symlink with UV set" {
workdir="$(mktemp -d)"
fakedir="$(make_fake_interpreter python3)"
run env UV=/usr/local/bin/uv PATH="${BATS_TEST_DIRNAME}:${fakedir}:/usr/bin:/bin" \
"${BATS_TEST_DIRNAME}/python3" -c 'print(1)'
[[ $status -eq 42 ]]
[[ "$output" == "FAKE python3: -c print(1)" ]]
}

@test "skips its own directory under an aliased spelling (symlinked PATH entry)" {
workdir="$(mktemp -d)"
fakedir="$(make_fake_interpreter python)"
ln -s "$BATS_TEST_DIRNAME" "$workdir/alias"
run env UV=/usr/local/bin/uv \
PATH="$workdir/alias:${BATS_TEST_DIRNAME}:${fakedir}:/usr/bin:/bin" \
"$SHIM" script.py
[[ $status -eq 42 ]]
[[ "$output" == "FAKE python: script.py" ]]
}

@test "errors distinctly when UV is set but no real interpreter is on PATH" {
# PATH holds only the shims dir plus the tools the shim itself needs
# (bash for its `env bash` shebang, basename from coreutils); /usr/bin
# must stay off PATH because CI runners ship /usr/bin/python3.
workdir="$(mktemp -d)"
mkdir -p "$workdir/tools"
ln -s "$(command -v bash)" "$workdir/tools/bash"
ln -s "$(command -v basename)" "$workdir/tools/basename"
run env UV=/usr/local/bin/uv PATH="${BATS_TEST_DIRNAME}:$workdir/tools" \
"$SHIM" script.py
[[ $status -eq 127 ]]
[[ "$output" == *"no real python was found on PATH"* ]]
}

@test "does not exec-loop when a second shim copy is on PATH" {
# Two distinct copies of the shim (e.g. two plugin cache versions) used to
# be able to exec each other forever; the PID guard must break the cycle.
workdir="$(mktemp -d)"
mkdir -p "$workdir/copy" "$workdir/tools"
cp "$SHIM" "$workdir/copy/python"
chmod +x "$workdir/copy/python"
ln -s "$(command -v bash)" "$workdir/tools/bash"
ln -s "$(command -v basename)" "$workdir/tools/basename"
run timeout 5 env UV=/usr/local/bin/uv \
PATH="${BATS_TEST_DIRNAME}:$workdir/copy:$workdir/tools" \
"$SHIM" script.py
[[ $status -eq 127 ]]
[[ "$output" == *"no real python was found on PATH"* ]]
}

@test "python -m pip stays blocked even when UV is set" {
workdir="$(mktemp -d)"
fakedir="$(make_fake_interpreter python)"
run env UV=/usr/local/bin/uv PATH="${BATS_TEST_DIRNAME}:${fakedir}:/usr/bin:/bin" \
"$SHIM" -m pip install requests
[[ $status -eq 1 ]]
[[ "$output" == *"uv add"* ]]
[[ "$output" == *"uv remove"* ]]
[[ "$output" != *"FAKE"* ]]
}