Skip to content

Commit dd4d759

Browse files
authored
Add support for Python 3.13 (#1661)
Add support for Python 3.13, and release 3.13.0. The default Python version remains unchanged (at 3.12.x) for now. Notably for Python 3.13 we now: - No longer install setuptools and wheel - matching what the wider ecosystem has already done for Python 3.12+. (See the Python CNB's removal PR for more details: heroku/buildpacks-python#243) - No longer install the SQLite headers and CLI, as the first step towards dropping that rarely used feature. - Have to disable some of the tests that run during the PGO profiling phase when on Heroku-22 (see PR comments for more details). In addition, for all Python versions we now also remove the `idle3` and `pydoc3` scripts, since they do not work with relocated Python and so have been broken for some time. Their functionality continues to be available by invoking them via their modules instead (e.g. `python -m pydoc`). Release announcement: https://blog.python.org/2024/10/python-3130-final-released.html https://www.python.org/downloads/release/python-3130/ Details on what's new in Python 3.13: https://docs.python.org/3.13/whatsnew/3.13.html Binary builds: https://github.com/heroku/heroku-buildpack-python/actions/runs/11280580537 Python 3.13 readiness status of the top 360 packages on PyPI: https://pyreadiness.org/3.13/ GUS-W-14846826. GUS-W-14846839. GUS-W-16944574.
1 parent 5a6ec71 commit dd4d759

16 files changed

+206
-28
lines changed

CHANGELOG.md

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

33
## [Unreleased]
44

5+
- Added support for Python 3.13. ([#1661](https://github.com/heroku/heroku-buildpack-python/pull/1661))
6+
- Removed the `idle3` and `pydoc3` scripts since they do not work with relocated Python and so have been broken for some time. Invoke them via their modules instead (e.g. `python -m pydoc`). ([#1661](https://github.com/heroku/heroku-buildpack-python/pull/1661))
57

68
## [v259] - 2024-10-09
79

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Specify a Python Runtime
6060

6161
Supported runtime options include:
6262

63+
- `python-3.13.0` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details)
6364
- `python-3.12.7` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details)
6465
- `python-3.11.10` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details)
6566
- `python-3.10.15` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details)

bin/compile

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,11 @@ package_manager_install_start_time=$(nowms)
201201
bundled_pip_module_path="$(utils::bundled_pip_module_path "${BUILD_DIR}")"
202202
case "${package_manager}" in
203203
pip)
204-
pip::install_pip_setuptools_wheel "${bundled_pip_module_path}"
204+
pip::install_pip_setuptools_wheel "${bundled_pip_module_path}" "${python_major_version}"
205205
;;
206206
pipenv)
207207
# TODO: Stop installing pip when using Pipenv.
208-
pip::install_pip_setuptools_wheel "${bundled_pip_module_path}"
208+
pip::install_pip_setuptools_wheel "${bundled_pip_module_path}" "${python_major_version}"
209209
pipenv::install_pipenv
210210
;;
211211
*)
@@ -217,10 +217,13 @@ meta_time "package_manager_install_duration" "${package_manager_install_start_ti
217217
# SQLite3 support.
218218
# Installs the sqlite3 dev headers and sqlite3 binary but not the
219219
# libsqlite3-0 library since that exists in the base image.
220-
install_sqlite_start_time=$(nowms)
221-
source "${BUILDPACK_DIR}/bin/steps/sqlite3"
222-
buildpack_sqlite3_install
223-
meta_time "sqlite_install_duration" "${install_sqlite_start_time}"
220+
# We skip this step on Python 3.13, as a first step towards removing this feature.
221+
if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) ]]; then
222+
install_sqlite_start_time=$(nowms)
223+
source "${BUILDPACK_DIR}/bin/steps/sqlite3"
224+
buildpack_sqlite3_install
225+
meta_time "sqlite_install_duration" "${install_sqlite_start_time}"
226+
fi
224227

225228
# Install app dependencies.
226229
dependencies_install_start_time=$(nowms)

