Skip to content

Commit 4e8ae51

Browse files
authored
Improve Python download/installation (#1749)
Refactors the Python download/install and outdated version warning steps, improves error/warning messages and improves buildpack metrics. See the changelog entries for more details. Fixes #1701. Closes #1708. GUS-W-8059919. GUS-W-17844538. GUS-W-17844985. GUS-W-17845321.
1 parent a840ce3 commit 4e8ae51

File tree

10 files changed

+308
-133
lines changed

10 files changed

+308
-133
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
## [Unreleased]
44

5-
- Improved buildpack metrics for builds that fail. ([#1746](https://github.com/heroku/heroku-buildpack-python/pull/1746))
5+
- Improved the warning message shown when the requested Python version is not the latest patch version. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
6+
- Improved the error message shown when the requested Python patch version isn't available. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
7+
- Improved the error message shown if there was a networking or server related error downloading Python. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
8+
- Adjusted the curl options used when downloading Python to set a maximum download time of 120s to prevent hanging builds in the case of network issues. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
9+
- Refactored the Python download step to avoid an unnecessary version check `HEAD` request to S3 prior to downloading Python or reusing a cached install. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
10+
- Improved buildpack metrics for Python version selection. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
11+
- Improved buildpack metrics for builds that fail. ([#1746](https://github.com/heroku/heroku-buildpack-python/pull/1746) and [#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749))
612

713
## [v276] - 2025-02-05
814

bin/compile

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ source "${BUILDPACK_DIR}/lib/output.sh"
2828
source "${BUILDPACK_DIR}/lib/package_manager.sh"
2929
source "${BUILDPACK_DIR}/lib/pip.sh"
3030
source "${BUILDPACK_DIR}/lib/pipenv.sh"
31-
source "${BUILDPACK_DIR}/lib/python_version.sh"
3231
source "${BUILDPACK_DIR}/lib/poetry.sh"
32+
source "${BUILDPACK_DIR}/lib/python_version.sh"
33+
source "${BUILDPACK_DIR}/lib/python.sh"
3334

3435
compile_start_time=$(nowms)
3536

@@ -49,13 +50,6 @@ export PATH=:/usr/local/bin:$PATH
4950
# Exported for use in subshells, such as the steps run via sub_env.
5051
export BUILD_DIR CACHE_DIR ENV_DIR
5152

52-
# Set the base URL for downloading buildpack assets like Python runtimes.
53-
# The user can provide BUILDPACK_S3_BASE_URL to specify a custom target.
54-
# Note: this is designed for non-Heroku use, as it does not use the user-provided
55-
# environment variable mechanism (the ENV_DIR).
56-
S3_BASE_URL="${BUILDPACK_S3_BASE_URL:-"https://heroku-buildpack-python.s3.us-east-1.amazonaws.com"}"
57-
# This has to be exported since it's used by the geo-libs step which is run in a subshell.
58-
5953
# Common Problem Warnings:
6054
# This section creates a temporary file in which to stick the output of `pip install`.
6155
# The `warnings` subscript then greps through this for common problems and guides
@@ -121,6 +115,7 @@ cached_python_full_version="$(cache::cached_python_full_version "${CACHE_DIR}")"
121115
# We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function
122116
# without having to hardcode globals. See: https://stackoverflow.com/a/38997681
123117
python_version::read_requested_python_version "${BUILD_DIR}" "${package_manager}" "${cached_python_full_version}" requested_python_version python_version_origin
118+
meta_set "python_version_requested" "${requested_python_version}"
124119
meta_set "python_version_reason" "${python_version_origin}"
125120

126121
# TODO: More strongly recommend specifying a Python version (eg switch the messaging to
@@ -144,6 +139,12 @@ python_major_version="${python_full_version%.*}"
144139
meta_set "python_version" "${python_full_version}"
145140
meta_set "python_version_major" "${python_major_version}"
146141

142+
if [[ "${requested_python_version}" == "${python_full_version}" ]]; then
143+
meta_set "python_version_pinned" "true"
144+
else
145+
meta_set "python_version_pinned" "false"
146+
fi
147+
147148
if [[ "${python_version_origin}" == "runtime.txt" ]]; then
148149
output::warning <<-EOF
149150
Warning: The runtime.txt file is deprecated.
@@ -170,6 +171,9 @@ if [[ "${python_version_origin}" == "runtime.txt" ]]; then
170171
EOF
171172
fi
172173

174+
python_version::warn_if_deprecated_major_version "${python_major_version}" "${python_version_origin}"
175+
python_version::warn_if_patch_update_available "${python_full_version}" "${python_major_version}" "${python_version_origin}"
176+
173177
cache::restore "${BUILD_DIR}" "${CACHE_DIR}" "${STACK}" "${cached_python_full_version}" "${python_full_version}" "${package_manager}"
174178

175179
# The directory for the .profile.d scripts.
@@ -190,10 +194,7 @@ if [[ "$(realpath "${BUILD_DIR}")" != "$(realpath /app)" ]]; then
190194
# Note: .heroku/src is copied in later.
191195
fi
192196

193-
# Download and install Python using pre-built binaries from S3.
194-
install_python_start_time=$(nowms)
195-
source "${BUILDPACK_DIR}/bin/steps/python"
196-
meta_time "python_install_duration" "${install_python_start_time}"
197+
python::install "${BUILD_DIR}" "${STACK}" "${python_full_version}" "${python_major_version}" "${python_version_origin}"
197198

198199
# Install the package manager and related tools.
199200
package_manager_install_start_time=$(nowms)

bin/report

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ STRING_FIELDS=(
7272
poetry_version
7373
python_version_major
7474
python_version_reason
75+
python_version_requested
7576
python_version
7677
setuptools_version
7778
wheel_version
@@ -81,6 +82,7 @@ STRING_FIELDS=(
8182
ALL_OTHER_FIELDS=(
8283
cache_restore_duration
8384
cache_save_duration
85+
custom_s3_base_url
8486
dependencies_install_duration
8587
django_collectstatic_duration
8688
duplicate_python_buildpack
@@ -94,6 +96,7 @@ ALL_OTHER_FIELDS=(
9496
pre_compile_hook_duration
9597
python_install_duration
9698
python_version_outdated
99+
python_version_pinned
97100
setup_py_only
98101
sqlite_install_duration
99102
total_duration

bin/steps/python

Lines changed: 0 additions & 98 deletions
This file was deleted.

lib/python.sh

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env bash
2+
3+
# This is technically redundant, since all consumers of this lib will have enabled these,
4+
# however, it helps Shellcheck realise the options under which these functions will run.
5+
set -euo pipefail
6+
7+
DEFAULT_S3_BASE_URL="https://heroku-buildpack-python.s3.us-east-1.amazonaws.com"
8+
9+
function python::install() {
10+
local build_dir="${1}"
11+
local stack="${2}"
12+
local python_full_version="${3}"
13+
local python_major_version="${4}"
14+
local python_version_origin="${5}"
15+
16+
local install_python_start_time
17+
install_python_start_time=$(nowms)
18+
local install_dir="${build_dir}/.heroku/python"
19+
20+
if [[ -f "${install_dir}/bin/python" ]]; then
21+
output::step "Using cached install of Python ${python_full_version}"
22+
else
23+
output::step "Installing Python ${python_full_version}"
24+
25+
mkdir -p "${install_dir}"
26+
27+
# Note: This can't be used via app config vars, since it doesn't reference the value from ENV_DIR.
28+
# TODO: Remove this for parity with the Python CNB, if metrics show it to be unused on Heroku.
29+
if [[ -v BUILDPACK_S3_BASE_URL ]]; then
30+
local s3_base_url="${BUILDPACK_S3_BASE_URL}"
31+
meta_set "custom_s3_base_url" "true"
32+
else
33+
local s3_base_url="${DEFAULT_S3_BASE_URL}"
34+
fi
35+
36+
# Calculating the Ubuntu version from the stack name saves having to shell out to `lsb_release`.
37+
local ubuntu_version="${stack/heroku-/}.04"
38+
local arch
39+
arch=$(dpkg --print-architecture)
40+
# e.g.: https://heroku-buildpack-python.s3.us-east-1.amazonaws.com/python-3.13.0-ubuntu-24.04-amd64.tar.zst
41+
local python_url="${s3_base_url}/python-${python_full_version}-ubuntu-${ubuntu_version}-${arch}.tar.zst"
42+
43+
local error_log
44+
error_log=$(mktemp)
45+
46+
# shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled.
47+
if ! {
48+
{
49+
# We set max-time for improved UX/metrics for hanging downloads compared to relying
50+
# on the build system timeout. The Python archives are only ~10 MB so take < 1s to
51+
# download on Heroku's build system, however, we use much higher timeouts so that
52+
# the buildpack works in non-Heroku environments that may be far from `us-east-1`
53+
# or have a slower connection. We don't use `--speed-limit` since it gives worse
54+
# error messages when used with retries and piping to tar.
55+
curl \
56+
--connect-timeout 10 \
57+
--fail \
58+
--max-time 120 \
59+
--retry-max-time 120 \
60+
--retry 3 \
61+
--retry-connrefused \
62+
--show-error \
63+
--silent \
64+
"${python_url}" \
65+
| tar \
66+
--directory "${install_dir}" \
67+
--extract \
68+
--zstd
69+
} \
70+
|& tee "${error_log}" \
71+
|& output::indent
72+
}; then
73+
local latest_known_patch_version
74+
latest_known_patch_version="$(python_version::resolve_python_version "${python_major_version}" "${python_version_origin}")"
75+
# Ideally we would inspect the HTTP status code directly instead of grepping, however:
76+
# 1. We want to pipe to tar (since it's faster than performing the download and
77+
# decompression/extraction as separate steps), so can't write to stdout.
78+
# 2. We want to display the original stderr to the user, so can't write to stderr.
79+
# 3. Curl's `--write-out` feature only supports outputting to a file (as opposed to
80+
# stdout/stderr) as of curl v8.3.0, which is newer than the curl on Heroku-20/22.
81+
# This has an integration test run against all stacks, which will mean we will know
82+
# if future versions of curl change the error message string.
83+
#
84+
# We have to check for HTTP 403s too, since S3 will return a 403 instead of a 404 for
85+
# missing files, if the S3 bucket does not have public list permissions enabled.
86+
if [[ "${python_full_version}" != "${latest_known_patch_version}" ]] && grep --quiet "The requested URL returned error: 40[34]" "${error_log}"; then
87+
output::error <<-EOF
88+
Error: The requested Python version isn't available.
89+
90+
Your app's ${python_version_origin} file specifies a Python version
91+
of ${python_full_version}, however, we couldn't find that version on S3.
92+
93+
Check that this Python version has been released upstream,
94+
and that the Python buildpack has added support for it:
95+
https://www.python.org/downloads/
96+
https://github.com/heroku/heroku-buildpack-python/blob/main/CHANGELOG.md
97+
98+
If it has, make sure that you are using the latest version
99+
of this buildpack, and haven't pinned to an older release:
100+
https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks
101+
https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references
102+
103+
We also strongly recommend that you do not pin your app to an
104+
exact Python version such as ${python_full_version}, and instead only specify
105+
the major Python version of ${python_major_version} in your ${python_version_origin} file.
106+
This will allow your app to receive the latest available Python
107+
patch version automatically, and prevent this type of error.
108+
EOF
109+
meta_set "failure_reason" "python-version::unknown-patch"
110+
meta_set "failure_detail" "${python_full_version}"
111+
else
112+
output::error <<-EOF
113+
Error: Unable to download/install Python.
114+
115+
An error occurred while downloading/installing the Python
116+
runtime archive from:
117+
${python_url}
118+
119+
In some cases, this happens due to a temporary issue with
120+
the network connection or server.
121+
122+
First, make sure that you are using the latest version
123+
of this buildpack, and haven't pinned to an older release:
124+
https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks
125+
https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references
126+
127+
Then try building again to see if the error resolves itself.
128+
EOF
129+
meta_set "failure_reason" "install-python"
130+
# e.g.: 'curl: (6) Could not resolve host: heroku-buildpack-python.s3.us-east-1.amazonaws.com'
131+
meta_set "failure_detail" "$(head --lines=1 "${error_log}" || true)"
132+
fi
133+
134+
exit 1
135+
fi
136+
fi
137+
138+
meta_time "python_install_duration" "${install_python_start_time}"
139+
}

0 commit comments

Comments
 (0)