Skip to content

Commit 0c36ffe

Browse files
committed
Add initial support for Poetry
The Python package manager Poetry is now supported for installing app dependencies: 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`). Apps must only have one package manager file (either `requirements.txt` or `poetry.lock`, but not both) otherwise the buildpack will abort the build with an error (which will help prevent some of the types of support tickets we see in the classic buildpack with users unknowingly mixing and matching pip + Pipenv). Poetry is installed into a build-only layer (to reduce the final app image size), so is not available at run-time. The app dependencies are installed into a virtual environment (the same as for pip after #257, for the reasons described in #253), which is on `PATH` so does not need explicit activation when using the app image. As such, 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 (possible thanks to #258), 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). Both the Poetry and app dependencies layers are cached, however, the Poetry download/wheel cache is not cached, since using it is slower than caching the dependencies layer (for more details see the comments on `poetry_dependencies::install_dependencies`). 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]`). I've marked this `semver: major` since in the (probably unlikely) event there are any early-adopter projects using this CNB that have both a `requirements.txt` and `poetry.lock` then this change will cause them to error (until one of the files is deleted). 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 Work that will be handled later: - Support for selecting Python version via `tool.poetry.dependencies.python`: #260 - Build output and error messages polish/CX review (this will be performed when switching the buildpack to the new logging style). - More detailed user-facing docs: #11 Closes #7. GUS-W-9607867. GUS-W-9608286. GUS-W-9608295.
1 parent 3bd0fbe commit 0c36ffe

26 files changed

