Skip to content

Commit 42499ff

Browse files
authored
Refactor Python version handling (#1658)
The previous implementation of Python version detection/resolution revolved heavily around the `runtime.txt` file, even though the version could originate from other sources. For example, a fake `runtime.txt` would be written out into the build directory containing the desired Python version, even if the version originated from `Pipfile.lock`. This meant all later Python version handling in the buildpack would see a `runtime.txt` file, along with versions specified in the syntax of that file (e.g. `python-N.N.N` strings), even though that wasn't the format in which the user had specified the version. Now, the buildpack explicitly tracks the requested version and its origin (rather than using the `runtime.txt` file as an API), along with the resolved Python major and full versions (which makes later Python version conditionals less fragile). In addition, the Python version specifiers are validated upfront at the point of parsing the relevant data source, so that clearer error messages can be shown. Lastly, the Python version resolution (the mapping of major Python versions to the latest patch release) has been decoupled from the Pipenv version implementation and made more robust, so it can also be used by the upcoming `.python-version` file support. GUS-W-16821309. GUS-W-7924371. GUS-W-8104668.
1 parent a5dfa07 commit 42499ff

File tree

36 files changed

+680
-471
lines changed

36 files changed

+680
-471
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
- Improved build log output about the detected Python version. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658))
6+
- Improved error messages shown when the requested Python version is not a valid version string or is for an unknown/non-existent major Python version. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658))
7+
- Improved error messages shown when `Pipfile.lock` is not valid JSON. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658))
8+
- Fixed invalid Python versions being silently ignored when they were specified via the `python_version` field in `Pipfile.lock`. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658))
59
- Added support for Python 3.9 on Heroku-24. ([#1656](https://github.com/heroku/heroku-buildpack-python/pull/1656))
610
- Added buildpack metrics for use of outdated Python patch versions and occurrences of internal errors. ([#1657](https://github.com/heroku/heroku-buildpack-python/pull/1657))
711
- Improved the robustness of buildpack error handling by enabling `inherit_errexit`. ([#1655](https://github.com/heroku/heroku-buildpack-python/pull/1655))

bin/compile

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ source "${BUILDPACK_DIR}/lib/output.sh"
2424
source "${BUILDPACK_DIR}/lib/package_manager.sh"
2525
source "${BUILDPACK_DIR}/lib/pip.sh"
2626
source "${BUILDPACK_DIR}/lib/pipenv.sh"
27+
source "${BUILDPACK_DIR}/lib/python_version.sh"
2728
source "${BUILDPACK_DIR}/lib/utils.sh"
2829

2930
compile_start_time=$(nowms)
@@ -46,9 +47,6 @@ export BUILD_DIR CACHE_DIR ENV_DIR
4647
S3_BASE_URL="${BUILDPACK_S3_BASE_URL:-"https://heroku-buildpack-python.s3.us-east-1.amazonaws.com"}"
4748
# This has to be exported since it's used by the geo-libs step which is run in a subshell.
4849

49-
# Default Python Versions
50-
source "${BUILDPACK_DIR}/bin/default_pythons"
51-
5250
# Common Problem Warnings:
5351
# This section creates a temporary file in which to stick the output of `pip install`.
5452
# The `warnings` subscript then greps through this for common problems and guides
@@ -123,10 +121,14 @@ fi
123121
# Runs a `bin/pre_compile` script if found in the app source, allowing build customisation.
124122
source "${BUILDPACK_DIR}/bin/steps/hooks/pre_compile"
125123

126-
# Sticky runtimes. If there was a previous build, and it used a given version of Python,
127-
# continue to use that version of Python in perpetuity.
124+
# TODO: Clear the cache if this isn't a valid version, as part of the cache refactor.
125+
# (Currently the version is instead validated in `read_requested_python_version()`)
128126
if [[ -f "$CACHE_DIR/.heroku/python-version" ]]; then
129-
CACHED_PYTHON_VERSION=$(cat "$CACHE_DIR/.heroku/python-version")
127+
cached_python_version="$(cat "${CACHE_DIR}/.heroku/python-version")"
128+
# `python-X.Y.Z` -> `X.Y`
129+
cached_python_version="${cached_python_version#python-}"
130+
else
131+
cached_python_version=
130132
fi
131133

132134
# We didn't always record the stack version.
@@ -140,29 +142,37 @@ fi
140142
package_manager="$(package_manager::determine_package_manager "${BUILD_DIR}")"
141143
meta_set "package_manager" "${package_manager}"
142144

143-
# Pipenv Python version support.
144-
# Detect the version of Python requested from a Pipfile (e.g. python_version or python_full_version).
145-
# Convert it to a runtime.txt file.
146-
source "${BUILDPACK_DIR}/bin/steps/pipenv-python-version"
147-
148-
if [[ -f runtime.txt ]]; then
149-
# PYTHON_VERSION_SOURCE may have already been set by the pipenv-python-version step.
150-
# TODO: Refactor this and stop pipenv-python-version using runtime.txt as an API.
151-
PYTHON_VERSION_SOURCE=${PYTHON_VERSION_SOURCE:-"runtime.txt"}
152-
puts-step "Using Python version specified in ${PYTHON_VERSION_SOURCE}"
153-
meta_set "python_version_reason" "specified"
154-
elif [[ -n "${CACHED_PYTHON_VERSION:-}" ]]; then
155-
puts-step "No Python version was specified. Using the same version as the last build: ${CACHED_PYTHON_VERSION}"
156-
echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"
157-
meta_set "python_version_reason" "cached"
158-
echo "${CACHED_PYTHON_VERSION}" >runtime.txt
159-
else
160-
puts-step "No Python version was specified. Using the buildpack default: ${DEFAULT_PYTHON_VERSION}"
161-
echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"
162-
meta_set "python_version_reason" "default"
163-
echo "${DEFAULT_PYTHON_VERSION}" >runtime.txt
145+
# TODO: Move this warning to lib/package_manager.sh once `output::warning()` exists
146+
# (puts-warn outputs to stdout, which would break `determine_package_manager()` as is).
147+
# TODO: Adjust this warning to mention support for missing Pipfile.lock will be removed soon.
148+
if [[ "${package_manager}" == "pipenv" && ! -f "${BUILD_DIR}/Pipfile.lock" ]]; then
149+
puts-warn "No 'Pipfile.lock' found! We recommend you commit this into your repository."
164150
fi
165151

152+
# We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function
153+
# without having to hardcode globals. See: https://stackoverflow.com/a/38997681
154+
python_version::read_requested_python_version "${BUILD_DIR}" "${package_manager}" "${cached_python_version}" requested_python_version python_version_origin
155+
meta_set "python_version_reason" "${python_version_origin}"
156+
157+
case "${python_version_origin}" in
158+
default)
159+
puts-step "No Python version was specified. Using the buildpack default: Python ${requested_python_version}"
160+
echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"
161+
;;
162+
cached)
163+
puts-step "No Python version was specified. Using the same version as the last build: Python ${requested_python_version}"
164+
echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"
165+
;;
166+
*)
167+
puts-step "Using Python ${requested_python_version} specified in ${python_version_origin}"
168+
;;
169+
esac
170+
171+
python_full_version="$(python_version::resolve_python_version "${requested_python_version}" "${python_version_origin}")"
172+
python_major_version="${python_full_version%.*}"
173+
meta_set "python_version" "${python_full_version}"
174+
meta_set "python_version_major" "${python_major_version}"
175+
166176
# The directory for the .profile.d scripts.
167177
mkdir -p "$(dirname "$PROFILE_PATH")"
168178
# The directory for editable VCS dependencies.

bin/default_pythons

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

bin/steps/pipenv-python-version

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

bin/steps/python

Lines changed: 37 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,54 +4,21 @@
44

55
set -euo pipefail
66

7-
PYTHON_VERSION=$(cat runtime.txt)
8-
# Remove leading and trailing whitespace. Note: This implementation relies upon
9-
# `extglob` being set, which is the case thanks to `bin/utils` being run earlier.
10-
PYTHON_VERSION="${PYTHON_VERSION##+([[:space:]])}"
11-
PYTHON_VERSION="${PYTHON_VERSION%%+([[:space:]])}"
12-
13-
function eol_python_version_error() {
14-
local major_version="${1}"
15-
local eol_date="${2}"
16-
display_error <<-EOF
17-
Error: Python ${major_version} is no longer supported.
18-
19-
Python ${major_version} reached upstream end-of-life on ${eol_date}, and is
20-
therefore no longer receiving security updates:
21-
https://devguide.python.org/versions/#supported-versions
22-
23-
As such, it is no longer supported by this buildpack.
24-
25-
Please upgrade to a newer Python version.
26-
27-
For a list of the supported Python versions, see:
28-
https://devcenter.heroku.com/articles/python-support#supported-runtimes
29-
EOF
30-
meta_set "failure_reason" "python-version-eol"
31-
exit 1
32-
}
33-
34-
# We check for EOL prior to checking if the archive exists on S3, to ensure the more specific EOL error
35-
# message is still shown for newer stacks where the EOL Python versions might not have been built.
36-
case "${PYTHON_VERSION}" in
37-
python-3.7.+([0-9]))
38-
eol_python_version_error "3.7" "June 27th, 2023"
39-
;;
40-
python-3.6.+([0-9]))
41-
eol_python_version_error "3.6" "December 23rd, 2021"
42-
;;
43-
*) ;;
44-
esac
45-
467
# The Python runtime archive filename is of form: 'python-X.Y.Z-ubuntu-22.04-amd64.tar.zst'
478
# The Ubuntu version is calculated from `STACK` since it's faster than calling `lsb_release`.
489
UBUNTU_VERSION="${STACK/heroku-/}.04"
4910
ARCH=$(dpkg --print-architecture)
50-
PYTHON_URL="${S3_BASE_URL}/${PYTHON_VERSION}-ubuntu-${UBUNTU_VERSION}-${ARCH}.tar.zst"
51-
11+
PYTHON_URL="${S3_BASE_URL}/python-${python_full_version}-ubuntu-${UBUNTU_VERSION}-${ARCH}.tar.zst"
12+
13+
# The Python version validation earlier will have filtered out most unsupported versions.
14+
# However, the version might still not be found if either:
15+
# 1. It's a Python major version we've deprecated and so is only available on older stacks (i.e: Python 3.8).
16+
# 2. If an exact Python version was requested and the patch version doesn't exist (e.g. 3.12.999).
17+
# 3. The user has pinned to an older buildpack version and the S3 bucket location or layout has changed since.
18+
# TODO: Update this message to be more specific once Python 3.8 support is dropped.
5219
if ! curl --output /dev/null --silent --head --fail --retry 3 --retry-connrefused --connect-timeout 10 "${PYTHON_URL}"; then
5320
display_error <<-EOF
54-
Error: Requested runtime '${PYTHON_VERSION}' is not available for this stack (${STACK}).
21+
Error: Python ${python_full_version} is not available for this stack (${STACK}).
5522
5623
For a list of the supported Python versions, see:
5724
https://devcenter.heroku.com/articles/python-support#supported-runtimes
@@ -60,20 +27,17 @@ if ! curl --output /dev/null --silent --head --fail --retry 3 --retry-connrefuse
6027
exit 1
6128
fi
6229

