Skip to content

Commit b77dd09

Browse files
authored
Make Python version pinning pin to the major version only (#1714)
For apps that do not specify an explicit Python version (e.g.: via a `.python-version` or `runtime.txt` file), the buildpack uses a curated default version for the first build of the app. Then for subsequent builds of the app, the buildpack selects a Python version based on the version found in the build cache, so that the version used for the app doesn't change in a breaking way over time as the buildpack's own default version changes. This feature is referred to as "version pinning" and/or "sticky versions". The existing implementation of this feature pinned the version to the full Python version (e.g. `3.13.0`), meaning that the app would always use that exact Python version, even when newer backwards-compatible patch releases (such as `3.13.1`) became available over time. Now that we have Python major version -> latest patch version resolution support (as of #1658) and improved build output around cache invalidation reasons (as of #1679), we can switch to instead only pinning to the major Python version (e.g. `3.13`). This allows apps that do not specify a Python version to pick up any bug and security fixes for their major Python version the next time the app is built, whilst still keeping the compatibility properties of version pinning. Longer term, the plan is to deprecate/sunset version pinning entirely (since it leads to confusing UX / lack of parity between multiple apps deployed from the same codebase at different times, e.g. review apps), and the Python CNB has already dropped support for it. However, that will be a breaking change for the classic buildpack, so out of scope for now. GUS-W-17384879.
1 parent dc79a48 commit b77dd09

File tree

7 files changed

+29
-38
lines changed

7 files changed

+29
-38
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- Changed Python version pinning behaviour for apps that do not specify a Python version. Repeat builds are now pinned to the major Python version only (`3.X`) instead of the full Python version (`3.X.Y`), so that they always use the latest patch version. ([#1714](https://github.com/heroku/heroku-buildpack-python/pull/1714))
56

67
## [v269] - 2024-12-04
78

bin/compile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ hooks::run_hook "pre_compile"
106106
package_manager="$(package_manager::determine_package_manager "${BUILD_DIR}")"
107107
meta_set "package_manager" "${package_manager}"
108108

109-
cached_python_version="$(cache::cached_python_version "${CACHE_DIR}")"
109+
cached_python_full_version="$(cache::cached_python_full_version "${CACHE_DIR}")"
110110

111111
# We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function
112112
# without having to hardcode globals. See: https://stackoverflow.com/a/38997681
113-
python_version::read_requested_python_version "${BUILD_DIR}" "${package_manager}" "${cached_python_version}" requested_python_version python_version_origin
113+
python_version::read_requested_python_version "${BUILD_DIR}" "${package_manager}" "${cached_python_full_version}" requested_python_version python_version_origin
114114
meta_set "python_version_reason" "${python_version_origin}"
115115

116116
# TODO: More strongly recommend specifying a Python version (eg switch the messaging to
@@ -122,7 +122,7 @@ case "${python_version_origin}" in
122122
echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"
123123
;;
124124
cached)
125-
output::step "No Python version was specified. Using the same version as the last build: Python ${requested_python_version}"
125+
output::step "No Python version was specified. Using the same major version as the last build: Python ${requested_python_version}"
126126
echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes"
127127
;;
128128
*)
@@ -135,7 +135,7 @@ python_major_version="${python_full_version%.*}"
135135
meta_set "python_version" "${python_full_version}"
136136
meta_set "python_version_major" "${python_major_version}"
137137

138-
cache::restore "${BUILD_DIR}" "${CACHE_DIR}" "${STACK:?}" "${cached_python_version}" "${python_full_version}" "${package_manager}"
138+
cache::restore "${BUILD_DIR}" "${CACHE_DIR}" "${STACK:?}" "${cached_python_full_version}" "${python_full_version}" "${package_manager}"
139139

140140
# The directory for the .profile.d scripts.
141141
mkdir -p "$(dirname "$PROFILE_PATH")"

lib/cache.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ set -euo pipefail
66

77
# Read the full Python version of the Python install in the cache, or the empty string
88
# if the cache is empty or doesn't contain a Python version metadata file.
9-
function cache::cached_python_version() {
9+
function cache::cached_python_full_version() {
1010
local cache_dir="${1}"
1111

1212
if [[ -f "${cache_dir}/.heroku/python-version" ]]; then
@@ -29,7 +29,7 @@ function cache::restore() {
2929
local build_dir="${1}"
3030
local cache_dir="${2}"
3131
local stack="${3}"
32-
local cached_python_version="${4}"
32+
local cached_python_full_version="${4}"
3333
local python_full_version="${5}"
3434
local package_manager="${6}"
3535

@@ -48,8 +48,8 @@ function cache::restore() {
4848
cache_invalidation_reasons+=("The stack has changed from ${cached_stack:-"unknown"} to ${stack}")
4949
fi
5050

51-
if [[ "${cached_python_version}" != "${python_full_version}" ]]; then
52-
cache_invalidation_reasons+=("The Python version has changed from ${cached_python_version:-"unknown"} to ${python_full_version}")
51+
if [[ "${cached_python_full_version}" != "${python_full_version}" ]]; then
52+
cache_invalidation_reasons+=("The Python version has changed from ${cached_python_full_version:-"unknown"} to ${python_full_version}")
5353
fi
5454

5555
# The metadata store only exists in caches created in v252+ of the buildpack (released 2024-06-17),

lib/python_version.sh

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ DEFAULT_PYTHON_MAJOR_VERSION="${DEFAULT_PYTHON_FULL_VERSION%.*}"
1818
INT_REGEX="(0|[1-9][0-9]*)"
1919
# Versions of form N.N or N.N.N.
2020
PYTHON_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}(\.${INT_REGEX})?"
21+
# Versions of form N.N.N only.
22+
PYTHON_FULL_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}\.${INT_REGEX}"
2123

2224
# Determine what Python version has been requested for the project.
2325
#
@@ -33,16 +35,13 @@ PYTHON_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}(\.${INT_REGEX})?"
3335
#
3436
# If a version wasn't specified by the app, then new apps/those with an empty cache will use
3537
# a buildpack default version for the first build, and then subsequent cached builds will use
36-
# the same Python full version in perpetuity (aka sticky versions). Sticky versioning leads to
38+
# the same Python major version in perpetuity (aka sticky versions). Sticky versioning leads to
3739
# confusing UX so is something we want to deprecate/sunset in the future (and have already done
3840
# so in the Python CNB).
39-
# TODO: Change the sticky versioning implementation so it's only sticky to the major version
40-
# rather than the full version, so apps that don't specify a Python version at least get
41-
# security patch updates.
4241
function python_version::read_requested_python_version() {
4342
local build_dir="${1}"
4443
local package_manager="${2}"
45-
local cached_python_version="${3}"
44+
local cached_python_full_version="${3}"
4645
# We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function
4746
# without having to hardcode globals. See: https://stackoverflow.com/a/38997681
4847
declare -n version="${4}"
@@ -75,8 +74,9 @@ function python_version::read_requested_python_version() {
7574
fi
7675

7776
# Protect against unsupported (eg PyPy) or invalid versions being found in the cache metadata.
78-
if [[ "${cached_python_version}" =~ ^${PYTHON_VERSION_REGEX}$ ]]; then
79-
version="${cached_python_version}"
77+
if [[ "${cached_python_full_version}" =~ ^${PYTHON_FULL_VERSION_REGEX}$ ]]; then
78+
local cached_python_major_version="${cached_python_full_version%.*}"
79+
version="${cached_python_major_version}"
8080
origin="cached"
8181
else
8282
version="${DEFAULT_PYTHON_MAJOR_VERSION}"

spec/hatchet/pip_spec.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
app.deploy do |app|
8080
# The test fixture's requirements.txt is a symlink to a requirements file in a subdirectory in
8181
# order to test that symlinked requirements files work in general and with cache invalidation.
82-
File.write('requirements/prod.txt', 'six', mode: 'a')
82+
File.write('requirements/prod.txt', 'six==1.17.0', mode: 'a')
8383
app.commit!
8484
app.push!
8585
expect(clean_output(app.output)).to include(<<~OUTPUT)
@@ -93,12 +93,12 @@
9393
remote: -----> Installing dependencies using 'pip install -r requirements.txt'
9494
remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 5))
9595
remote: Downloading typing_extensions-4.12.2-py3-none-any.whl.metadata (3.0 kB)
96-
remote: Collecting six (from -r requirements.txt (line 6))
97-
remote: Downloading six-1.16.0-py2.py3-none-any.whl.metadata (1.8 kB)
96+
remote: Collecting six==1.17.0 (from -r requirements.txt (line 6))
97+
remote: Downloading six-1.17.0-py2.py3-none-any.whl.metadata (1.7 kB)
9898
remote: Downloading typing_extensions-4.12.2-py3-none-any.whl (37 kB)
99-
remote: Downloading six-1.16.0-py2.py3-none-any.whl (11 kB)
99+
remote: Downloading six-1.17.0-py2.py3-none-any.whl (11 kB)
100100
remote: Installing collected packages: typing-extensions, six
101-
remote: Successfully installed six-1.16.0 typing-extensions-4.12.2
101+
remote: Successfully installed six-1.17.0 typing-extensions-4.12.2
102102
OUTPUT
103103
end
104104
end

spec/hatchet/python_version_spec.rb

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,15 @@
6161
app.push!
6262
expect(clean_output(app.output)).to include(<<~OUTPUT)
6363
remote: -----> Python app detected
64-
remote: -----> No Python version was specified. Using the same version as the last build: Python 3.12.6
64+
remote: -----> No Python version was specified. Using the same major version as the last build: Python 3.12
6565
remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes
6666
remote: -----> Discarding cache since:
67+
remote: - The Python version has changed from 3.12.6 to #{LATEST_PYTHON_3_12}
6768
remote: - The pip version has changed from 24.0 to #{PIP_VERSION}
68-
remote: -----> Installing Python 3.12.6
69-
remote:
70-
remote: ! Warning: A Python security update is available!
71-
remote: !
72-
remote: ! Upgrade as soon as possible to: Python #{LATEST_PYTHON_3_12}
73-
remote: ! See: https://devcenter.heroku.com/articles/python-runtimes
74-
remote:
69+
remote: -----> Installing Python #{LATEST_PYTHON_3_12}
7570
remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION}
7671
OUTPUT
77-
expect(app.run('python -V')).to include('Python 3.12.6')
72+
expect(app.run('python -V')).to include("Python #{LATEST_PYTHON_3_12}")
7873
end
7974
end
8075
end

spec/hatchet/stack_spec.rb

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,13 @@
2121
app.push!
2222
expect(clean_output(app.output)).to include(<<~OUTPUT)
2323
remote: -----> Python app detected
24-
remote: -----> No Python version was specified. Using the same version as the last build: Python 3.12.3
24+
remote: -----> No Python version was specified. Using the same major version as the last build: Python 3.12
2525
remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes
2626
remote: -----> Discarding cache since:
2727
remote: - The stack has changed from heroku-22 to heroku-24
28+
remote: - The Python version has changed from 3.12.3 to #{LATEST_PYTHON_3_12}
2829
remote: - The pip version has changed
29-
remote: -----> Installing Python 3.12.3
30-
remote:
31-
remote: ! Warning: A Python security update is available!
32-
remote: !
33-
remote: ! Upgrade as soon as possible to: Python #{LATEST_PYTHON_3_12}
34-
remote: ! See: https://devcenter.heroku.com/articles/python-runtimes
35-
remote:
30+
remote: -----> Installing Python #{LATEST_PYTHON_3_12}
3631
remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION}
3732
remote: -----> Installing SQLite3
3833
remote: -----> Installing dependencies using 'pip install -r requirements.txt'
@@ -53,7 +48,7 @@
5348
app.push!
5449
expect(clean_output(app.output)).to include(<<~OUTPUT)
5550
remote: -----> Python app detected
56-
remote: -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION}
51+
remote: -----> No Python version was specified. Using the same major version as the last build: Python #{DEFAULT_PYTHON_MAJOR_VERSION}
5752
remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes
5853
remote: -----> Discarding cache since:
5954
remote: - The stack has changed from heroku-24 to heroku-22

0 commit comments

Comments
 (0)