builds/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ RUN apt-get update --error-on=any \
1515
&& rm -rf /var/lib/apt/lists/*
1616

1717
WORKDIR /tmp
18-
COPY build_python_runtime.sh .
18+
COPY build_python_runtime.sh python-3.13-ubuntu-22.04-libexpat-workaround.patch .

builds/build_python_runtime.sh

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ case "${STACK:?}" in
2727
"3.10"
2828
"3.11"
2929
"3.12"
30+
"3.13"
3031
)
3132
;;
3233
heroku-20)
@@ -36,6 +37,7 @@ case "${STACK:?}" in
3637
"3.10"
3738
"3.11"
3839
"3.12"
40+
"3.13"
3941
)
4042
;;
4143
*)
@@ -49,6 +51,10 @@ fi
4951

5052
# The release keys can be found on https://www.python.org/downloads/ -> "OpenPGP Public Keys".
5153
case "${PYTHON_MAJOR_VERSION}" in
54+
3.13)
55+
# https://github.com/Yhg1s.gpg
56+
GPG_KEY_FINGERPRINT='7169605F62C751356D054A26A821E680E5FA6305'
57+
;;
5258
3.12)
5359
# https://github.com/Yhg1s.gpg
5460
GPG_KEY_FINGERPRINT='7169605F62C751356D054A26A821E680E5FA6305'
@@ -84,6 +90,14 @@ gpg --batch --verify python.tgz.asc python.tgz
8490
tar --extract --file python.tgz --strip-components=1 --directory "${SRC_DIR}"
8591
cd "${SRC_DIR}"
8692

93+
# Work around PGO profile test failures with Python 3.13 on Ubuntu 22.04, due to the tests
94+
# checking the raw libexpat version which doesn't account for Ubuntu backports:
95+
# https://github.com/heroku/heroku-buildpack-python/pull/1661#issuecomment-2405259352
96+
# https://github.com/python/cpython/issues/125067
97+
if [[ "${PYTHON_MAJOR_VERSION}" == "3.13" && "${STACK}" == "heroku-22" ]]; then
98+
patch -p1 </tmp/python-3.13-ubuntu-22.04-libexpat-workaround.patch
99+
fi
100+
87101
# Aim to keep this roughly consistent with the options used in the Python Docker images,
88102
# for maximum compatibility / most battle-tested build configuration:
89103
# https://github.com/docker-library/python
@@ -108,7 +122,7 @@ CONFIGURE_OPTS=(
108122
"--with-system-expat"
109123
)
110124

111-
if [[ "${PYTHON_MAJOR_VERSION}" != 3.[8-9] ]]; then
125+
if [[ "${PYTHON_MAJOR_VERSION}" != +(3.8|3.9) ]]; then
112126
CONFIGURE_OPTS+=(
113127
# Shared builds are beneficial for a number of reasons:
114128
# - Reduces the size of the build, since it avoids the duplication between
@@ -133,7 +147,7 @@ if [[ "${PYTHON_MAJOR_VERSION}" != 3.[8-9] ]]; then
133147
)
134148
fi
135149

136-
if [[ "${PYTHON_MAJOR_VERSION}" == "3.11" || "${PYTHON_MAJOR_VERSION}" == "3.12" ]]; then
150+
if [[ "${PYTHON_MAJOR_VERSION}" != +(3.8|3.9|3.10) ]]; then
137151
CONFIGURE_OPTS+=(
138152
# Skip building the test modules, since we remove them after the build anyway.
139153
# This feature was added in Python 3.10+, however it wasn't until Python 3.11
@@ -156,7 +170,7 @@ fi
156170
# - https://github.com/docker-library/python/issues/810
157171
# We only use `dpkg-buildflags` for Python versions where we build in shared mode (Python 3.9+),
158172
# since some of the options it enables interferes with the stripping of static libraries.
159-
if [[ "${PYTHON_MAJOR_VERSION}" == 3.[8-9] ]]; then
173+
if [[ "${PYTHON_MAJOR_VERSION}" == +(3.8|3.9) ]]; then
160174
EXTRA_CFLAGS=''
161175
LDFLAGS='-Wl,--strip-all'
162176
else
@@ -168,7 +182,7 @@ CPU_COUNT="$(nproc)"
168182
make -j "${CPU_COUNT}" "EXTRA_CFLAGS=${EXTRA_CFLAGS}" "LDFLAGS=${LDFLAGS}"
169183
make install
170184

171-
if [[ "${PYTHON_MAJOR_VERSION}" == 3.[8-9] ]]; then
185+
if [[ "${PYTHON_MAJOR_VERSION}" == +(3.8|3.9) ]]; then
172186
# On older versions of Python we're still building the static library, which has to be
173187
# manually stripped since the linker stripping enabled in LDFLAGS doesn't cover them.
174188
# We're using `--strip-unneeded` since `--strip-all` would remove the `.symtab` section
@@ -213,6 +227,16 @@ find "${INSTALL_DIR}" -depth -type f -name "*.pyc" -delete
213227
# https://github.com/python/cpython/blob/v3.11.3/Makefile.pre.in#L2087-L2113
214228
LD_LIBRARY_PATH="${SRC_DIR}" "${SRC_DIR}/python" -m compileall -f --invalidation-mode unchecked-hash --workers 0 "${INSTALL_DIR}"
215229

230+
# Delete entrypoint scripts (and their symlinks) that don't work with relocated Python since they
231+
# hardcode the Python install directory in their shebangs (e.g. `#!/tmp/python/bin/python3.NN`).
232+
# These scripts are rarely used in production, and can still be accessed via their Python module
233+
# (e.g. `python -m pydoc`) if needed.
234+
rm "${INSTALL_DIR}"/bin/{idle,pydoc}*
235+
# The 2to3 module and entrypoint was removed from the stdlib in Python 3.13.
236+
if [[ "${PYTHON_MAJOR_VERSION}" == +(3.8|3.9|3.10|3.11|3.12) ]]; then
237+
rm "${INSTALL_DIR}"/bin/2to3*
238+
fi
239+
216240
# Support using Python 3 via the version-less `python` command, for parity with virtualenvs,
217241
# the Python Docker images and to also ensure buildpack Python shadows any installed system
218242
# Python, should that provide a version-less alias too.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py
2+
index ebec9d8f18a..385735c1e18 100644
3+
--- a/Lib/test/test_xml_etree.py
4+
+++ b/Lib/test/test_xml_etree.py
5+
@@ -1504,9 +1504,11 @@ def test_simple_xml(self, chunk_size=None, flush=False):
6+
self.assert_event_tags(parser, [('end', 'root')])
7+
self.assertIsNone(parser.close())
8+
9+
+ @unittest.skip('Work around: https://github.com/python/cpython/issues/125067')
10+
def test_simple_xml_chunk_1(self):
11+
self.test_simple_xml(chunk_size=1, flush=True)
12+
13+
+ @unittest.skip('Work around: https://github.com/python/cpython/issues/125067')
14+
def test_simple_xml_chunk_5(self):
15+
self.test_simple_xml(chunk_size=5, flush=True)
16+
17+
@@ -1731,6 +1733,7 @@ def test_flush_reparse_deferral_enabled(self):
18+
19+
self.assert_event_tags(parser, [('end', 'doc')])
20+
21+
+ @unittest.skip('Work around: https://github.com/python/cpython/issues/125067')
22+
def test_flush_reparse_deferral_disabled(self):
23+
parser = ET.XMLPullParser(events=('start', 'end'))
24+

builds/test_python_runtime.sh

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ function abort() {
1010
exit 1
1111
}
1212

13+
set -x
14+
1315
# We intentionally extract the Python runtime into a different directory to the one into which it
1416
# was originally installed before being packaged, to check that relocation works (since buildpacks
1517
# depend on it). Since the Python binary was built in shared mode, `LD_LIBRARY_PATH` must be set
@@ -25,10 +27,26 @@ tar --zstd --extract --verbose --file "${ARCHIVE_FILEPATH}" --directory "${INSTA
2527
"${INSTALL_DIR}/bin/python3" --version
2628
"${INSTALL_DIR}/bin/python" --version
2729

30+
# Check the Python config script still exists/works after the deletion of scripts with broken shebang lines.
31+
"${INSTALL_DIR}/bin/python3-config" --help
32+
33+
set +x
34+
35+
# Check that the broken bin entrypoints and symlinks (such as `idle3` and `pydoc3`) were deleted.
36+
UNEXPECTED_BIN_FILES="$(find "${INSTALL_DIR}/bin" -type 'f,l' -not -name 'python*')"
37+
if [[ -n "${UNEXPECTED_BIN_FILES}" ]]; then
38+
echo "${UNEXPECTED_BIN_FILES}"
39+
abort "The above files were found in the bin/ directory but were not expected!"
40+
else
41+
echo "No unexpected files found in the bin/ directory."
42+
fi
43+
2844
# Check that all dynamically linked libraries exist in the run image (since it has fewer packages than the build image).
2945
LDD_OUTPUT=$(find "${INSTALL_DIR}" -type f,l \( -name 'python3' -o -name '*.so*' \) -exec ldd '{}' +)
3046
if grep 'not found' <<<"${LDD_OUTPUT}" | sort --unique; then
3147
abort "The above dynamically linked libraries were not found!"
48+
else
49+
echo "All dynamically linked libraries were found."
3250
fi
3351

3452
# Check that optional and/or system library dependent stdlib modules were built.
@@ -47,9 +65,11 @@ optional_stdlib_modules=(
4765
xml.parsers.expat
4866
zlib
4967
)
50-
if ! "${INSTALL_DIR}/bin/python3" -c "import $(
68+
if "${INSTALL_DIR}/bin/python3" -c "import $(
5169
IFS=,
5270
echo "${optional_stdlib_modules[*]}"
5371
)"; then
72+
echo "Successful imported: ${optional_stdlib_modules[*]}"
73+
else
5474
abort "The above optional stdlib module failed to import! Check the compile logs to see if it was skipped due to missing libraries/headers."
5575
fi

lib/pip.sh

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,41 @@ function pip::install_pip_setuptools_wheel() {
88
# We use the pip wheel bundled within Python's standard library to install our chosen
99
# pip version, since it's faster than `ensurepip` followed by an upgrade in place.
1010
local bundled_pip_module_path="${1}"
11+
local python_major_version="${2}"
1112

1213
# TODO: Either make these `local` or move elsewhere as part of the cache invalidation refactoring.
1314
PIP_VERSION=$(get_requirement_version 'pip')
14-
SETUPTOOLS_VERSION=$(get_requirement_version 'setuptools')
15-
WHEEL_VERSION=$(get_requirement_version 'wheel')
1615
meta_set "pip_version" "${PIP_VERSION}"
17-
meta_set "setuptools_version" "${SETUPTOOLS_VERSION}"
18-
meta_set "wheel_version" "${WHEEL_VERSION}"
1916

20-
puts-step "Installing pip ${PIP_VERSION}, setuptools ${SETUPTOOLS_VERSION} and wheel ${WHEEL_VERSION}"
17+
local packages_to_install=(
18+
"pip==${PIP_VERSION}"
19+
)
20+
local packages_display_text="pip ${PIP_VERSION}"
21+
22+
# We only install setuptools and wheel on Python 3.12 and older, since:
23+
# - If either is not installed, pip will automatically install them into an isolated build
24+
# environment if needed when installing packages from an sdist. This means that for
25+
# all packages that correctly declare their metadata, it's no longer necessary to have
26+
# them installed.
27+
# - Most of the Python ecosystem has stopped installing them for Python 3.12+ already.
28+
# See the Python CNB's removal for more details: https://github.com/heroku/buildpacks-python/pull/243
29+
if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) ]]; then
30+
SETUPTOOLS_VERSION=$(get_requirement_version 'setuptools')
31+
WHEEL_VERSION=$(get_requirement_version 'wheel')
32+
meta_set "setuptools_version" "${SETUPTOOLS_VERSION}"
33+
meta_set "wheel_version" "${WHEEL_VERSION}"
34+
35+
packages_to_install+=(
36+
"setuptools==${SETUPTOOLS_VERSION}"
37+
"wheel==${WHEEL_VERSION}"
38+
)
39+
packages_display_text+=", setuptools ${SETUPTOOLS_VERSION} and wheel ${WHEEL_VERSION}"
40+
fi
41+
42+
puts-step "Installing ${packages_display_text}"
2143

2244
/app/.heroku/python/bin/python "${bundled_pip_module_path}" install --quiet --disable-pip-version-check --no-cache-dir \
23-
"pip==${PIP_VERSION}" "setuptools==${SETUPTOOLS_VERSION}" "wheel==${WHEEL_VERSION}"
45+
"${packages_to_install[@]}"
2446
}
2547

2648
function pip::install_dependencies() {

lib/python_version.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ LATEST_PYTHON_3_9="3.9.20"
99
LATEST_PYTHON_3_10="3.10.15"
1010
LATEST_PYTHON_3_11="3.11.10"
1111
LATEST_PYTHON_3_12="3.12.7"
12+
LATEST_PYTHON_3_13="3.13.0"
1213

1314
DEFAULT_PYTHON_FULL_VERSION="${LATEST_PYTHON_3_12}"
1415
DEFAULT_PYTHON_MAJOR_VERSION="${DEFAULT_PYTHON_FULL_VERSION%.*}"
@@ -233,7 +234,7 @@ function python_version::resolve_python_version() {
233234
return 1
234235
fi
235236

236-
if (((major == 3 && minor > 12) || major >= 4)); then
237+
if (((major == 3 && minor > 13) || major >= 4)); then
237238
if [[ "${python_version_origin}" == "cached" ]]; then
238239
display_error <<-EOF
239240
Error: The cached Python version is not recognised.
@@ -281,6 +282,7 @@ function python_version::resolve_python_version() {
281282
3.10) echo "${LATEST_PYTHON_3_10}" ;;
282283
3.11) echo "${LATEST_PYTHON_3_11}" ;;
283284
3.12) echo "${LATEST_PYTHON_3_12}" ;;
285+
3.13) echo "${LATEST_PYTHON_3_13}" ;;
284286
*) utils::abort_internal_error "Unhandled Python major version: ${requested_python_version}" ;;
285287
esac
286288
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[[source]]
2+
url = "https://pypi.org/simple"
3+
verify_ssl = true
4+
name = "pypi"
5+
6+
[packages]
7+
urllib3 = "*"
8+
9+
[dev-packages]
10+
11+
[requires]
12+
python_version = "3.13"

spec/fixtures/pipenv_python_3.13/Pipfile.lock

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
urllib3

spec/fixtures/python_3.13/runtime.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
python-3.13.0

spec/hatchet/pipenv_spec.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,24 @@
208208
include_examples 'builds using Pipenv with the requested Python version', '3.12', LATEST_PYTHON_3_12
209209
end
210210

211+
context 'with a Pipfile.lock containing python_version 3.13' do
212+
let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.13') }
213+
214+
it 'builds with latest Python 3.13' do
215+
app.deploy do |app|
216+
expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX))
217+
remote: -----> Python app detected
218+
remote: -----> Using Python 3.13 specified in Pipfile.lock
219+
remote: -----> Installing Python #{LATEST_PYTHON_3_13}
220+
remote: -----> Installing pip #{PIP_VERSION}
221+
remote: -----> Installing Pipenv #{PIPENV_VERSION}
222+
remote: -----> Installing dependencies with Pipenv
223+
remote: Installing dependencies from Pipfile.lock \\(.+\\)...
224+
REGEX
225+
end
226+
end
227+
end
228+
211229
# As well as testing `python_full_version`, this also tests:
212230
# 1. That `python_full_version` takes precedence over `python_version`.
213231
# 2. That Pipenv works on the oldest Python version supported by all stacks.

0 commit comments

Comments
 (0)