-
Notifications
You must be signed in to change notification settings - Fork 1.8k
/
Copy pathpython_version.sh
418 lines (344 loc) · 15.3 KB
/
python_version.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
#!/usr/bin/env bash
# This is technically redundant, since all consumers of this lib will have enabled these,
# however, it helps Shellcheck realise the options under which these functions will run.
set -euo pipefail
LATEST_PYTHON_3_9="3.9.21"
LATEST_PYTHON_3_10="3.10.16"
LATEST_PYTHON_3_11="3.11.11"
LATEST_PYTHON_3_12="3.12.9"
LATEST_PYTHON_3_13="3.13.2"
OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION=9
NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION=13
DEFAULT_PYTHON_FULL_VERSION="${LATEST_PYTHON_3_13}"
DEFAULT_PYTHON_MAJOR_VERSION="${DEFAULT_PYTHON_FULL_VERSION%.*}"
# Integer with no redundant leading zeros.
INT_REGEX="(0|[1-9][0-9]*)"
# Versions of form N.N or N.N.N.
PYTHON_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}(\.${INT_REGEX})?"
# Versions of form N.N.N only.
PYTHON_FULL_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}\.${INT_REGEX}"
# Determine what Python version has been requested for the project.
#
# Returns a version request of form N.N or N.N.N, with basic validation checks that the version
# matches those forms. EOL version checks will be performed later, when this version request is
# resolved to an exact Python version.
#
# If an app specifies the Python version via multiple means, then the order of precedence is:
# 1. `runtime.txt` file (deprecated)
# 2. `.python-version` file (recommended)
# 3. The `python_full_version` field in the `Pipfile.lock` file
# 4. The `python_version` field in the `Pipfile.lock` file
#
# If a version wasn't specified by the app, then new apps/those with an empty cache will use
# a buildpack default version for the first build, and then subsequent cached builds will use
# the same Python major version in perpetuity (aka sticky versions). Sticky versioning leads to
# confusing UX so is something we want to deprecate/sunset in the future (and have already done
# so in the Python CNB).
function python_version::read_requested_python_version() {
local build_dir="${1}"
local package_manager="${2}"
local cached_python_full_version="${3}"
# We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function
# without having to hardcode globals. See: https://stackoverflow.com/a/38997681
declare -n version="${4}"
declare -n origin="${5}"
local contents
local runtime_txt_path="${build_dir}/runtime.txt"
if [[ -f "${runtime_txt_path}" ]]; then
contents="$(cat "${runtime_txt_path}")"
version="$(python_version::parse_runtime_txt "${contents}")"
origin="runtime.txt"
return 0
fi
local python_version_file_path="${build_dir}/.python-version"
if [[ -f "${python_version_file_path}" ]]; then
contents="$(cat "${python_version_file_path}")"
version="$(python_version::parse_python_version_file "${contents}")"
origin=".python-version"
return 0
fi
if [[ "${package_manager}" == "pipenv" ]]; then
version="$(python_version::read_pipenv_python_version "${build_dir}")"
# The Python version fields in a Pipfile.lock are optional.
if [[ -n "${version}" ]]; then
origin="Pipfile.lock"
return 0
fi
fi
# Protect against unsupported (eg PyPy) or invalid versions being found in the cache metadata.
if [[ "${cached_python_full_version}" =~ ^${PYTHON_FULL_VERSION_REGEX}$ ]]; then
local cached_python_major_version="${cached_python_full_version%.*}"
version="${cached_python_major_version}"
origin="cached"
else
version="${DEFAULT_PYTHON_MAJOR_VERSION}"
# shellcheck disable=SC2034 # This isn't unused, Shellcheck doesn't follow namerefs.
origin="default"
fi
}
# Parse the contents of a runtime.txt file and return the Python version substring (e.g. `3.12` or `3.12.0`).
function python_version::parse_runtime_txt() {
local contents="${1}"
# The file must contain a string of form `python-N.N` or `python-N.N.N`.
# Leading/trailing whitespace is permitted.
if [[ "${contents}" =~ ^[[:space:]]*python-(${PYTHON_VERSION_REGEX})[[:space:]]*$ ]]; then
local version="${BASH_REMATCH[1]}"
echo "${version}"
else
output::error <<-EOF
Error: Invalid Python version in runtime.txt.
The Python version specified in your runtime.txt file isn't
in the correct format.
The following file contents were found, which aren't valid:
${contents:0:100}
However, the runtime.txt file is deprecated since it has
been replaced by the .python-version file. As such, we
recommend that you switch to using a .python-version file
instead of fixing your runtime.txt file.
Please delete your runtime.txt file and create a new file named:
.python-version
Make sure to include the '.' at the start of the filename.
In the new file, specify your app's Python version without
quotes or a 'python-' prefix. For example:
${DEFAULT_PYTHON_MAJOR_VERSION}
We strongly recommend that you use the major version form
instead of pinning to an exact version, since it will allow
your app to receive Python security updates.
EOF
meta_set "failure_reason" "runtime-txt::invalid-version"
meta_set "failure_detail" "${contents:0:50}"
exit 1
fi
}
# Parse the contents of a .python-version file and return the Python version substring (e.g. `3.12` or `3.12.0`).
function python_version::parse_python_version_file() {
local contents="${1}"
local version_lines=()
while IFS= read -r line; do
# Ignore lines that only contain whitespace and/or comments.
if [[ ! "${line}" =~ ^[[:space:]]*(#.*)?$ ]]; then
version_lines+=("${line}")
fi
done <<<"${contents}"
case "${#version_lines[@]}" in
1)
local line="${version_lines[0]}"
if [[ "${line}" =~ ^[[:space:]]*(${PYTHON_VERSION_REGEX})[[:space:]]*$ ]]; then
local version="${BASH_REMATCH[1]}"
echo "${version}"
return 0
else
output::error <<-EOF
Error: Invalid Python version in .python-version.
The Python version specified in your .python-version file
isn't in the correct format.
The following version was found:
${line}
However, the Python version must be specified as either:
1. The major version only: 3.X (recommended)
2. An exact patch version: 3.X.Y
Don't include quotes or a 'python-' prefix. To include
comments, add them on their own line, prefixed with '#'.
For example, to request the latest version of Python ${DEFAULT_PYTHON_MAJOR_VERSION},
update your .python-version file so it contains:
${DEFAULT_PYTHON_MAJOR_VERSION}
We strongly recommend that you use the major version form
instead of pinning to an exact version, since it will allow
your app to receive Python security updates.
EOF
meta_set "failure_reason" "python-version-file::invalid-version"
meta_set "failure_detail" "${line:0:50}"
exit 1
fi
;;
0)
output::error <<-EOF
Error: Invalid Python version in .python-version.
No Python version was found in your .python-version file.
Update the file so that it contains a valid Python version.
For example, to request the latest version of Python ${DEFAULT_PYTHON_MAJOR_VERSION},
update your .python-version file so it contains:
${DEFAULT_PYTHON_MAJOR_VERSION}
If the file already contains a version, check the line doesn't
begin with a '#', otherwise it will be treated as a comment.
EOF
meta_set "failure_reason" "python-version-file::no-version"
meta_set "failure_detail" "${contents:0:50}"
exit 1
;;
*)
local first_five_version_lines=("${version_lines[@]:0:5}")
output::error <<-EOF
Error: Invalid Python version in .python-version.
Multiple versions were found in your .python-version file:
$(
IFS=$'\n'
echo "${first_five_version_lines[*]}"
)
Update the file so it contains only one Python version.
If you have added comments to the file, make sure that those
lines begin with a '#', so that they are ignored.
EOF
meta_set "failure_reason" "python-version-file::multiple-versions"
meta_set "failure_detail" "$(
IFS=,
echo "${first_five_version_lines[*]}"
)"
exit 1
;;
esac
}
# Read the Python version from a Pipfile.lock, which can exist in one of two optional fields,
# `python_full_version` (as N.N.N) and `python_version` (as N.N). If both fields are
# defined, we will use the value set in `python_full_version`. See:
# https://pipenv.pypa.io/en/stable/specifiers.html#specifying-versions-of-python
function python_version::read_pipenv_python_version() {
local build_dir="${1}"
local pipfile_lock_path="${build_dir}/Pipfile.lock"
local version
# We currently permit using Pipenv without a `Pipfile.lock`, however, in the future we will
# require a lockfile, at which point this conditional can be removed.
if [[ ! -f "${pipfile_lock_path}" ]]; then
return 0
fi
if ! version=$(jq --raw-output '._meta.requires.python_full_version // ._meta.requires.python_version' "${pipfile_lock_path}" 2>&1); then
local jq_error_message="${version}"
output::error <<-EOF
Error: Can't parse Pipfile.lock.
A Pipfile.lock file was found, however, it couldn't be parsed:
${jq_error_message}
This is likely due to it not being valid JSON.
Run 'pipenv lock' to regenerate/fix the lockfile.
EOF
meta_set "failure_reason" "pipfile-lock::invalid-json"
meta_set "failure_detail" "${jq_error_message:0:100}"
exit 1
fi
# Neither of the optional fields were found.
if [[ "${version}" == "null" ]]; then
return 0
fi
# We don't use separate version validation rules for both fields (e.g. ensuring a patch version
# only exists for `python_full_version`) since Pipenv doesn't distinguish between them either:
# https://github.com/pypa/pipenv/blob/v2024.1.0/pipenv/project.py#L392-L398
if [[ "${version}" =~ ^${PYTHON_VERSION_REGEX}$ ]]; then
echo "${version}"
else
output::error <<-EOF
Error: Invalid Python version in Pipfile.lock.
The Python version specified in your Pipfile.lock file by the
'python_version' or 'python_full_version' fields isn't valid.
The following version was found:
${version}
However, the Python version must be specified as either:
1. The major version only: 3.X (recommended)
2. An exact patch version: 3.X.Y
Please update your Pipfile to use a valid Python version and
then run 'pipenv lock' to regenerate Pipfile.lock.
We strongly recommend that you use the major version form
instead of pinning to an exact version, since it will allow
your app to receive Python security updates.
For more information, see:
https://pipenv.pypa.io/en/stable/specifiers.html#specifying-versions-of-python
EOF
meta_set "failure_reason" "pipfile-lock::invalid-version"
meta_set "failure_detail" "${version:0:50}"
exit 1
fi
}
# Resolve a requested Python version (which can be of form N.N or N.N.N) to a specific
# Python version of form N.N.N. Rejects Python major versions that aren't supported.
function python_version::resolve_python_version() {
local requested_python_version="${1}"
local python_version_origin="${2}"
if [[ ! "${requested_python_version}" =~ ^${PYTHON_VERSION_REGEX}$ ]]; then
# The Python version was previously validated, so this should never occur.
utils::abort_internal_error "Invalid Python version: ${requested_python_version}"
fi
local major="${BASH_REMATCH[1]}"
local minor="${BASH_REMATCH[2]}"
if ((major < 3 || (major == 3 && minor < OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION))); then
if [[ "${python_version_origin}" == "cached" ]]; then
output::error <<-EOF
Error: The cached Python version has reached end-of-life.
Your app doesn't specify a Python version, and so normally
would use the version cached from the last build (${requested_python_version}).
However, Python ${major}.${minor} has reached its upstream end-of-life,
and is therefore no longer receiving security updates:
https://devguide.python.org/versions/#supported-versions
As such, it's no longer supported by this buildpack:
https://devcenter.heroku.com/articles/python-support#supported-python-versions
Please upgrade to at least Python 3.${OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION} by configuring an
explicit Python version for your app.
Create a .python-version file in the root directory of your
app, that contains a Python version like:
3.${NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION}
When creating this file make sure to include the '.' at the
start of the filename.
EOF
else
output::error <<-EOF
Error: The requested Python version has reached end-of-life.
Python ${major}.${minor} has reached its upstream end-of-life, and is
therefore no longer receiving security updates:
https://devguide.python.org/versions/#supported-versions
As such, it's no longer supported by this buildpack:
https://devcenter.heroku.com/articles/python-support#supported-python-versions
Please upgrade to at least Python 3.${OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION} by changing the
version in your ${python_version_origin} file.
EOF
fi
meta_set "failure_reason" "python-version::eol"
meta_set "failure_detail" "${major}.${minor}"
exit 1
fi
if (((major == 3 && minor > NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION) || major >= 4)); then
if [[ "${python_version_origin}" == "cached" ]]; then
output::error <<-EOF
Error: The cached Python version isn't recognised.
Your app doesn't specify a Python version, and so normally
would use the version cached from the last build (${requested_python_version}).
However, Python ${major}.${minor} isn't recognised by this version
of the buildpack.
This can occur if you have downgraded the version of the
buildpack to an older version.
Please switch back to a newer version of this buildpack:
https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks
https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references
Alternatively, request an older Python version by creating
a .python-version file in the root directory of your app,
that contains a Python version like:
3.${NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION}
EOF
else
output::error <<-EOF
Error: The requested Python version isn't recognised.
The requested Python version ${major}.${minor} isn't recognised.
Check that this Python version has been officially released,
and that the Python buildpack has added support for it:
https://devguide.python.org/versions/#supported-versions
https://devcenter.heroku.com/articles/python-support#supported-python-versions
If it has, make sure that you are using the latest version
of this buildpack, and haven't pinned to an older release:
https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks
https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references
Otherwise, switch to a supported version (such as Python 3.${NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION})
by changing the version in your ${python_version_origin} file.
EOF
fi
meta_set "failure_reason" "python-version::unknown-major"
meta_set "failure_detail" "${major}.${minor}"
exit 1
fi
# If an exact Python version was requested, there's nothing to resolve.
# Otherwise map major version specifiers to the latest patch release.
case "${requested_python_version}" in
*.*.*) echo "${requested_python_version}" ;;
3.9) echo "${LATEST_PYTHON_3_9}" ;;
3.10) echo "${LATEST_PYTHON_3_10}" ;;
3.11) echo "${LATEST_PYTHON_3_11}" ;;
3.12) echo "${LATEST_PYTHON_3_12}" ;;
3.13) echo "${LATEST_PYTHON_3_13}" ;;
*) utils::abort_internal_error "Unhandled Python major version: ${requested_python_version}" ;;
esac
}