63-
# TODO: Refactor Python version usage to use the non-prefixed form everywhere.
64-
python_version_without_prefix="${PYTHON_VERSION#python-}"
65-
meta_set "python_version" "${python_version_without_prefix}"
66-
meta_set "python_version_major" "${python_version_without_prefix%.*}"
67-
6830
function warn_if_patch_update_available() {
69-
local requested_version="${1}"
70-
local latest_patch_version="${2}"
31+
local requested_full_version="${1}"
32+
local requested_major_version="${2}"
33+
local latest_patch_version
34+
latest_patch_version="$(python_version::resolve_python_version "${requested_major_version}" "${python_version_origin}")"
7135
# Extract the patch version component of the version strings (ie: the '5' in '3.10.5').
72-
local requested_patch_number="${requested_version##*.}"
36+
local requested_patch_number="${requested_full_version##*.}"
7337
local latest_patch_number="${latest_patch_version##*.}"
7438
if ((requested_patch_number < latest_patch_number)); then
7539
puts-warn
76-
puts-warn "A Python security update is available! Upgrade as soon as possible to: ${latest_patch_version}"
40+
puts-warn "A Python security update is available! Upgrade as soon as possible to: Python ${latest_patch_version}"
7741
puts-warn "See: https://devcenter.heroku.com/articles/python-runtimes"
7842
puts-warn
7943
meta_set "python_version_outdated" "true"
@@ -84,45 +48,31 @@ function warn_if_patch_update_available() {
8448

8549
# We wait until now to display outdated Python version warnings, since we only want to show them
8650
# if there weren't any errors with the version to avoid adding noise to the error messages.
87-
case "${PYTHON_VERSION}" in
88-
python-3.12.*)
89-
warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_312}"
90-
;;
91-
python-3.11.*)
92-
warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_311}"
93-
;;
94-
python-3.10.*)
95-
warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_310}"
96-
;;
97-
python-3.9.*)
98-
warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_39}"
99-
;;
100-
python-3.8.*)
101-
puts-warn
102-
puts-warn "Python 3.8 will reach its upstream end-of-life in October 2024, at which"
103-
puts-warn "point it will no longer receive security updates:"
104-
puts-warn "https://devguide.python.org/versions/#supported-versions"
105-
puts-warn
106-
puts-warn "Support for Python 3.8 will be removed from this buildpack on December 4th, 2024."
107-
puts-warn
108-
puts-warn "Upgrade to a newer Python version as soon as possible to keep your app secure."
109-
puts-warn "See: https://devcenter.heroku.com/articles/python-runtimes"
110-
puts-warn
111-
warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_38}"
112-
;;
113-
# TODO: Make this case an error, since it should be unreachable.
114-
*) ;;
115-
esac
51+
# TODO: Move this into lib/ as part of the warnings refactor.
52+
if [[ "${python_major_version}" == "3.8" ]]; then
53+
puts-warn
54+
puts-warn "Python 3.8 will reach its upstream end-of-life in October 2024, at which"
55+
puts-warn "point it will no longer receive security updates:"
56+
puts-warn "https://devguide.python.org/versions/#supported-versions"
57+
puts-warn
58+
puts-warn "Support for Python 3.8 will be removed from this buildpack on December 4th, 2024."
59+
puts-warn
60+
puts-warn "Upgrade to a newer Python version as soon as possible to keep your app secure."
61+
puts-warn "See: https://devcenter.heroku.com/articles/python-runtimes"
62+
puts-warn
63+
fi
64+
65+
warn_if_patch_update_available "${python_full_version}" "${python_major_version}"
11666

