Skip to content

Commit e34aea2

Browse files
committed
Add shim_mode, manifest, and target dir pre-wipe
1 parent e9093ed commit e34aea2

File tree

4 files changed

+241
-36
lines changed

4 files changed

+241
-36
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,14 @@
33

44
## 1.0.0
55
- Initial release.
6+
7+
## 1.1.0
8+
- Added internal environment variable to signal major.minor override
9+
behavior in shims. Used by new optional wrapper shims.
10+
- Added `SHIM_MODE=symlink|copy|wrapper` environment setting to control
11+
installer creation of major.minor shims.
12+
- The installer now installs a manifest file to keep track of what it
13+
installed during the last run.
14+
- The installer now wipes the target directory before installing shims.
15+
There are guardrails to help prevent unintentional destructive wipes.
16+
The guardrails can be overridden via `UV_PYTHON_SHIMS_FORCE` environ var.

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ Common locations:
9191

9292
Then restart your shell, or run the export line once in your current session.
9393

94+
**Windows PATH:** If you install this in a Windows environment with bash support
95+
(Git Bash / MSYS2 / Cygwin / WSL), be aware that the bash shell PATH update
96+
suggested above will affect bash sessions only. To use from PowerShell/cmd,
97+
you may also need to add the directory to Windows PATH.
98+
99+
94100
### Verify result
95101

