Skip to content

Commit 895a004

Browse files
authored
Add support for Poetry (#1682)
After many refactoring/preparation PRs, we're now ready to add support for the package manager Poetry: https://python-poetry.org To use Poetry, apps must have a `poetry.lock` lockfile, which can be created by running `poetry lock` locally, after adding Poetry config to `pyproject.toml` (which can be done either manually or by using `poetry init`). For now, if a `requirements.txt` or `Pipfile` is found it will take precedence over `poetry.lock` for backwards compatibility (in the future this will become a warning then an error). This means users of the third-party `python-poetry-buildpack` will need to remove that buildpack in order to use the new native Poetry support, since it exports a `requirements.txt` file during the build. Poetry is installed into the build cache rather than the slug, so is not available at run-time (since it's not typically needed at run-time and doing so reduces the slug size). The entrypoints of installed dependencies are available on `PATH`, so use of `poetry run` or `poetry shell` is not required at run-time to use dependencies in the environment. When using Poetry, pip is not installed since Poetry includes its own internal vendored copy that it will use instead (for the small number of Poetry operations for which it still calls out to pip, such as package uninstalls). During normal (non-CI) builds, the `poetry install --sync` command is run using `--only main` so as to only install the main `[tool.poetry.dependencies]` dependencies group from `pyproject.toml` and not any of the app's other dependency groups (such as test/dev/... groups, eg `[tool.poetry.group.test.dependencies]`). On Heroku CI, all default Poetry dependency groups are installed (i.e. all groups minus those marked as `optional = true`). Relevant Poetry docs: - https://python-poetry.org/docs/cli/#install - https://python-poetry.org/docs/configuration/ - https://python-poetry.org/docs/managing-dependencies/#dependency-groups See also the Python CNB equivalent of this PR: - heroku/buildpacks-python#261 Note: We don't support controlling the Python version via Poetry's `tool.poetry.dependencies.python` field, since that field typically contains a version range, which is not safe to use. Use the newly added `.python-version` file support instead. For more on this, see the longer explanation over in the Python CNB repo: heroku/buildpacks-python#260 Closes #796. Closes #835. GUS-W-16810914.
1 parent 6dda58a commit 895a004

File tree

48 files changed

+954
-22
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+954
-22
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+
- Added support for the package manager Poetry. Apps must have a `pyproject.toml` + `poetry.lock` and no other package manager files (otherwise pip/Pipenv will take precedence for backwards compatibility). ([#1682](https://github.com/heroku/heroku-buildpack-python/pull/1682))
56

67
## [v263] - 2024-10-31
78

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the [Getting Started on Heroku with Python](https://devcenter.heroku.com/art
1414

1515
## Application Requirements
1616

17-
A `requirements.txt` or `Pipfile` file must be present in the root (top-level) directory of your app's source code.
17+
A `requirements.txt`, `Pipfile` or `poetry.lock` file must be present in the root (top-level) directory of your app's source code.
1818

1919
## Configuration
2020

bin/compile

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ source "${BUILDPACK_DIR}/lib/package_manager.sh"
2828
source "${BUILDPACK_DIR}/lib/pip.sh"
2929
source "${BUILDPACK_DIR}/lib/pipenv.sh"
3030
source "${BUILDPACK_DIR}/lib/python_version.sh"
31+
source "${BUILDPACK_DIR}/lib/poetry.sh"
3132

3233
compile_start_time=$(nowms)
3334

@@ -166,6 +167,9 @@ case "${package_manager}" in
166167
pip::install_pip_setuptools_wheel "${python_home}" "${python_major_version}"
167168
pipenv::install_pipenv
168169
;;
170+
poetry)
171+
poetry::install_poetry "${python_home}" "${CACHE_DIR}" "${EXPORT_PATH}"
172+
;;
169173
*)
170174
utils::abort_internal_error "Unhandled package manager: ${package_manager}"
171175
;;
@@ -175,8 +179,8 @@ meta_time "package_manager_install_duration" "${package_manager_install_start_ti
175179
# SQLite3 support.
176180
# Installs the sqlite3 dev headers and sqlite3 binary but not the
177181
# libsqlite3-0 library since that exists in the base image.
178-
# We skip this step on Python 3.13, as a first step towards removing this feature.
179-
if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) ]]; then
182+
# We skip this step on Python 3.13 or when using Poetry, as a first step towards removing this feature.
183+
if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) && "${package_manager}" != "poetry" ]]; then
180184
install_sqlite_start_time=$(nowms)
181185
source "${BUILDPACK_DIR}/bin/steps/sqlite3"
182186
buildpack_sqlite3_install
@@ -192,6 +196,9 @@ case "${package_manager}" in
192196
pipenv)
193197
pipenv::install_dependencies
194198
;;
199+
poetry)
200+
poetry::install_dependencies
201+
;;
195202
*)
196203
utils::abort_internal_error "Unhandled package manager: ${package_manager}"
197204
;;