+963
-70
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added initial support for the Poetry package manager. ([#261](https://github.com/heroku/buildpacks-python/pull/261))
13+
1014
## [0.16.0] - 2024-08-30
1115

1216
### Changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,25 @@ docker run --rm -it -e "PORT=8080" -p 8080:8080 sample-app
3131

3232
## Application Requirements
3333

34-
A `requirements.txt` file must be present at the root of your application's repository.
34+
A `requirements.txt` or `poetry.lock` file must be present in the root (top-level) directory of your app's source code.
3535

3636
## Configuration
3737

3838
### Python Version
3939

4040
By default, the buildpack will install the latest version of Python 3.12.
4141

42-
To install a different version, add a `runtime.txt` file to your apps root directory that declares the exact version number to use:
42+
To install a different version, add a `runtime.txt` file to your app's root directory that declares the exact version number to use:
4343

4444
```term
4545
$ cat runtime.txt
4646
python-3.12.5
4747
```
4848

49-
In the future this buildpack will also support specifying the Python version via a `.python-version` file (see [#6](https://github.com/heroku/buildpacks-python/issues/6)).
49+
In the future this buildpack will also support specifying the Python version using:
50+
51+
- A `.python-version` file: [#6](https://github.com/heroku/buildpacks-python/issues/6)
52+
- `tool.poetry.dependencies.python` in `pyproject.toml`: [#260](https://github.com/heroku/buildpacks-python/issues/260)
5053

5154
## Contributing
5255

requirements/poetry.txt

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

src/detect.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,29 +41,29 @@ pub(crate) fn is_python_project_directory(app_dir: &Path) -> io::Result<bool> {
4141
#[cfg(test)]
4242
mod tests {
4343
use super::*;
44-
use crate::package_manager::PACKAGE_MANAGER_FILE_MAPPING;
44+
use crate::package_manager::SUPPORTED_PACKAGE_MANAGERS;
4545

4646
#[test]
47-
fn is_python_project_valid_project() {
47+
fn is_python_project_directory_valid_project() {
4848
assert!(
4949
is_python_project_directory(Path::new("tests/fixtures/pyproject_toml_only")).unwrap()
5050
);
5151
}
5252

5353
#[test]
54-
fn is_python_project_empty() {
54+
fn is_python_project_directory_empty() {
5555
assert!(!is_python_project_directory(Path::new("tests/fixtures/empty")).unwrap());
5656
}
5757

5858
#[test]
59-
fn is_python_project_io_error() {
59+
fn is_python_project_directory_io_error() {
6060
assert!(is_python_project_directory(Path::new("tests/fixtures/empty/.gitkeep")).is_err());
6161
}
6262

6363
#[test]
6464
fn known_python_project_files_contains_all_package_manager_files() {
65-
assert!(PACKAGE_MANAGER_FILE_MAPPING
66-
.iter()
67-
.all(|(filename, _)| { KNOWN_PYTHON_PROJECT_FILES.contains(filename) }));
65+
assert!(SUPPORTED_PACKAGE_MANAGERS.iter().all(|package_manager| {
66+
KNOWN_PYTHON_PROJECT_FILES.contains(&package_manager.packages_file())
67+
}));
6868
}
6969
}

src/errors.rs

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::django::DjangoCollectstaticError;
22
use crate::layers::pip::PipLayerError;
33
use crate::layers::pip_dependencies::PipDependenciesLayerError;
4+
use crate::layers::poetry::PoetryLayerError;
5+
use crate::layers::poetry_dependencies::PoetryDependenciesLayerError;
46
use crate::layers::python::PythonLayerError;
57
use crate::package_manager::DeterminePackageManagerError;
68
use crate::python_version::{PythonVersion, PythonVersionError, DEFAULT_PYTHON_VERSION};
@@ -48,6 +50,8 @@ fn on_buildpack_error(error: BuildpackError) {
4850
BuildpackError::DjangoDetection(error) => on_django_detection_error(&error),
4951
BuildpackError::PipDependenciesLayer(error) => on_pip_dependencies_layer_error(error),
5052
BuildpackError::PipLayer(error) => on_pip_layer_error(error),
53+
BuildpackError::PoetryDependenciesLayer(error) => on_poetry_dependencies_layer_error(error),
54+
BuildpackError::PoetryLayer(error) => on_poetry_layer_error(error),
5155
BuildpackError::PythonLayer(error) => on_python_layer_error(error),
5256
BuildpackError::PythonVersion(error) => on_python_version_error(error),
5357
};
@@ -68,18 +72,46 @@ fn on_determine_package_manager_error(error: DeterminePackageManagerError) {
6872
"determining which Python package manager to use for this project",
6973
&io_error,
7074
),
71-
// TODO: Should this mention the setup.py / pyproject.toml case?
75+
DeterminePackageManagerError::MultipleFound(package_managers) => {
76+
let files_found = package_managers
77+
.into_iter()
78+
.map(|package_manager| {
79+
format!(
80+
"{} ({})",
81+
package_manager.packages_file(),
82+
package_manager.name()
83+
)
84+
})
85+
.collect::<Vec<String>>()
86+
.join("\n");
87+
log_error(
88+
"Multiple Python package manager files were found",
89+
formatdoc! {"
90+
Exactly one package manager file must be present in your app's source code,
91+
however, several were found:
92+
93+
{files_found}
94+
95+
Decide which package manager you want to use with your app, and then delete
96+
the file(s) and any config from the others.
97+
"},
98+
);
99+
}
72100
DeterminePackageManagerError::NoneFound => log_error(
73-
"No Python package manager files were found",
101+
"Couldn't find any supported Python package manager files",
74102
indoc! {"
75-
A pip requirements file was not found in your application's source code.
76-
This file is required so that your application's dependencies can be installed.
103+
Your app must have either a pip requirements file ('requirements.txt')
104+
or Poetry lockfile ('poetry.lock') in the root directory of its source
105+
code, so your app's dependencies can be installed.
77106
78-
Please add a file named exactly 'requirements.txt' to the root directory of your
79-
application, containing a list of the packages required by your application.
107+
If your app already has one of those files, check that it:
80108
81-
For more information on what this file should contain, see:
82-
https://pip.pypa.io/en/stable/reference/requirements-file-format/
109+
1. Is in the top level directory (not a subdirectory).
110+
2. Has the correct spelling (the filenames are case-sensitive).
111+
3. Isn't excluded by '.gitignore' or 'project.toml'.
112+
113+
Otherwise, add a package manager file to your app. If your app has
114+
no dependencies, then create an empty 'requirements.txt' file.
83115
"},
84116
),
85117
};
@@ -235,6 +267,76 @@ fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) {
235267
};
236268
}
237269

270+
fn on_poetry_layer_error(error: PoetryLayerError) {
271+
match error {
272+
PoetryLayerError::InstallPoetryCommand(error) => match error {
273+
StreamedCommandError::Io(io_error) => log_io_error(
274+
"Unable to install Poetry",
275+
"running 'python' to install Poetry",
276+
&io_error,
277+
),
278+
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
279+
"Unable to install Poetry",
280+
formatdoc! {"
281+
The command to install Poetry did not exit successfully ({exit_status}).
282+
283+
See the log output above for more information.
284+
285+
In some cases, this happens due to an unstable network connection.
286+
Please try again to see if the error resolves itself.
287+
288+
If that does not help, check the status of PyPI (the upstream Python
289+
package repository service), here:
290+
https://status.python.org
291+
"},
292+
),
293+
},
294+
PoetryLayerError::LocateBundledPip(io_error) => log_io_error(
295+
"Unable to locate the bundled copy of pip",
296+
"locating the pip wheel file bundled inside the Python 'ensurepip' module",
297+
&io_error,
298+
),
299+
};
300+
}
301+
302+
fn on_poetry_dependencies_layer_error(error: PoetryDependenciesLayerError) {
303+
match error {
304+
PoetryDependenciesLayerError::CreateVenvCommand(error) => match error {
305+
StreamedCommandError::Io(io_error) => log_io_error(
306+
"Unable to create virtual environment",
307+
"running 'python -m venv' to create a virtual environment",
308+
&io_error,
309+
),
310+
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
311+
"Unable to create virtual environment",
312+
formatdoc! {"
313+
The 'python -m venv' command to create a virtual environment did
314+
not exit successfully ({exit_status}).
315+
316+
See the log output above for more information.
317+
"},
318+
),
319+
},
320+
PoetryDependenciesLayerError::PoetryInstallCommand(error) => match error {
321+
StreamedCommandError::Io(io_error) => log_io_error(
322+
"Unable to install dependencies using Poetry",
323+
"running 'poetry install' to install the app's dependencies",
324+
&io_error,
325+
),
326+
// TODO: Add more suggestions here as to possible causes (similar to pip)
327+
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
328+
"Unable to install dependencies using Poetry",
329+
formatdoc! {"
330+
The 'poetry install --sync --only main' command to install the app's
331+
dependencies failed ({exit_status}).
332+
333+
See the log output above for more information.
334+
"},
335+
),
336+
},
337+
};
338+
}
339+
238340
fn on_django_detection_error(error: &io::Error) {
239341
log_io_error(
240342
"Unable to determine if this is a Django-based app",

src/layers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
pub(crate) mod pip;
22
pub(crate) mod pip_cache;
33
pub(crate) mod pip_dependencies;
4+
pub(crate) mod poetry;
5+
pub(crate) mod poetry_dependencies;
46
pub(crate) mod python;

src/layers/poetry.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
use crate::packaging_tool_versions::POETRY_VERSION;
2+
use crate::python_version::PythonVersion;
3+
use crate::utils::StreamedCommandError;
4+
use crate::{utils, BuildpackError, PythonBuildpack};
5+
use libcnb::build::BuildContext;
6+
use libcnb::data::layer_name;
7+
use libcnb::layer::{
8+
CachedLayerDefinition, EmptyLayerCause, InvalidMetadataAction, LayerState, RestoredLayerAction,
9+
};
10+
use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope};
11+
use libcnb::Env;
12+
use libherokubuildpack::log::log_info;
13+
use serde::{Deserialize, Serialize};
14+
use std::io;
15+
use std::path::Path;
16+
use std::process::Command;
17+
18+
/// Creates a build-only layer containing Poetry.
19+
pub(crate) fn install_poetry(
20+
context: &BuildContext<PythonBuildpack>,
21+
env: &mut Env,
22+
python_version: &PythonVersion,
23+
python_layer_path: &Path,
24+
) -> Result<(), libcnb::Error<BuildpackError>> {
25+
let new_metadata = PoetryLayerMetadata {
26+
arch: context.target.arch.clone(),
27+
distro_name: context.target.distro_name.clone(),
28+
distro_version: context.target.distro_version.clone(),
29+
python_version: python_version.to_string(),
30+
poetry_version: POETRY_VERSION.to_string(),
31+
};
32+
33+
let layer = context.cached_layer(
34+
layer_name!("poetry"),
35+
CachedLayerDefinition {
36+
build: true,
37+
launch: false,
38+
invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer,
39+
restored_layer_action: &|cached_metadata: &PoetryLayerMetadata, _| {
40+
let cached_poetry_version = cached_metadata.poetry_version.clone();
41+
if cached_metadata == &new_metadata {
42+
(RestoredLayerAction::KeepLayer, cached_poetry_version)
43+
} else {
44+
(RestoredLayerAction::DeleteLayer, cached_poetry_version)
45+
}
46+
},
47+
},
48+
)?;
49+
50+
// Move the Python user base directory to this layer instead of under HOME:
51+
// https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUSERBASE
52+
let mut layer_env = LayerEnv::new().chainable_insert(
53+
Scope::Build,
54+
ModificationBehavior::Override,
55+
"PYTHONUSERBASE",
56+
layer.path(),
57+
);
58+
59+
match layer.state {
60+
LayerState::Restored {
61+
cause: ref cached_poetry_version,
62+
} => {
63+
log_info(format!("Using cached Poetry {cached_poetry_version}"));
64+
}
65+
LayerState::Empty { ref cause } => {
66+
match cause {
67+
EmptyLayerCause::InvalidMetadataAction { .. } => {
68+
log_info("Discarding cached Poetry since its layer metadata can't be parsed");
69+
}
70+
EmptyLayerCause::RestoredLayerAction {
71+
cause: cached_poetry_version,
72+
} => {
73+
log_info(format!("Discarding cached Poetry {cached_poetry_version}"));
74+
}
75+
EmptyLayerCause::NewlyCreated => {}
76+
}
77+
78+
log_info(format!("Installing Poetry {POETRY_VERSION}"));
79+
80+
// We use the pip wheel bundled within Python's standard library to install Poetry.
81+
// Whilst Poetry does still require pip for some tasks (such as package uninstalls),
82+
// it bundles its own copy for use as a fallback. As such we don't need to install pip
83+
// into the user site-packages (and in fact, Poetry wouldn't use this install anyway,
84+
// since it only finds an external pip if it exists in the target venv).
85+
let bundled_pip_module_path =
86+
utils::bundled_pip_module_path(python_layer_path, python_version)
87+
.map_err(PoetryLayerError::LocateBundledPip)?;
88+
89+
utils::run_command_and_stream_output(
90+
Command::new("python")
91+
.args([
92+
&bundled_pip_module_path.to_string_lossy(),
93+
"install",
94+
// There is no point using pip's cache here, since the layer itself will be cached.
95+
"--no-cache-dir",
96+
"--no-input",
97+
"--no-warn-script-location",
98+
"--quiet",
99+
"--user",
100+
format!("poetry=={POETRY_VERSION}").as_str(),
101+
])
102+
.env_clear()
103+
.envs(&layer_env.apply(Scope::Build, env)),
104+
)
105+
.map_err(PoetryLayerError::InstallPoetryCommand)?;
106+
107+
layer.write_metadata(new_metadata)?;
108+
}
109+
}
110+
111+
layer.write_env(&layer_env)?;
112+
// Required to pick up the automatic PATH env var. See: https://github.com/heroku/libcnb.rs/issues/842
113+
layer_env = layer.read_env()?;
114+
env.clone_from(&layer_env.apply(Scope::Build, env));
115+
116+
Ok(())
117+
}
118+
119+
// Some of Poetry's dependencies contain compiled components so are platform-specific (unlike pure
120+
// Python packages). As such we have to take arch and distro into account for cache invalidation.
121+
#[derive(Deserialize, PartialEq, Serialize)]
122+
#[serde(deny_unknown_fields)]
123+
struct PoetryLayerMetadata {
124+
arch: String,
125+
distro_name: String,
126+
distro_version: String,
127+
python_version: String,
128+
poetry_version: String,
129+
}
130+
131+
/// Errors that can occur when installing Poetry into a layer.
132+
#[derive(Debug)]
133+
pub(crate) enum PoetryLayerError {
134+
InstallPoetryCommand(StreamedCommandError),
135+
LocateBundledPip(io::Error),
136+
}
137+
138+
impl From<PoetryLayerError> for libcnb::Error<BuildpackError> {
139+
fn from(error: PoetryLayerError) -> Self {
140+
Self::BuildpackError(BuildpackError::PoetryLayer(error))
141+
}
142+
}

0 commit comments

Comments
 (0)