96102
```bash
@@ -115,18 +121,31 @@ project to confirm ***virtual environment discovery*** behaves as expected.
115121

116122
### Installer knobs
117123

118-
You can override the install destination or pacing:
124+
You can override the install destination or installer pacing:
119125

120126
```bash
121127
DEST_DIR="$HOME/.local/uv-python-shims" bash ./install.bash
122128
PAUSE=0 bash ./install.bash
123129
PAUSE=0.25 bash ./install.bash
124130
```
125131

126-
You can also change which `python3.X` links are created by setting `MINOR_VERSIONS`:
132+
You can also change which `python3.X` symlinks are created by setting `MINOR_VERSIONS`
133+
and instead of symlinks the installer can create copies or wrapper scripts:
127134

128135
```bash
129136
MINOR_VERSIONS="10 11 12" bash ./install.bash
137+
SHIM_MODE=copy bash ./install.bash
138+
SHIM_MODE=wrapper bash ./install.bash
139+
```
140+
141+
Before installing the shims, the installer wipes the target directory. This is
142+
done to allow for easy updates and experimentation. There are guardrails to
143+
help prevent unintentional destructive wipes. If the target directory looks fishy
144+
or unexpected items are seen in the target directory, the script will exit
145+
with an error message. Set `UV_PYTHON_SHIMS_FORCE` to override the guardrails:
146+
147+
```bash
148+
UV_PYTHON_SHIMS_FORCE=1 bash ./install.bash
130149
```
131150

132151

install.bash

Lines changed: 202 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,49 @@
11
#!/usr/bin/env bash
22

33
# -----------------------------------------------------------------------------
4-
# install.bash -- v1.0.0
4+
# install.bash -- v1.1.0
55
# https://github.com/newbery/uv-python-shims
66
#
77
# Copies the project-root ./python shim to:
88
# ~/.local/uv-python-shims/python
99
#
10-
# Then creates symlinks:
10+
# Then creates shims for "major.minor" versions:
1111
# python3, python3.9, python3.10, ... python3.14
1212
#
13-
# Controls:
14-
# DEST_DIR=... change install directory
15-
# PAUSE=0.2 slow down / speed up (seconds). Use PAUSE=0 to disable.
13+
# Safety:
14+
# A manifest file is written on each successful run:
15+
# ${DEST_DIR}/.uv-python-shim-manifest
16+
#
17+
# On the next run, if DEST_DIR contains any items NOT listed in the manifest,
18+
# the install will warn and exit (to avoid deleting user files).
19+
#
20+
# Control via environmment vars:
21+
# PAUSE=0.2 : slow down / speed up (seconds). Use PAUSE=0 to disable.
22+
# DEST_DIR=... : change install directory
23+
# SHIM_MODE=copy : set major.minor shim mode (copy|symlink|wrapper)
24+
# MINOR_VERSIONS="13 14" : set major.minor versions to be created
25+
# UV_PYTHON_SHIMS_FORCE=1 : bypass safety checks and wipe DEST_DIR contents
26+
#
27+
# Default values:
28+
# PAUSE=0.08
29+
# DEST_DIR=$HOME/.local/uv-python-shims
30+
# SHIM_MODE=symlink
31+
# MINOR_VERSIONS="9 10 11 12 13 14"
1632
# -----------------------------------------------------------------------------
1733

1834
set -euo pipefail
1935

20-
DEST_DIR="${DEST_DIR:-$HOME/.local/uv-python-shims}"
2136
PAUSE="${PAUSE:-0.08}"
37+
SHIM_MODE="${SHIM_MODE:-symlink}"
2238
MINOR_VERSIONS="${MINOR_VERSIONS:-9 10 11 12 13 14}"
39+
UV_PYTHON_SHIMS_FORCE="${UV_PYTHON_SHIMS_FORCE:-}"
40+
41+
SRC_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
42+
DEST_DIR="${DEST_DIR:-$HOME/.local/uv-python-shims}"
2343

24-
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
25-
SRC_SHIM="${SCRIPT_DIR}/python"
44+
SRC_SHIM="${SRC_DIR}/python"
2645
DEST_SHIM="${DEST_DIR}/python"
46+
MANIFEST="${DEST_DIR}/.uv-python-shim-manifest"
2747

2848
pause() {
2949
[[ "${PAUSE}" == "0" || "${PAUSE}" == "0.0" ]] && return 0
@@ -38,6 +58,41 @@ run_step() {
3858
pause
3959
}
4060

61+
create_copy() {
62+
local name="$1"
63+
local path="${DEST_DIR}/${name}"
64+
command cp -f -- "${SRC_SHIM}" "${path}"
65+
chmod 0755 "${path}"
66+
}
67+
68+
create_symlink() {
69+
local name="$1"
70+
command ln -sfn "python" "${DEST_DIR}/${name}"
71+
}
72+
73+
create_wrapper() {
74+
local name="$1"
75+
local path="${DEST_DIR}/${name}"
76+
command cat >"${path}" <<'EOF'
77+
#!/usr/bin/env bash
78+
# generated by uv-python-shim installer
79+
UV_PYTHON_SHIM_INVOKED_AS="$(basename "$0")"
80+
export UV_PYTHON_SHIM_INVOKED_AS
81+
exec "$(dirname "$0")/python" "$@"
82+
EOF
83+
chmod 0755 "${path}"
84+
}
85+
86+
create_shim() {
87+
local name="$1"
88+
case "${SHIM_MODE}" in
89+
copy) create_copy "${name}" ;;
90+
symlink) create_symlink "${name}" ;;
91+
wrapper) create_wrapper "${name}" ;;
92+
esac
93+
}
94+
95+
4196
# ----------------------------
4297
# Some simple color setup
4398
# ----------------------------
@@ -53,39 +108,157 @@ else
53108
C_RESET=""; C_BOLD=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_CYAN=""
54109
fi
55110

56-
info() { printf "%s==>%s %s\n" "${C_CYAN}${C_BOLD}" "${C_RESET}" "$*" ; }
57-
ok() { printf "%s✔%s %s\n" "${C_GREEN}" "${C_RESET}" "$*" ; }
58-
warn() { printf "%s!%s %s\n" "${C_YELLOW}" "${C_RESET}" "$*" ; }
59-
err() { printf "%sERROR:%s %s\n" "${C_RED}${C_BOLD}" "${C_RESET}" "$*" >&2; }
111+
info() { printf '%s==>%s %s\n' "${C_CYAN}${C_BOLD}" "${C_RESET}" "$*" ; }
112+
ok() { printf '%s✔%s %s\n' "${C_GREEN}" "${C_RESET}" "$*" ; }
113+
warn() { printf '%s!%s %s\n' "${C_YELLOW}" "${C_RESET}" "$*" ; }
114+
err() { printf '%sERROR:%s %s\n' "${C_RED}${C_BOLD}" "${C_RESET}" "$*" >&2; }
115+
116+
117+
# ----------------------------
118+
# Manifest safety helpers
119+
# ----------------------------
120+
121+
is_truthy() {
122+
case "${1:-}" in
123+
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
124+
*) return 1 ;;
125+
esac
126+
}
127+
128+
check_denylist() {
129+
# Refuse to wipe obviously dangerous paths (even with force).
130+
case "${DEST_DIR}" in
131+
""|"/"|"$HOME"|"$HOME/"|"$HOME/.local"|"$HOME/.local/"|"$HOME/.local/bin"|"$HOME/bin")
132+
err "Refusing to wipe dangerous DEST_DIR: ${DEST_DIR}"
133+
err "Choose a dedicated directory (default is fine): \$HOME/.local/uv-python-shims"
134+
exit 1
135+
;;
136+
esac
137+
}
138+
139+
list_items() {
140+
local p
141+
shopt -s dotglob nullglob
142+
for p in "${DEST_DIR}"/*; do
143+
[[ -e "${p}" ]] || continue
144+
printf '%s\n' "${p#"${DEST_DIR}"/}"
145+
done
146+
}
147+
148+
write_manifest() {
149+
local tmp
150+
tmp="$(mktemp "${DEST_DIR}/.uv-python-shim-manifest.tmp.XXXXXX")"
151+
installed=( "python" "python3" )
152+
for minor in ${MINOR_VERSIONS}; do
153+
installed+=( "python3.${minor}" )
154+
done
155+
installed+=( ".uv-python-shim-manifest" )
156+
printf '%s\n' "${installed[@]}" >"$tmp"
157+
command mv -f -- "${tmp}" "${MANIFEST}"
158+
}
159+
160+
load_manifest() {
161+
if [[ ! -f "${MANIFEST}" ]]; then
162+
warn "Refusing to modify non-empty directory without manifest: ${DEST_DIR}"
163+
warn "To proceed (and wipe it), run with: UV_PYTHON_SHIMS_FORCE=1 bash ./install.bash"
164+
exit 1
165+
fi
166+
mapfile -t manifest_items <"${MANIFEST}"
167+
}
168+
169+
validate_manifest() {
170+
declare -A allowed=()
171+
local item
172+
173+
for item in "${manifest_items[@]}"; do
174+
[[ -n "${item}" ]] || continue
175+
allowed["${item}"]=1
176+
done
177+
178+
while IFS= read -r item; do
179+
if [[ -z "${allowed[${item}]:-}" ]]; then
180+
warn "Refusing to modify ${DEST_DIR}: unexpected item not in manifest: ${item}"
181+
warn "If this directory is dedicated to uv-python-shims:"
182+
warn " - remove the unexpected files, OR"
183+
warn " - set UV_PYTHON_SHIMS_FORCE=1 to wipe the directory"
184+
exit 1
185+
fi
186+
done < <(list_items)
187+
}
188+
189+
wipe_previous_install() {
190+
if [[ ! -d "${DEST_DIR}" ]]; then
191+
return 0
192+
fi
193+
194+
# If user opts into force, just wipe DEST_DIR and skip the rest
195+
if is_truthy "${UV_PYTHON_SHIMS_FORCE}"; then
196+
check_denylist
197+
warn "UV_PYTHON_SHIMS_FORCE=1 set; wiping ${DEST_DIR}"
198+
command rm -rf -- "${DEST_DIR}"
199+
printf '\n'
200+
return 0
201+
fi
202+
203+
# If DEST_DIR is already empty, just skip the rest
204+
local items=()
205+
shopt -s dotglob nullglob
206+
items=( "${DEST_DIR}"/* )
207+
if (( ${#items[@]} == 0 )); then
208+
return 0
209+
fi
210+
211+
# Now load and validate manifest
212+
load_manifest
213+
validate_manifest
214+
215+
# If we reach here, it's safe to remove all items listed in the manifest
216+
local rel
217+
for rel in "${manifest_items[@]}"; do
218+
[[ -n "${rel}" ]] || continue
219+
command rm -f -- "${DEST_DIR}/${rel}"
220+
done
221+
}
60222

61223

62224
# ----------------------------
63225
# Do the work...
64226
# ----------------------------
65227

66228
info "Install uv python shims"
67-
printf " Source: %s\n" "${SRC_SHIM}"
68-
printf " Destination: %s\n\n" "${DEST_SHIM}"
229+
printf ' Source: %s\n' "${SRC_SHIM}"
230+
printf ' Destination: %s\n\n' "${DEST_SHIM}"
69231

70232
if [[ ! -f "${SRC_SHIM}" ]]; then
71-
err "Shim not found at: ${SRC_SHIM}"
233+
err "Source shim not found at: ${SRC_SHIM}"
72234
err "Expected a file named 'python' in the project root next to install.bash"
73235
exit 1
74236
fi
75237

238+
case "${SHIM_MODE}" in symlink|wrapper|copy) ;;
239+
*)
240+
err "Invalid shim-mode: ${SHIM_MODE}"
241+
err "Allowed values: symlink|wrapper|copy"
242+
exit 1
243+
;;
244+
esac
245+
246+
wipe_previous_install
247+
76248
info "Create main shim"
77249
run_step "Create directory ${DEST_DIR}" mkdir -p "${DEST_DIR}"
78-
run_step "Copy shim to ${DEST_SHIM}" cp "${SRC_SHIM}" "${DEST_SHIM}"
79-
run_step "Set executable bit on ${DEST_SHIM}" chmod 0755 "${DEST_SHIM}"
80-
printf "\n"
250+
run_step "Copy shim to ${DEST_SHIM}" create_copy "python"
251+
printf '\n'
81252

82-
info "Create symlinks"
83-
run_step "Link ${DEST_DIR}/python3 -> python" ln -sfn "python" "${DEST_DIR}/python3"
253+
info "Create major.minor shims (SHIM_MODE=${SHIM_MODE})"
254+
run_step "Create ${DEST_DIR}/python3" create_shim "python3"
84255
for minor in ${MINOR_VERSIONS}; do
85-
run_step "Link ${DEST_DIR}/python3.${minor} -> python" ln -sfn "python" "${DEST_DIR}/python3.${minor}"
256+
run_step "Create ${DEST_DIR}/python3.${minor}" create_shim "python3.${minor}"
86257
done
87258

88-
printf "\n"
259+
write_manifest
260+
261+
printf '\n'
89262
info "Done"
90263

91264
cat <<EOF
@@ -110,8 +283,11 @@ ${C_BOLD}Verify result${C_RESET}
110283
python3.11 --version
111284
112285
${C_BOLD}Install can be customized${C_RESET}
113-
PAUSE=0 bash ./install.bash
114-
PAUSE=0.25 bash ./install.bash
115-
DEST_DIR=... bash ./install.bash
116-
286+
PAUSE=0 bash ./install.bash
287+
PAUSE=0.25 bash ./install.bash
288+
DEST_DIR=... bash ./install.bash
289+
SHIM_MODE=copy bash ./install.bash
290+
SHIM_MODE=wrapper bash ./install.bash
291+
MINOR_VERSIONS="13 14" bash ./install.bash
292+
UV_PYTHON_SHIMS_FORCE=1 bash ./install.bash
117293
EOF

python

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bash
22

33
# -----------------------------------------------------------------------------
4-
# uv python shim -- v1.0.0
4+
# uv python shim -- v1.1.0
55
# https://github.com/newbery/uv-python-shims
66
#
77
# - Mimics the pyenv python shim behavior.
@@ -12,7 +12,6 @@
1212

1313
set -euo pipefail
1414

15-
py=""
1615
FLAGS=(--resolve-links)
1716

1817
# Uncomment to ignore virtual environments
@@ -53,17 +52,18 @@ major_minor() {
5352
printf '%s.%s\n' "$a" "$b"
5453
}
5554

56-
# Extract a major.minor override from the shim name:
55+
py=""
56+
57+
# Try to extract major.minor override from shim name or environment variable:
5758
# python3.11 -> 3.11
5859
# python3.11.2 -> 3.11
5960
shim_prefix=""
60-
self="$(basename "$0")"
61-
if [[ "$self" =~ ^python3\.([0-9]+)(\.([0-9]+))?$ ]]; then
61+
invoked_as="${UV_PYTHON_SHIM_INVOKED_AS:-$(basename "$0")}"
62+
if [[ "$invoked_as" =~ ^python3\.([0-9]+)(\.([0-9]+))?$ ]]; then
6263
shim_prefix="3.${BASH_REMATCH[1]}"
6364
fi
6465

65-
# If this is a python 3.X shim, the resulting python should match
66-
# shim prefix so let's first compare the version prefixes.
66+
# If major.minor override is given, test if found version matches
6767
if [[ -n "$shim_prefix" ]]; then
6868
found_prefix=""
6969
found_version="$(uv_find_version 2>/dev/null || true)"
@@ -74,7 +74,6 @@ if [[ -n "$shim_prefix" ]]; then
7474
# If not a match, let's defer to the result using the shim prefix
7575
if [[ "$found_prefix" != "$shim_prefix" ]]; then
7676
py="$(uv_find "$shim_prefix")"
77-
# echo "Using python $shim_prefix instead of the default 'uv python find' result"
7877
fi
7978
fi
8079

0 commit comments

Comments
 (0)