bin/detect

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ output::error <<EOF
4949
Error: Your app is configured to use the Python buildpack,
5050
but we couldn't find any supported Python project files.
5151
52-
A Python app on Heroku must have either a 'requirements.txt' or
53-
'Pipfile' package manager file in the root directory of its
54-
source code.
52+
A Python app on Heroku must have either a 'requirements.txt',
53+
'Pipfile' or 'poetry.lock' package manager file in the root
54+
directory of its source code.
5555
5656
Currently the root directory of your app contains:
5757

bin/report

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ STRING_FIELDS=(
6868
package_manager_multiple_found
6969
pip_version
7070
pipenv_version
71+
poetry_version
7172
python_version_major
7273
python_version_reason
7374
python_version

bin/steps/collectstatic

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# automatically be executed as part of the build process. If collectstatic
66
# fails, your build fails.
77

8-
# This functionality will only activate if Django is in requirements.txt.
8+
# This functionality will only activate if Django is installed.
99

1010
# Runtime arguments:
1111
# - $DISABLE_COLLECTSTATIC: disables this functionality.

lib/cache.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ function cache::restore() {
104104
cache_invalidation_reasons+=("The Pipenv version has changed from ${cached_pipenv_version} to ${PIPENV_VERSION}")
105105
fi
106106
;;
107+
poetry)
108+
local cached_poetry_version
109+
cached_poetry_version="$(meta_prev_get "poetry_version")"
110+
# Poetry support was added after the metadata store, so we'll always have the version here.
111+
if [[ "${cached_poetry_version}" != "${POETRY_VERSION:?}" ]]; then
112+
cache_invalidation_reasons+=("The Poetry version has changed from ${cached_poetry_version:-"unknown"} to ${POETRY_VERSION}")
113+
fi
114+
;;
107115
*)
108116
utils::abort_internal_error "Unhandled package manager: ${package_manager}"
109117
;;
@@ -119,6 +127,7 @@ function cache::restore() {
119127

120128
rm -rf \
121129
"${cache_dir}/.heroku/python" \
130+
"${cache_dir}/.heroku/python-poetry" \
122131
"${cache_dir}/.heroku/python-stack" \
123132
"${cache_dir}/.heroku/python-version" \
124133
"${cache_dir}/.heroku/src" \

lib/package_manager.sh

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ function package_manager::determine_package_manager() {
2727
package_managers_found+=(pip)
2828
fi
2929

30+
# This must be after the requirements.txt check, so that the requirements.txt exported by
31+
# `python-poetry-buildpack` takes precedence over poetry.lock, for consistency with the
32+
# behaviour prior to this buildpack supporting Poetry natively. In the future the presence
33+
# of multiple package manager files will be turned into an error, at which point the
34+
# ordering here won't matter.
35+
if [[ -f "${build_dir}/poetry.lock" ]]; then
36+
package_managers_found+=(poetry)
37+
fi
38+
3039
# TODO: Deprecate/sunset this fallback, since using setup.py declared dependencies is
3140
# not a best practice, and we can only guess as to which package manager to use.
3241
if ((${#package_managers_found[@]} == 0)) && [[ -f "${build_dir}/setup.py" ]]; then
@@ -47,9 +56,9 @@ function package_manager::determine_package_manager() {
4756
output::error <<-EOF
4857
Error: Couldn't find any supported Python package manager files.
4958
50-
A Python app on Heroku must have either a 'requirements.txt' or
51-
'Pipfile' package manager file in the root directory of its
52-
source code.
59+
A Python app on Heroku must have either a 'requirements.txt',
60+
'Pipfile' or 'poetry.lock' package manager file in the root
61+
directory of its source code.
5362
5463
Currently the root directory of your app contains:
5564
@@ -76,8 +85,7 @@ function package_manager::determine_package_manager() {
7685
# TODO: Turn this case into an error since it results in support tickets from users
7786
# who don't realise they have multiple package manager files and think their changes
7887
# aren't taking effect. (We'll need to wait until after Poetry support has landed,
79-
# and people have had a chance to migrate from the third-party Poetry buildpack,
80-
# since using it results in both a requirements.txt and a poetry.lock.)
88+
# and people have had a chance to migrate from the Poetry buildpack mentioned above.)
8189
echo "${package_managers_found[0]}"
8290
meta_set "package_manager_multiple_found" "$(
8391
IFS=,

lib/poetry.sh

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
POETRY_VERSION=$(utils::get_requirement_version 'poetry')
8+
9+
function poetry::install_poetry() {
10+
local python_home="${1}"
11+
local cache_dir="${2}"
12+
local export_file="${3}"
13+
14+
# We store Poetry in the build cache, since we only need it during the build.
15+
local poetry_root="${cache_dir}/.heroku/python-poetry"
16+
17+
# We nest the venv and then symlink the `poetry` script to prevent the rest of `venv/bin/`
18+
# (such as entrypoint scripts from Poetry's dependencies, or the venv's activation scripts)
19+
# from being added to PATH and exposed to the app.
20+
local poetry_bin_dir="${poetry_root}/bin"
21+
local poetry_venv_dir="${poetry_root}/venv"
22+
23+
meta_set "poetry_version" "${POETRY_VERSION}"
24+
25+
# The earlier buildpack cache invalidation step will have already handled the case where the
26+
# Poetry version has changed, so here we only need to check that a valid Poetry install exists.
27+
# venvs are not relocatable, so if the cache directory were ever to change location, the cached
28+
# Poetry installation would stop working. To save having to track the cache location via build
29+
# metadata, we instead rely on the fact that relocating the venv would also break the absolute
30+
# path `poetry` symlink created below, and that the `-e` condition not only checks that the
31+
# `poetry` symlink exists, but that its target is also valid.
32+
# Note: Whilst the Codon cache location remains stable from build to build, for Heroku CI the
33+
# cache directory currently does not, so the cached Poetry will always be invalidated there.
34+
if [[ -e "${poetry_bin_dir}/poetry" ]]; then
35+
output::step "Using cached Poetry ${POETRY_VERSION}"
36+
else
37+
output::step "Installing Poetry ${POETRY_VERSION}"
38+
39+
# The Poetry directory will already exist in the relocated cache case mentioned above.
40+
rm -rf "${poetry_root}"
41+
42+
python -m venv --without-pip "${poetry_venv_dir}"
43+
44+
# We use the pip wheel bundled within Python's standard library to install Poetry.
45+
# Whilst Poetry does still require pip for some tasks (such as package uninstalls),
46+
# it bundles its own copy for use as a fallback. As such we don't need to install pip
47+
# into the Poetry venv (and in fact, Poetry wouldn't use this install anyway, since
48+
# it only finds an external pip if it exists in the target venv).
49+
local bundled_pip_module_path
50+
bundled_pip_module_path="$(utils::bundled_pip_module_path "${python_home}")"
51+
52+
if ! {
53+
python "${bundled_pip_module_path}" \
54+
--python "${poetry_venv_dir}" \
55+
install \
56+
--disable-pip-version-check \
57+
--no-cache-dir \
58+
--no-input \
59+
--quiet \
60+
"poetry==${POETRY_VERSION}"
61+
}; then
62+
output::error <<-EOF
63+
Error: Unable to install Poetry.
64+
65+
Try building again to see if the error resolves itself.
66+
67+
If that does not help, check the status of PyPI (the Python
68+
package repository service), here:
69+
https://status.python.org
70+
EOF
71+
meta_set "failure_reason" "install-poetry"
72+
return 1
73+
fi
74+
75+
mkdir -p "${poetry_bin_dir}"
76+
# NB: This symlink must not use `--relative`, since we want the symlink to break if the cache
77+
# (and thus venv) were ever relocated - so that it triggers a reinstall (see above).
78+
ln --symbolic --no-target-directory "${poetry_venv_dir}/bin/poetry" "${poetry_bin_dir}/poetry"
79+
fi
80+
81+
export PATH="${poetry_bin_dir}:${PATH}"
82+
echo "export PATH=\"${poetry_bin_dir}:\${PATH}\"" >>"${export_file}"
83+
# Force Poetry to manage the system Python site-packages instead of using venvs.
84+
export POETRY_VIRTUALENVS_CREATE="false"
85+
echo 'export POETRY_VIRTUALENVS_CREATE="false"' >>"${export_file}"
86+
}
87+
88+
# Note: We cache site-packages since:
89+
# - It results in faster builds than only caching Poetry's download/wheel cache.
90+
# - It's safe to do so, since `poetry install --sync` fully manages the environment
91+
# (including e.g. uninstalling packages when they are removed from the lockfile).
92+
#
93+
# With site-packages cached there is no need to persist Poetry's download/wheel cache in the build
94+
# cache, so we let Poetry write it to the home directory where it will be discarded at the end of
95+
# the build. We don't use `--no-cache` since the cache still offers benefits (such as avoiding
96+
# repeat downloads of PEP-517/518 build requirements).
97+
function poetry::install_dependencies() {
98+
local poetry_install_command=(
99+
poetry
100+
install
101+
--sync
102+
)
103+
104+
# On Heroku CI, all default Poetry dependency groups are installed (i.e. all groups minus those
105+
# marked as `optional = true`). Otherwise, only the 'main' Poetry dependency group is installed.
106+
if [[ ! -v INSTALL_TEST ]]; then
107+
poetry_install_command+=(--only main)
108+
fi
109+
110+
# We only display the most relevant command args here, to improve the signal to noise ratio.
111+
output::step "Installing dependencies using '${poetry_install_command[*]}'"
112+
113+
# `--compile`: Compiles Python bytecode, to improve app boot times (pip does this by default).
114+
# `--no-ansi`: Whilst we'd prefer to enable colour if possible, Poetry also emits ANSI escape
115+
# codes for redrawing lines, which renders badly in persisted build logs.
116+
# shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled.
117+
if ! {
118+
"${poetry_install_command[@]}" --compile --no-ansi --no-interaction \
119+
|& tee "${WARNINGS_LOG:?}" \
120+
|& grep --invert-match 'Skipping virtualenv creation' \
121+
|& output::indent
122+
}; then
123+
show-warnings
124+
125+
output::error <<-EOF
126+
Error: Unable to install dependencies using Poetry.
127+
128+
See the log output above for more information.
129+
EOF
130+
meta_set "failure_reason" "install-dependencies::poetry"
131+
return 1
132+
fi
133+
}

requirements/poetry.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
poetry==1.8.4

0 commit comments

Comments
 (0)