11767
if [[ "$STACK" != "$CACHED_PYTHON_STACK" ]]; then
11868
puts-step "Stack has changed from $CACHED_PYTHON_STACK to $STACK, clearing cache"
11969
rm -rf .heroku/python-stack .heroku/python-version .heroku/python .heroku/vendor .heroku/python .heroku/python-sqlite3-version
12070
fi
12171

72+
# TODO: Clean this up as part of the cache refactor.
12273
if [[ -f .heroku/python-version ]]; then
123-
# shellcheck disable=SC2312 # TODO: Invoke this command separately to avoid masking its return value.
124-
if [[ ! "$(cat .heroku/python-version)" == "$PYTHON_VERSION" ]]; then
125-
puts-step "Python version has changed from $(cat .heroku/python-version) to ${PYTHON_VERSION}, clearing cache"
74+
if [[ "${cached_python_version}" != "${python_full_version}" ]]; then
75+
puts-step "Python version has changed from ${cached_python_version} to ${python_full_version}, clearing cache"
12676
rm -rf .heroku/python
12777
else
12878
SKIP_INSTALL=1
@@ -153,23 +103,23 @@ if [[ -f "${BUILD_DIR}/requirements.txt" ]]; then
153103
fi
154104

155105
if [[ "${SKIP_INSTALL:-0}" == "1" ]]; then
156-
puts-step "Using cached install of ${PYTHON_VERSION}"
106+
puts-step "Using cached install of Python ${python_full_version}"
157107
else
158-
puts-step "Installing ${PYTHON_VERSION}"
108+
puts-step "Installing Python ${python_full_version}"
159109

160110
# Prepare destination directory.
161111
mkdir -p .heroku/python
162112

163113
if ! curl --silent --show-error --fail --retry 3 --retry-connrefused --connect-timeout 10 "${PYTHON_URL}" | tar --zstd --extract --directory .heroku/python; then
164114
# The Python version was confirmed to exist previously, so any failure here is due to
165115
# a networking issue or archive/buildpack bug rather than the runtime not existing.
166-
display_error "Error: Failed to download/install ${PYTHON_VERSION}."
116+
display_error "Error: Failed to download/install Python ${python_full_version}."
167117
meta_set "failure_reason" "python-download"
168118
exit 1
169119
fi
170120

171121
# Record for future reference.
172-
echo "$PYTHON_VERSION" >.heroku/python-version
122+
echo "python-${python_full_version}" >.heroku/python-version
173123
echo "$STACK" >.heroku/python-stack
174124

175125
hash -r

0 commit comments

Comments
 (0)