Skip to content

Commit 1e976e3

Browse files
authored
Merge pull request #3 from NickChecan/fix-compile
UV in Compile Script
2 parents 0a7609d + 69b1c62 commit 1e976e3

File tree

2 files changed

+113
-99
lines changed

2 files changed

+113
-99
lines changed

bin/compile

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,63 @@
11
#!/usr/bin/env bash
22
# Compile script for Python buildpack with uv support
33

4-
set -e
4+
set -euo pipefail
55
BUILD_DIR=$1
66
CACHE_DIR=$2
77
ENV_DIR=$3
8+
UV_INSTALL_DIR="$CACHE_DIR/uv/bin"
9+
UV_BIN="$UV_INSTALL_DIR/uv"
10+
UV_PYTHON_INSTALL_ROOT="$BUILD_DIR/.uv/python"
11+
PYTHON_SHIM_DIR="$BUILD_DIR/.python/bin"
812

9-
# Cloud Foundry environments may expose either `python3` or `python`,
10-
# so resolve one interpreter once and reuse it for the whole build.
11-
if command -v python3 >/dev/null 2>&1; then
12-
PYTHON_BIN="python3"
13-
elif command -v python >/dev/null 2>&1; then
14-
PYTHON_BIN="python"
15-
else
16-
echo "Python interpreter not found. Expected python3 or python on PATH."
17-
exit 1
18-
fi
13+
install_uv() {
14+
if command -v uv >/dev/null 2>&1; then
15+
UV_BIN="$(command -v uv)"
16+
return 0
17+
fi
1918

20-
ensure_pip() {
21-
# Some CF Python runtimes do not expose pip by default, so bootstrap it
22-
# before the buildpack tries to install uv or exported dependencies.
23-
if "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then
19+
if [ -x "$UV_BIN" ]; then
2420
return 0
2521
fi
2622

27-
echo "pip not found for $PYTHON_BIN. Bootstrapping with ensurepip."
28-
"$PYTHON_BIN" -m ensurepip --upgrade
23+
mkdir -p "$UV_INSTALL_DIR"
24+
25+
echo "uv not found on PATH. Installing standalone uv."
26+
if command -v curl >/dev/null 2>&1; then
27+
env UV_UNMANAGED_INSTALL="$UV_INSTALL_DIR" sh -c "$(curl -LsSf https://astral.sh/uv/install.sh)"
28+
elif command -v wget >/dev/null 2>&1; then
29+
env UV_UNMANAGED_INSTALL="$UV_INSTALL_DIR" sh -c "$(wget -qO- https://astral.sh/uv/install.sh)"
30+
else
31+
echo "Neither curl nor wget is available to install uv."
32+
exit 1
33+
fi
34+
}
35+
36+
resolve_python_request() {
37+
if [ -f ".python-version" ]; then
38+
awk 'NF { print; exit }' .python-version
39+
fi
40+
}
41+
42+
install_managed_python() {
43+
local python_request
44+
45+
install_uv
46+
47+
# Keep the managed interpreter inside the droplet so the released app can
48+
# run without depending on whatever Python may or may not exist on the stack.
49+
export UV_PYTHON_INSTALL_DIR="$UV_PYTHON_INSTALL_ROOT"
50+
51+
python_request="$(resolve_python_request)"
52+
if [ -n "$python_request" ]; then
53+
echo "Installing Python $python_request from .python-version."
54+
"$UV_BIN" python install "$python_request"
55+
PYTHON_BIN="$("$UV_BIN" python find --managed-python "$python_request")"
56+
else
57+
echo "No .python-version found. Installing a managed Python with uv defaults."
58+
"$UV_BIN" python install
59+
PYTHON_BIN="$("$UV_BIN" python find --managed-python)"
60+
fi
2961
}
3062

3163
cd "$BUILD_DIR"
@@ -34,17 +66,20 @@ cd "$BUILD_DIR"
3466
# metadata and the lockfile are present.
3567
if [ -f "pyproject.toml" ] && [ -f "uv.lock" ]; then
3668
echo "Detected uv project with lockfile. Installing dependencies with uv."
69+
install_managed_python
70+
3771
# Keep installer caches inside the buildpack cache directory so repeated
3872
# builds are faster and do not depend on user-specific cache locations.
3973
export PIP_CACHE_DIR="$CACHE_DIR/pip"
4074
export UV_CACHE_DIR="$CACHE_DIR/uv"
4175

4276
mkdir -p "$PIP_CACHE_DIR" "$UV_CACHE_DIR"
77+
mkdir -p "$PYTHON_SHIM_DIR"
4378

44-
# Install uv into the selected interpreter so the buildpack can export the
45-
# exact locked dependency set from the app's uv.lock file.
46-
ensure_pip
47-
"$PYTHON_BIN" -m pip install uv
79+
# The release script still looks for `python3`/`python` on PATH, so provide
80+
# stable shim names that point at the uv-managed interpreter we just installed.
81+
ln -sf "$PYTHON_BIN" "$PYTHON_SHIM_DIR/python3"
82+
ln -sf "$PYTHON_BIN" "$PYTHON_SHIM_DIR/python"
4883

4984
PYTHON_VERSION=$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
5085
# Stage dependencies into a buildpack-owned location instead of a local venv
@@ -57,19 +92,23 @@ if [ -f "pyproject.toml" ] && [ -f "uv.lock" ]; then
5792
mkdir -p "$SITE_PACKAGES_DIR" "$PROFILE_DIR"
5893

5994
# Export the exact locked dependency set from uv, then install those
60-
# packages into the staged site-packages directory.
61-
uv export --locked --format requirements-txt --no-emit-local -o "$EXPORT_FILE"
62-
"$PYTHON_BIN" -m pip install --no-deps --target "$SITE_PACKAGES_DIR" -r "$EXPORT_FILE"
95+
# packages into the staged site-packages directory. Use `uv pip` instead of
96+
# `python -m pip` because uv-managed Python installations are intentionally
97+
# marked as externally managed.
98+
"$UV_BIN" export --locked --format requirements-txt --no-emit-local -o "$EXPORT_FILE"
99+
"$UV_BIN" pip install --python "$PYTHON_BIN" --no-deps --target "$SITE_PACKAGES_DIR" -r "$EXPORT_FILE"
63100

64101
# Add the staged packages to PYTHONPATH so the app can import them at runtime.
65102
# If the project uses a `src/` layout, include that too because the local
66103
# project itself is not installed by the exported third-party requirements.
67104
if [ -d "$SRC_DIR" ]; then
68105
cat > "$PROFILE_DIR/python.sh" <<EOF
106+
export PATH="$PYTHON_SHIM_DIR:\${PATH}"
69107
export PYTHONPATH="$SRC_DIR:$SITE_PACKAGES_DIR:\${PYTHONPATH}"
70108
EOF
71109
else
72110
cat > "$PROFILE_DIR/python.sh" <<EOF
111+
export PATH="$PYTHON_SHIM_DIR:\${PATH}"
73112
export PYTHONPATH="$SITE_PACKAGES_DIR:\${PYTHONPATH}"
74113
EOF
75114
fi

test/unit/compile_test.sh

Lines changed: 50 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ COMPILE_SCRIPT="$ROOT_DIR/bin/compile"
77
TMP_DIR="$(mktemp -d /tmp/compile-test.XXXXXX)"
88
TEST_ROOT="$TMP_DIR/test-root"
99
FAKE_BIN_DIR="$TMP_DIR/fake-bin"
10+
FAKE_MANAGED_PYTHON="$FAKE_BIN_DIR/managed-python3"
1011

1112
cleanup() {
1213
rm -rf "$TMP_DIR"
@@ -67,10 +68,9 @@ assert_path_exists() {
6768
}
6869

6970
setup_fake_commands() {
70-
local pip_available="${1:-1}"
7171
mkdir -p "$FAKE_BIN_DIR"
7272

73-
cat > "$FAKE_BIN_DIR/python3" <<'EOF'
73+
cat > "$FAKE_MANAGED_PYTHON" <<'EOF'
7474
#!/usr/bin/env bash
7575
set -euo pipefail
7676
@@ -79,28 +79,48 @@ if [ "$#" -ge 2 ] && [ "$1" = "-c" ] && [ "$2" = "import sys; print(f\"{sys.vers
7979
exit 0
8080
fi
8181
82-
if [ "$#" -ge 3 ] && [ "$1" = "-m" ] && [ "$2" = "pip" ] && [ "$3" = "--version" ]; then
83-
if [ "${FAKE_PIP_AVAILABLE:-1}" = "1" ]; then
84-
printf 'pip 24.0 from /fake/site-packages/pip (python 3.13)\n'
85-
exit 0
86-
fi
82+
echo "Unexpected python3 invocation: $*" >&2
83+
exit 1
84+
EOF
85+
86+
cat > "$FAKE_BIN_DIR/uv" <<'EOF'
87+
#!/usr/bin/env bash
88+
set -euo pipefail
8789
88-
echo "No module named pip" >&2
89-
exit 1
90+
log_file="${TEST_ROOT}/uv.log"
91+
mkdir -p "$(dirname "$log_file")"
92+
printf '%s\n' "$*" >> "$log_file"
93+
94+
if [ "$#" -ge 3 ] && [ "$1" = "python" ] && [ "$2" = "install" ]; then
95+
exit 0
9096
fi
9197
92-
if [ "$#" -ge 3 ] && [ "$1" = "-m" ] && [ "$2" = "ensurepip" ] && [ "$3" = "--upgrade" ]; then
93-
log_file="${TEST_ROOT}/ensurepip.log"
94-
mkdir -p "$(dirname "$log_file")"
95-
printf '%s\n' "$*" >> "$log_file"
98+
if [ "$#" -ge 3 ] && [ "$1" = "python" ] && [ "$2" = "find" ]; then
99+
printf '%s\n' "${FAKE_MANAGED_PYTHON}"
96100
exit 0
97101
fi
98102
99-
if [ "$#" -ge 3 ] && [ "$1" = "-m" ] && [ "$2" = "pip" ]; then
100-
log_file="${TEST_ROOT}/pip.log"
101-
mkdir -p "$(dirname "$log_file")"
102-
printf '%s\n' "$*" >> "$log_file"
103+
if [ "$1" = "export" ]; then
104+
output_file=""
105+
106+
while [ "$#" -gt 0 ]; do
107+
case "$1" in
108+
-o)
109+
shift
110+
output_file="$1"
111+
;;
112+
esac
113+
shift || true
114+
done
115+
116+
cat > "$output_file" <<'REQ'
117+
fastapi==0.135.3
118+
uvicorn==0.44.0
119+
REQ
120+
exit 0
121+
fi
103122
123+
if [ "$1" = "pip" ] && [ "$2" = "install" ]; then
104124
target_dir=""
105125
requirements_file=""
106126
@@ -130,44 +150,11 @@ if [ "$#" -ge 3 ] && [ "$1" = "-m" ] && [ "$2" = "pip" ]; then
130150
exit 0
131151
fi
132152
133-
echo "Unexpected python3 invocation: $*" >&2
134-
exit 1
135-
EOF
136-
137-
cat > "$FAKE_BIN_DIR/uv" <<'EOF'
138-
#!/usr/bin/env bash
139-
set -euo pipefail
140-
141-
log_file="${TEST_ROOT}/uv.log"
142-
mkdir -p "$(dirname "$log_file")"
143-
printf '%s\n' "$*" >> "$log_file"
144-
145-
if [ "$1" = "export" ]; then
146-
output_file=""
147-
148-
while [ "$#" -gt 0 ]; do
149-
case "$1" in
150-
-o)
151-
shift
152-
output_file="$1"
153-
;;
154-
esac
155-
shift || true
156-
done
157-
158-
cat > "$output_file" <<'REQ'
159-
fastapi==0.135.3
160-
uvicorn==0.44.0
161-
REQ
162-
exit 0
163-
fi
164-
165153
echo "Unexpected uv invocation: $*" >&2
166154
exit 1
167155
EOF
168156

169-
chmod +x "$FAKE_BIN_DIR/python3" "$FAKE_BIN_DIR/uv"
170-
FAKE_PIP_AVAILABLE="$pip_available"
157+
chmod +x "$FAKE_MANAGED_PYTHON" "$FAKE_BIN_DIR/uv"
171158
}
172159

173160
run_compile() {
@@ -177,7 +164,7 @@ run_compile() {
177164
local output_file="$TEST_ROOT/output.txt"
178165

179166
set +e
180-
PATH="$FAKE_BIN_DIR:/usr/bin:/bin" TEST_ROOT="$TEST_ROOT" FAKE_PIP_AVAILABLE="${FAKE_PIP_AVAILABLE:-1}" "$COMPILE_SCRIPT" "$build_dir" "$cache_dir" "$env_dir" >"$output_file" 2>&1
167+
PATH="$FAKE_BIN_DIR:/usr/bin:/bin" TEST_ROOT="$TEST_ROOT" FAKE_MANAGED_PYTHON="$FAKE_MANAGED_PYTHON" "$COMPILE_SCRIPT" "$build_dir" "$cache_dir" "$env_dir" >"$output_file" 2>&1
181168
status=$?
182169
set -e
183170

@@ -192,9 +179,11 @@ test_compile_succeeds_for_locked_uv_project() {
192179
local site_packages_dir="$build_dir/.python_packages/lib/python3.13/site-packages"
193180
local profile_file="$build_dir/.profile.d/python.sh"
194181
local export_file="$build_dir/.uv-export-requirements.txt"
182+
local shim_dir="$build_dir/.python/bin"
195183

196184
mkdir -p "$build_dir" "$cache_dir" "$env_dir"
197185
touch "$build_dir/pyproject.toml" "$build_dir/uv.lock"
186+
printf '3.13\n' > "$build_dir/.python-version"
198187
setup_fake_commands
199188

200189
# Act
@@ -203,33 +192,18 @@ test_compile_succeeds_for_locked_uv_project() {
203192
# Assert
204193
assert_exit_code "$status" 0 "compile should succeed for a locked uv project"
205194
assert_contains "$output" "Detected uv project with lockfile. Installing dependencies with uv." "compile should announce supported uv projects"
195+
assert_contains "$output" "Installing Python 3.13 from .python-version." "compile should install the requested Python version"
206196
assert_path_exists "$site_packages_dir" "compile should create the staged site-packages directory"
207197
assert_path_exists "$profile_file" "compile should write a profile script for runtime imports"
208198
assert_path_exists "$export_file" "compile should write the exported requirements file"
199+
assert_path_exists "$shim_dir/python3" "compile should create a python3 shim for runtime commands"
200+
assert_path_exists "$shim_dir/python" "compile should create a python shim for runtime commands"
209201
assert_file_contains "$profile_file" "$site_packages_dir" "profile script should add staged dependencies to PYTHONPATH"
202+
assert_file_contains "$profile_file" "$shim_dir" "profile script should add the managed Python shims to PATH"
203+
assert_file_contains "$TEST_ROOT/uv.log" "python install 3.13" "compile should install the Python version pinned by .python-version"
204+
assert_file_contains "$TEST_ROOT/uv.log" "python find --managed-python 3.13" "compile should resolve the managed interpreter path after installation"
210205
assert_file_contains "$TEST_ROOT/uv.log" "export --locked --format requirements-txt --no-emit-local -o $export_file" "compile should export locked third-party dependencies"
211-
assert_file_contains "$TEST_ROOT/pip.log" "-m pip install uv" "compile should install uv with pip"
212-
assert_file_contains "$TEST_ROOT/pip.log" "--no-deps --target $site_packages_dir -r $export_file" "compile should install exported dependencies into the staged site-packages directory"
213-
}
214-
215-
test_compile_bootstraps_pip_when_missing() {
216-
# Arrange
217-
local build_dir="$TEST_ROOT/missing-pip/build"
218-
local cache_dir="$TEST_ROOT/missing-pip/cache"
219-
local env_dir="$TEST_ROOT/missing-pip/env"
220-
221-
mkdir -p "$build_dir" "$cache_dir" "$env_dir"
222-
touch "$build_dir/pyproject.toml" "$build_dir/uv.lock"
223-
setup_fake_commands 0
224-
225-
# Act
226-
run_compile "$build_dir" "$cache_dir" "$env_dir"
227-
228-
# Assert
229-
assert_exit_code "$status" 0 "compile should bootstrap pip when the interpreter does not ship it"
230-
assert_contains "$output" "pip not found for python3. Bootstrapping with ensurepip." "compile should explain when it needs to bootstrap pip"
231-
assert_file_contains "$TEST_ROOT/ensurepip.log" "-m ensurepip --upgrade" "compile should bootstrap pip before using it"
232-
assert_file_contains "$TEST_ROOT/pip.log" "-m pip install uv" "compile should continue with pip installs after bootstrapping"
206+
assert_file_contains "$TEST_ROOT/uv.log" "pip install --python $FAKE_MANAGED_PYTHON --no-deps --target $site_packages_dir -r $export_file" "compile should install exported dependencies into the staged site-packages directory via uv pip"
233207
}
234208

235209
test_compile_adds_src_directory_to_pythonpath_when_present() {
@@ -241,6 +215,7 @@ test_compile_adds_src_directory_to_pythonpath_when_present() {
241215

242216
mkdir -p "$build_dir/src" "$cache_dir" "$env_dir"
243217
touch "$build_dir/pyproject.toml" "$build_dir/uv.lock"
218+
printf '3.13\n' > "$build_dir/.python-version"
244219
setup_fake_commands
245220

246221
# Act
@@ -259,6 +234,7 @@ test_compile_fails_when_lockfile_is_missing() {
259234

260235
mkdir -p "$build_dir" "$cache_dir" "$env_dir"
261236
touch "$build_dir/pyproject.toml"
237+
printf '3.13\n' > "$build_dir/.python-version"
262238
setup_fake_commands
263239

264240
# Act
@@ -270,7 +246,6 @@ test_compile_fails_when_lockfile_is_missing() {
270246
}
271247

272248
test_compile_succeeds_for_locked_uv_project
273-
test_compile_bootstraps_pip_when_missing
274249
test_compile_adds_src_directory_to_pythonpath_when_present
275250
test_compile_fails_when_lockfile_is_missing
276251

0 commit comments

Comments
 (0)