From 0c36ffe21b16a5e081e8bd6abf0cd90c98340324 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:52:26 +0100 Subject: [PATCH] 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`: https://github.com/heroku/buildpacks-python/issues/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: https://github.com/heroku/buildpacks-python/issues/11 Closes #7. GUS-W-9607867. GUS-W-9608286. GUS-W-9608295. --- CHANGELOG.md | 4 + README.md | 9 +- requirements/poetry.txt | 1 + src/detect.rs | 14 +- src/errors.rs | 118 ++++++++- src/layers/mod.rs | 2 + src/layers/poetry.rs | 142 ++++++++++ src/layers/poetry_dependencies.rs | 152 +++++++++++ src/main.rs | 17 +- src/package_manager.rs | 84 ++++-- src/packaging_tool_versions.rs | 2 + tests/detect_test.rs | 2 +- tests/fixtures/pip_and_poetry/poetry.lock | 7 + tests/fixtures/pip_and_poetry/pyproject.toml | 2 + .../fixtures/pip_and_poetry/requirements.txt | 0 tests/fixtures/poetry_basic/poetry.lock | 85 ++++++ tests/fixtures/poetry_basic/pyproject.toml | 10 + .../poetry_editable_git_compiled/poetry.lock | 22 ++ .../pyproject.toml | 17 ++ .../poetry_outdated_lockfile/poetry.lock | 7 + .../poetry_outdated_lockfile/pyproject.toml | 8 + tests/fixtures/testing_buildpack/bin/build | 13 +- tests/mod.rs | 1 + tests/package_manager_test.rs | 43 ++- tests/pip_test.rs | 25 +- tests/poetry_test.rs | 246 ++++++++++++++++++ 26 files changed, 963 insertions(+), 70 deletions(-) create mode 100644 requirements/poetry.txt create mode 100644 src/layers/poetry.rs create mode 100644 src/layers/poetry_dependencies.rs create mode 100644 tests/fixtures/pip_and_poetry/poetry.lock create mode 100644 tests/fixtures/pip_and_poetry/pyproject.toml create mode 100644 tests/fixtures/pip_and_poetry/requirements.txt create mode 100644 tests/fixtures/poetry_basic/poetry.lock create mode 100644 tests/fixtures/poetry_basic/pyproject.toml create mode 100644 tests/fixtures/poetry_editable_git_compiled/poetry.lock create mode 100644 tests/fixtures/poetry_editable_git_compiled/pyproject.toml create mode 100644 tests/fixtures/poetry_outdated_lockfile/poetry.lock create mode 100644 tests/fixtures/poetry_outdated_lockfile/pyproject.toml create mode 100644 tests/poetry_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a56a0..da9eaf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added initial support for the Poetry package manager. ([#261](https://github.com/heroku/buildpacks-python/pull/261)) + ## [0.16.0] - 2024-08-30 ### Changed diff --git a/README.md b/README.md index 978d3a5..e4bf265 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ docker run --rm -it -e "PORT=8080" -p 8080:8080 sample-app ## Application Requirements -A `requirements.txt` file must be present at the root of your application's repository. +A `requirements.txt` or `poetry.lock` file must be present in the root (top-level) directory of your app's source code. ## Configuration @@ -39,14 +39,17 @@ A `requirements.txt` file must be present at the root of your application's repo By default, the buildpack will install the latest version of Python 3.12. -To install a different version, add a `runtime.txt` file to your app’s root directory that declares the exact version number to use: +To install a different version, add a `runtime.txt` file to your app's root directory that declares the exact version number to use: ```term $ cat runtime.txt python-3.12.5 ``` -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)). +In the future this buildpack will also support specifying the Python version using: + +- A `.python-version` file: [#6](https://github.com/heroku/buildpacks-python/issues/6) +- `tool.poetry.dependencies.python` in `pyproject.toml`: [#260](https://github.com/heroku/buildpacks-python/issues/260) ## Contributing diff --git a/requirements/poetry.txt b/requirements/poetry.txt new file mode 100644 index 0000000..65e7a6c --- /dev/null +++ b/requirements/poetry.txt @@ -0,0 +1 @@ +poetry==1.8.3 diff --git a/src/detect.rs b/src/detect.rs index 2634ea0..d8ba626 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -41,29 +41,29 @@ pub(crate) fn is_python_project_directory(app_dir: &Path) -> io::Result { #[cfg(test)] mod tests { use super::*; - use crate::package_manager::PACKAGE_MANAGER_FILE_MAPPING; + use crate::package_manager::SUPPORTED_PACKAGE_MANAGERS; #[test] - fn is_python_project_valid_project() { + fn is_python_project_directory_valid_project() { assert!( is_python_project_directory(Path::new("tests/fixtures/pyproject_toml_only")).unwrap() ); } #[test] - fn is_python_project_empty() { + fn is_python_project_directory_empty() { assert!(!is_python_project_directory(Path::new("tests/fixtures/empty")).unwrap()); } #[test] - fn is_python_project_io_error() { + fn is_python_project_directory_io_error() { assert!(is_python_project_directory(Path::new("tests/fixtures/empty/.gitkeep")).is_err()); } #[test] fn known_python_project_files_contains_all_package_manager_files() { - assert!(PACKAGE_MANAGER_FILE_MAPPING - .iter() - .all(|(filename, _)| { KNOWN_PYTHON_PROJECT_FILES.contains(filename) })); + assert!(SUPPORTED_PACKAGE_MANAGERS.iter().all(|package_manager| { + KNOWN_PYTHON_PROJECT_FILES.contains(&package_manager.packages_file()) + })); } } diff --git a/src/errors.rs b/src/errors.rs index de9d602..58e8050 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,6 +1,8 @@ use crate::django::DjangoCollectstaticError; use crate::layers::pip::PipLayerError; use crate::layers::pip_dependencies::PipDependenciesLayerError; +use crate::layers::poetry::PoetryLayerError; +use crate::layers::poetry_dependencies::PoetryDependenciesLayerError; use crate::layers::python::PythonLayerError; use crate::package_manager::DeterminePackageManagerError; use crate::python_version::{PythonVersion, PythonVersionError, DEFAULT_PYTHON_VERSION}; @@ -48,6 +50,8 @@ fn on_buildpack_error(error: BuildpackError) { BuildpackError::DjangoDetection(error) => on_django_detection_error(&error), BuildpackError::PipDependenciesLayer(error) => on_pip_dependencies_layer_error(error), BuildpackError::PipLayer(error) => on_pip_layer_error(error), + BuildpackError::PoetryDependenciesLayer(error) => on_poetry_dependencies_layer_error(error), + BuildpackError::PoetryLayer(error) => on_poetry_layer_error(error), BuildpackError::PythonLayer(error) => on_python_layer_error(error), BuildpackError::PythonVersion(error) => on_python_version_error(error), }; @@ -68,18 +72,46 @@ fn on_determine_package_manager_error(error: DeterminePackageManagerError) { "determining which Python package manager to use for this project", &io_error, ), - // TODO: Should this mention the setup.py / pyproject.toml case? + DeterminePackageManagerError::MultipleFound(package_managers) => { + let files_found = package_managers + .into_iter() + .map(|package_manager| { + format!( + "{} ({})", + package_manager.packages_file(), + package_manager.name() + ) + }) + .collect::>() + .join("\n"); + log_error( + "Multiple Python package manager files were found", + formatdoc! {" + Exactly one package manager file must be present in your app's source code, + however, several were found: + + {files_found} + + Decide which package manager you want to use with your app, and then delete + the file(s) and any config from the others. + "}, + ); + } DeterminePackageManagerError::NoneFound => log_error( - "No Python package manager files were found", + "Couldn't find any supported Python package manager files", indoc! {" - A pip requirements file was not found in your application's source code. - This file is required so that your application's dependencies can be installed. + Your app must have either a pip requirements file ('requirements.txt') + or Poetry lockfile ('poetry.lock') in the root directory of its source + code, so your app's dependencies can be installed. - Please add a file named exactly 'requirements.txt' to the root directory of your - application, containing a list of the packages required by your application. + If your app already has one of those files, check that it: - For more information on what this file should contain, see: - https://pip.pypa.io/en/stable/reference/requirements-file-format/ + 1. Is in the top level directory (not a subdirectory). + 2. Has the correct spelling (the filenames are case-sensitive). + 3. Isn't excluded by '.gitignore' or 'project.toml'. + + Otherwise, add a package manager file to your app. If your app has + no dependencies, then create an empty 'requirements.txt' file. "}, ), }; @@ -235,6 +267,76 @@ fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) { }; } +fn on_poetry_layer_error(error: PoetryLayerError) { + match error { + PoetryLayerError::InstallPoetryCommand(error) => match error { + StreamedCommandError::Io(io_error) => log_io_error( + "Unable to install Poetry", + "running 'python' to install Poetry", + &io_error, + ), + StreamedCommandError::NonZeroExitStatus(exit_status) => log_error( + "Unable to install Poetry", + formatdoc! {" + The command to install Poetry did not exit successfully ({exit_status}). + + See the log output above for more information. + + In some cases, this happens due to an unstable network connection. + Please try again to see if the error resolves itself. + + If that does not help, check the status of PyPI (the upstream Python + package repository service), here: + https://status.python.org + "}, + ), + }, + PoetryLayerError::LocateBundledPip(io_error) => log_io_error( + "Unable to locate the bundled copy of pip", + "locating the pip wheel file bundled inside the Python 'ensurepip' module", + &io_error, + ), + }; +} + +fn on_poetry_dependencies_layer_error(error: PoetryDependenciesLayerError) { + match error { + PoetryDependenciesLayerError::CreateVenvCommand(error) => match error { + StreamedCommandError::Io(io_error) => log_io_error( + "Unable to create virtual environment", + "running 'python -m venv' to create a virtual environment", + &io_error, + ), + StreamedCommandError::NonZeroExitStatus(exit_status) => log_error( + "Unable to create virtual environment", + formatdoc! {" + The 'python -m venv' command to create a virtual environment did + not exit successfully ({exit_status}). + + See the log output above for more information. + "}, + ), + }, + PoetryDependenciesLayerError::PoetryInstallCommand(error) => match error { + StreamedCommandError::Io(io_error) => log_io_error( + "Unable to install dependencies using Poetry", + "running 'poetry install' to install the app's dependencies", + &io_error, + ), + // TODO: Add more suggestions here as to possible causes (similar to pip) + StreamedCommandError::NonZeroExitStatus(exit_status) => log_error( + "Unable to install dependencies using Poetry", + formatdoc! {" + The 'poetry install --sync --only main' command to install the app's + dependencies failed ({exit_status}). + + See the log output above for more information. + "}, + ), + }, + }; +} + fn on_django_detection_error(error: &io::Error) { log_io_error( "Unable to determine if this is a Django-based app", diff --git a/src/layers/mod.rs b/src/layers/mod.rs index ab0a91e..c0873d0 100644 --- a/src/layers/mod.rs +++ b/src/layers/mod.rs @@ -1,4 +1,6 @@ pub(crate) mod pip; pub(crate) mod pip_cache; pub(crate) mod pip_dependencies; +pub(crate) mod poetry; +pub(crate) mod poetry_dependencies; pub(crate) mod python; diff --git a/src/layers/poetry.rs b/src/layers/poetry.rs new file mode 100644 index 0000000..77a9317 --- /dev/null +++ b/src/layers/poetry.rs @@ -0,0 +1,142 @@ +use crate::packaging_tool_versions::POETRY_VERSION; +use crate::python_version::PythonVersion; +use crate::utils::StreamedCommandError; +use crate::{utils, BuildpackError, PythonBuildpack}; +use libcnb::build::BuildContext; +use libcnb::data::layer_name; +use libcnb::layer::{ + CachedLayerDefinition, EmptyLayerCause, InvalidMetadataAction, LayerState, RestoredLayerAction, +}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::Env; +use libherokubuildpack::log::log_info; +use serde::{Deserialize, Serialize}; +use std::io; +use std::path::Path; +use std::process::Command; + +/// Creates a build-only layer containing Poetry. +pub(crate) fn install_poetry( + context: &BuildContext, + env: &mut Env, + python_version: &PythonVersion, + python_layer_path: &Path, +) -> Result<(), libcnb::Error> { + let new_metadata = PoetryLayerMetadata { + arch: context.target.arch.clone(), + distro_name: context.target.distro_name.clone(), + distro_version: context.target.distro_version.clone(), + python_version: python_version.to_string(), + poetry_version: POETRY_VERSION.to_string(), + }; + + let layer = context.cached_layer( + layer_name!("poetry"), + CachedLayerDefinition { + build: true, + launch: false, + invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer, + restored_layer_action: &|cached_metadata: &PoetryLayerMetadata, _| { + let cached_poetry_version = cached_metadata.poetry_version.clone(); + if cached_metadata == &new_metadata { + (RestoredLayerAction::KeepLayer, cached_poetry_version) + } else { + (RestoredLayerAction::DeleteLayer, cached_poetry_version) + } + }, + }, + )?; + + // Move the Python user base directory to this layer instead of under HOME: + // https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUSERBASE + let mut layer_env = LayerEnv::new().chainable_insert( + Scope::Build, + ModificationBehavior::Override, + "PYTHONUSERBASE", + layer.path(), + ); + + match layer.state { + LayerState::Restored { + cause: ref cached_poetry_version, + } => { + log_info(format!("Using cached Poetry {cached_poetry_version}")); + } + LayerState::Empty { ref cause } => { + match cause { + EmptyLayerCause::InvalidMetadataAction { .. } => { + log_info("Discarding cached Poetry since its layer metadata can't be parsed"); + } + EmptyLayerCause::RestoredLayerAction { + cause: cached_poetry_version, + } => { + log_info(format!("Discarding cached Poetry {cached_poetry_version}")); + } + EmptyLayerCause::NewlyCreated => {} + } + + log_info(format!("Installing Poetry {POETRY_VERSION}")); + + // We use the pip wheel bundled within Python's standard library to install Poetry. + // Whilst Poetry does still require pip for some tasks (such as package uninstalls), + // it bundles its own copy for use as a fallback. As such we don't need to install pip + // into the user site-packages (and in fact, Poetry wouldn't use this install anyway, + // since it only finds an external pip if it exists in the target venv). + let bundled_pip_module_path = + utils::bundled_pip_module_path(python_layer_path, python_version) + .map_err(PoetryLayerError::LocateBundledPip)?; + + utils::run_command_and_stream_output( + Command::new("python") + .args([ + &bundled_pip_module_path.to_string_lossy(), + "install", + // There is no point using pip's cache here, since the layer itself will be cached. + "--no-cache-dir", + "--no-input", + "--no-warn-script-location", + "--quiet", + "--user", + format!("poetry=={POETRY_VERSION}").as_str(), + ]) + .env_clear() + .envs(&layer_env.apply(Scope::Build, env)), + ) + .map_err(PoetryLayerError::InstallPoetryCommand)?; + + layer.write_metadata(new_metadata)?; + } + } + + layer.write_env(&layer_env)?; + // Required to pick up the automatic PATH env var. See: https://github.com/heroku/libcnb.rs/issues/842 + layer_env = layer.read_env()?; + env.clone_from(&layer_env.apply(Scope::Build, env)); + + Ok(()) +} + +// Some of Poetry's dependencies contain compiled components so are platform-specific (unlike pure +// Python packages). As such we have to take arch and distro into account for cache invalidation. +#[derive(Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PoetryLayerMetadata { + arch: String, + distro_name: String, + distro_version: String, + python_version: String, + poetry_version: String, +} + +/// Errors that can occur when installing Poetry into a layer. +#[derive(Debug)] +pub(crate) enum PoetryLayerError { + InstallPoetryCommand(StreamedCommandError), + LocateBundledPip(io::Error), +} + +impl From for libcnb::Error { + fn from(error: PoetryLayerError) -> Self { + Self::BuildpackError(BuildpackError::PoetryLayer(error)) + } +} diff --git a/src/layers/poetry_dependencies.rs b/src/layers/poetry_dependencies.rs new file mode 100644 index 0000000..980f08b --- /dev/null +++ b/src/layers/poetry_dependencies.rs @@ -0,0 +1,152 @@ +use crate::packaging_tool_versions::POETRY_VERSION; +use crate::python_version::PythonVersion; +use crate::utils::StreamedCommandError; +use crate::{utils, BuildpackError, PythonBuildpack}; +use libcnb::build::BuildContext; +use libcnb::data::layer_name; +use libcnb::layer::{ + CachedLayerDefinition, EmptyLayerCause, InvalidMetadataAction, RestoredLayerAction, +}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::Env; +use libherokubuildpack::log::log_info; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Command; + +/// Creates a layer containing the application's Python dependencies, installed using Poetry. +// +// We install into a virtual environment since: +// - We can't install into the system site-packages inside the main Python directory since +// we need the app dependencies to be in their own layer. +// - Some packages are broken with `--user` installs when using relocated Python, and +// otherwise require other workarounds. eg: https://github.com/unbit/uwsgi/issues/2525 +// - Poetry doesn't support `--user`: https://github.com/python-poetry/poetry/issues/1214 +// - PEP-405 style venvs are very lightweight and are also much more frequently +// used in the wild compared to `--user`, and therefore the better tested path. +// +// We cache the virtual environment, since: +// - It results in faster builds than only caching Poetry's download/wheel cache. +// - It's safe to do so, since `poetry install --sync` fully manages the environment +// (including e.g. uninstalling packages when they are removed from the lockfile). +// +// With the venv cached there is no need to persist Poetry's download/wheel cache in its +// own layer, so we let Poetry write it to the home directory where it will be discarded +// at the end of the build. We don't use `--no-cache` since the cache still offers benefits +// (such as avoiding repeat downloads of PEP-517/518 build requirements). +pub(crate) fn install_dependencies( + context: &BuildContext, + env: &mut Env, + python_version: &PythonVersion, +) -> Result> { + let new_metadata = PoetryDependenciesLayerMetadata { + arch: context.target.arch.clone(), + distro_name: context.target.distro_name.clone(), + distro_version: context.target.distro_version.clone(), + python_version: python_version.to_string(), + poetry_version: POETRY_VERSION.to_string(), + }; + + let layer = context.cached_layer( + // The name of this layer must be alphabetically after that of the `python` layer so that + // this layer's `bin/` directory (and thus `python` symlink) is listed first in `PATH`: + // https://github.com/buildpacks/spec/blob/main/buildpack.md#layer-paths + layer_name!("venv"), + CachedLayerDefinition { + build: true, + launch: true, + invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer, + restored_layer_action: &|cached_metadata: &PoetryDependenciesLayerMetadata, _| { + if cached_metadata == &new_metadata { + RestoredLayerAction::KeepLayer + } else { + RestoredLayerAction::DeleteLayer + } + }, + }, + )?; + let layer_path = layer.path(); + + match layer.state { + libcnb::layer::LayerState::Restored { .. } => { + log_info("Using cached virtual environment"); + } + libcnb::layer::LayerState::Empty { cause } => { + match cause { + EmptyLayerCause::InvalidMetadataAction { .. } + | EmptyLayerCause::RestoredLayerAction { .. } => { + log_info("Discarding cached virtual environment"); + } + EmptyLayerCause::NewlyCreated => {} + } + + log_info("Creating virtual environment"); + utils::run_command_and_stream_output( + Command::new("python") + .args(["-m", "venv", "--without-pip", &layer_path.to_string_lossy()]) + .env_clear() + .envs(&*env), + ) + .map_err(PoetryDependenciesLayerError::CreateVenvCommand)?; + + layer.write_metadata(new_metadata)?; + } + } + + let mut layer_env = LayerEnv::new() + // For parity with the venv's `bin/activate` script: + // https://docs.python.org/3/library/venv.html#how-venvs-work + .chainable_insert( + Scope::All, + ModificationBehavior::Override, + "VIRTUAL_ENV", + &layer_path, + ); + layer.write_env(&layer_env)?; + // Required to pick up the automatic PATH env var. See: https://github.com/heroku/libcnb.rs/issues/842 + layer_env = layer.read_env()?; + env.clone_from(&layer_env.apply(Scope::Build, env)); + + log_info("Running 'poetry install --sync --only main'"); + utils::run_command_and_stream_output( + Command::new("poetry") + .args([ + "install", + // Compile Python bytecode up front to improve app boot times (pip does this by default). + "--compile", + "--only", + "main", + "--no-interaction", + "--sync", + ]) + .current_dir(&context.app_dir) + .env_clear() + .envs(&*env), + ) + .map_err(PoetryDependenciesLayerError::PoetryInstallCommand)?; + + Ok(layer_path) +} + +#[derive(Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PoetryDependenciesLayerMetadata { + arch: String, + distro_name: String, + distro_version: String, + python_version: String, + poetry_version: String, +} + +/// Errors that can occur when installing the project's dependencies into a layer using Poetry. +#[derive(Debug)] +pub(crate) enum PoetryDependenciesLayerError { + CreateVenvCommand(StreamedCommandError), + PoetryInstallCommand(StreamedCommandError), +} + +impl From for libcnb::Error { + fn from(error: PoetryDependenciesLayerError) -> Self { + Self::BuildpackError(BuildpackError::PoetryDependenciesLayer(error)) + } +} diff --git a/src/main.rs b/src/main.rs index fa533d0..dcb456f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,10 @@ mod utils; use crate::django::DjangoCollectstaticError; use crate::layers::pip::PipLayerError; use crate::layers::pip_dependencies::PipDependenciesLayerError; +use crate::layers::poetry::PoetryLayerError; +use crate::layers::poetry_dependencies::PoetryDependenciesLayerError; use crate::layers::python::PythonLayerError; -use crate::layers::{pip, pip_cache, pip_dependencies, python}; +use crate::layers::{pip, pip_cache, pip_dependencies, poetry, poetry_dependencies, python}; use crate::package_manager::{DeterminePackageManagerError, PackageManager}; use crate::python_version::PythonVersionError; use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder}; @@ -39,7 +41,7 @@ impl Buildpack for PythonBuildpack { { DetectResultBuilder::pass().build() } else { - log_info("No Python project files found (such as requirements.txt)."); + log_info("No Python project files found (such as pyproject.toml, requirements.txt or poetry.lock)."); DetectResultBuilder::fail().build() } } @@ -64,7 +66,6 @@ impl Buildpack for PythonBuildpack { log_header("Installing Python"); let python_layer_path = python::install_python(&context, &mut env, &python_version)?; - // In the future support will be added for package managers other than pip. let dependencies_layer_dir = match package_manager { PackageManager::Pip => { log_header("Installing pip"); @@ -73,6 +74,12 @@ impl Buildpack for PythonBuildpack { pip_cache::prepare_pip_cache(&context, &mut env, &python_version)?; pip_dependencies::install_dependencies(&context, &mut env)? } + PackageManager::Poetry => { + log_header("Installing Poetry"); + poetry::install_poetry(&context, &mut env, &python_version, &python_layer_path)?; + log_header("Installing dependencies using Poetry"); + poetry_dependencies::install_dependencies(&context, &mut env, &python_version)? + } }; if django::is_django_installed(&dependencies_layer_dir) @@ -105,6 +112,10 @@ pub(crate) enum BuildpackError { PipDependenciesLayer(PipDependenciesLayerError), /// Errors installing pip into a layer. PipLayer(PipLayerError), + /// Errors installing the project's dependencies into a layer using Poetry. + PoetryDependenciesLayer(PoetryDependenciesLayerError), + /// Errors installing Poetry into a layer. + PoetryLayer(PoetryLayerError), /// Errors installing Python into a layer. PythonLayer(PythonLayerError), /// Errors determining which Python version to use for a project. diff --git a/src/package_manager.rs b/src/package_manager.rs index f9981d8..a91ddf8 100644 --- a/src/package_manager.rs +++ b/src/package_manager.rs @@ -1,41 +1,62 @@ use std::io; use std::path::Path; -/// An ordered mapping of project filenames to their associated package manager. -/// Earlier entries will take precedence if a project matches multiple package managers. -pub(crate) const PACKAGE_MANAGER_FILE_MAPPING: [(&str, PackageManager); 1] = - [("requirements.txt", PackageManager::Pip)]; +pub(crate) const SUPPORTED_PACKAGE_MANAGERS: [PackageManager; 2] = + [PackageManager::Pip, PackageManager::Poetry]; -/// Python package managers supported by the buildpack. -#[derive(Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum PackageManager { Pip, + Poetry, +} + +impl PackageManager { + pub(crate) fn name(self) -> &'static str { + match self { + PackageManager::Pip => "pip", + PackageManager::Poetry => "Poetry", + } + } + + pub(crate) fn packages_file(self) -> &'static str { + match self { + PackageManager::Pip => "requirements.txt", + PackageManager::Poetry => "poetry.lock", + } + } } -/// Determine the Python package manager to use for a project, or return an error if no supported -/// package manager files are found. If a project contains the files for multiple package managers, -/// then the earliest entry in `PACKAGE_MANAGER_FILE_MAPPING` takes precedence. +/// Determine the Python package manager to use for a project, or return an error if either +/// multiple supported package manager files are found, or none are. pub(crate) fn determine_package_manager( app_dir: &Path, ) -> Result { - // Until `Iterator::try_find` is stabilised, this is cleaner as a for loop. - for (filename, package_manager) in PACKAGE_MANAGER_FILE_MAPPING { - if app_dir - .join(filename) - .try_exists() - .map_err(DeterminePackageManagerError::CheckFileExists)? - { - return Ok(package_manager); - } - } + let package_managers_found = SUPPORTED_PACKAGE_MANAGERS + .into_iter() + .filter_map(|package_manager| { + app_dir + .join(package_manager.packages_file()) + .try_exists() + .map_err(DeterminePackageManagerError::CheckFileExists) + .map(|exists| exists.then_some(package_manager)) + .transpose() + }) + .collect::, _>>()?; - Err(DeterminePackageManagerError::NoneFound) + match package_managers_found[..] { + [package_manager] => Ok(package_manager), + [] => Err(DeterminePackageManagerError::NoneFound), + _ => Err(DeterminePackageManagerError::MultipleFound( + package_managers_found, + )), + } } /// Errors that can occur when determining which Python package manager to use for a project. #[derive(Debug)] pub(crate) enum DeterminePackageManagerError { CheckFileExists(io::Error), + MultipleFound(Vec), NoneFound, } @@ -45,17 +66,32 @@ mod tests { #[test] fn determine_package_manager_requirements_txt() { - assert!(matches!( - determine_package_manager(Path::new("tests/fixtures/pip_editable_git_compiled")) - .unwrap(), + assert_eq!( + determine_package_manager(Path::new("tests/fixtures/pip_basic")).unwrap(), PackageManager::Pip + ); + } + + #[test] + fn determine_package_manager_poetry_lock() { + assert_eq!( + determine_package_manager(Path::new("tests/fixtures/poetry_basic")).unwrap(), + PackageManager::Poetry + ); + } + + #[test] + fn determine_package_manager_multiple() { + assert!(matches!( + determine_package_manager(Path::new("tests/fixtures/pip_and_poetry")).unwrap_err(), + DeterminePackageManagerError::MultipleFound(found) if found == [PackageManager::Pip, PackageManager::Poetry] )); } #[test] fn determine_package_manager_none() { assert!(matches!( - determine_package_manager(Path::new("tests/fixtures/empty")).unwrap_err(), + determine_package_manager(Path::new("tests/fixtures/pyproject_toml_only")).unwrap_err(), DeterminePackageManagerError::NoneFound )); } diff --git a/src/packaging_tool_versions.rs b/src/packaging_tool_versions.rs index 28f2f27..9f8fa16 100644 --- a/src/packaging_tool_versions.rs +++ b/src/packaging_tool_versions.rs @@ -5,6 +5,8 @@ use std::str; // from which we extract/validate the version substring at compile time. pub(crate) const PIP_VERSION: &str = extract_requirement_version(include_str!("../requirements/pip.txt")); +pub(crate) const POETRY_VERSION: &str = + extract_requirement_version(include_str!("../requirements/poetry.txt")); // Extract the version substring from an exact-version package specifier (such as `foo==1.2.3`). // This function should only be used to extract the version constants from the buildpack's own diff --git a/tests/detect_test.rs b/tests/detect_test.rs index 5d88843..1565eb5 100644 --- a/tests/detect_test.rs +++ b/tests/detect_test.rs @@ -11,7 +11,7 @@ fn detect_rejects_non_python_projects() { assert_contains!( context.pack_stdout, indoc! {"======== - No Python project files found (such as requirements.txt). + No Python project files found (such as pyproject.toml, requirements.txt or poetry.lock). ======== Results ======== "} ); diff --git a/tests/fixtures/pip_and_poetry/poetry.lock b/tests/fixtures/pip_and_poetry/poetry.lock new file mode 100644 index 0000000..e6e2be3 --- /dev/null +++ b/tests/fixtures/pip_and_poetry/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "*" +content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8" diff --git a/tests/fixtures/pip_and_poetry/pyproject.toml b/tests/fixtures/pip_and_poetry/pyproject.toml new file mode 100644 index 0000000..89c2838 --- /dev/null +++ b/tests/fixtures/pip_and_poetry/pyproject.toml @@ -0,0 +1,2 @@ +[tool.poetry] +package-mode = false diff --git a/tests/fixtures/pip_and_poetry/requirements.txt b/tests/fixtures/pip_and_poetry/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/poetry_basic/poetry.lock b/tests/fixtures/poetry_basic/poetry.lock new file mode 100644 index 0000000..4a1944d --- /dev/null +++ b/tests/fixtures/poetry_basic/poetry.lock @@ -0,0 +1,85 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "2e71a7976f439ce69fc771708b83dcfc6f795072ea73a7c2de0241878cbd378a" diff --git a/tests/fixtures/poetry_basic/pyproject.toml b/tests/fixtures/poetry_basic/pyproject.toml new file mode 100644 index 0000000..1b21050 --- /dev/null +++ b/tests/fixtures/poetry_basic/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +typing-extensions = "*" + +# This group shouldn't be installed due to us passing `--only main`. +[tool.poetry.group.test.dependencies] +pytest = "*" diff --git a/tests/fixtures/poetry_editable_git_compiled/poetry.lock b/tests/fixtures/poetry_editable_git_compiled/poetry.lock new file mode 100644 index 0000000..ee0ac73 --- /dev/null +++ b/tests/fixtures/poetry_editable_git_compiled/poetry.lock @@ -0,0 +1,22 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "extension.dist" +version = "0.1" +description = "A testing distribution ☃" +optional = false +python-versions = "*" +files = [] +develop = true + +[package.source] +type = "git" +url = "https://github.com/pypa/wheel.git" +reference = "0.44.0" +resolved_reference = "7bb46d7727e6e89fe56b3c78297b3af2672bbbe2" +subdirectory = "tests/testdata/extension.dist" + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "8c333a07a0702492e3f7715c3372860fafc8c2aed2dde0e9ee5241e7723a5da2" diff --git a/tests/fixtures/poetry_editable_git_compiled/pyproject.toml b/tests/fixtures/poetry_editable_git_compiled/pyproject.toml new file mode 100644 index 0000000..b86182c --- /dev/null +++ b/tests/fixtures/poetry_editable_git_compiled/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" + +# This requirement uses a VCS URL and `develop = true` in order to test that: +# - Git from the stack image can be found (ie: the system PATH has been correctly propagated to Poetry). +# - The editable mode repository clone is saved into the dependencies layer. +# +# A C-based package is used instead of a pure Python package, in order to test that the +# Python headers can be found in the `include/pythonX.Y/` directory of the Python layer. +[tool.poetry.dependencies.extension-dist] +git = "https://github.com/pypa/wheel.git" +tag = "0.44.0" +subdirectory = "tests/testdata/extension.dist" +develop = true diff --git a/tests/fixtures/poetry_outdated_lockfile/poetry.lock b/tests/fixtures/poetry_outdated_lockfile/poetry.lock new file mode 100644 index 0000000..1034779 --- /dev/null +++ b/tests/fixtures/poetry_outdated_lockfile/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "34e39677d8527182346093002688d17a5d2fc204b9eb3e094b2e6ac519028228" diff --git a/tests/fixtures/poetry_outdated_lockfile/pyproject.toml b/tests/fixtures/poetry_outdated_lockfile/pyproject.toml new file mode 100644 index 0000000..f0367e3 --- /dev/null +++ b/tests/fixtures/poetry_outdated_lockfile/pyproject.toml @@ -0,0 +1,8 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" + +# This dependency isn't in the lockfile. +typing-extensions = "*" diff --git a/tests/fixtures/testing_buildpack/bin/build b/tests/fixtures/testing_buildpack/bin/build index 37283f2..9c4b633 100755 --- a/tests/fixtures/testing_buildpack/bin/build +++ b/tests/fixtures/testing_buildpack/bin/build @@ -16,6 +16,15 @@ printenv | sort | grep -vE '^(_|CNB_.+|HOME|HOSTNAME|OLDPWD|PWD|SHLVL)=' echo python -c 'import pprint, sys; pprint.pp(sys.path)' echo -pip --version -pip list + +if [[ -f poetry.lock ]]; then + poetry --version + # The show command also lists dependencies that are in optional groups in pyproject.toml + # but that aren't actually installed, for which the only option is to filter out by hand. + poetry show | grep -v ' (!) ' +else + pip --version + pip list +fi + python -c 'import typing_extensions; print(typing_extensions)' diff --git a/tests/mod.rs b/tests/mod.rs index 97db4fa..d760e08 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -7,6 +7,7 @@ mod detect_test; mod django_test; mod package_manager_test; mod pip_test; +mod poetry_test; mod python_version_test; use libcnb_test::BuildConfig; diff --git a/tests/package_manager_test.rs b/tests/package_manager_test.rs index b10ac40..52fcd1c 100644 --- a/tests/package_manager_test.rs +++ b/tests/package_manager_test.rs @@ -12,15 +12,44 @@ fn no_package_manager_detected() { assert_contains!( context.pack_stderr, indoc! {" - [Error: No Python package manager files were found] - A pip requirements file was not found in your application's source code. - This file is required so that your application's dependencies can be installed. + [Error: Couldn't find any supported Python package manager files] + Your app must have either a pip requirements file ('requirements.txt') + or Poetry lockfile ('poetry.lock') in the root directory of its source + code, so your app's dependencies can be installed. - Please add a file named exactly 'requirements.txt' to the root directory of your - application, containing a list of the packages required by your application. + If your app already has one of those files, check that it: - For more information on what this file should contain, see: - https://pip.pypa.io/en/stable/reference/requirements-file-format/ + 1. Is in the top level directory (not a subdirectory). + 2. Has the correct spelling (the filenames are case-sensitive). + 3. Isn't excluded by '.gitignore' or 'project.toml'. + + Otherwise, add a package manager file to your app. If your app has + no dependencies, then create an empty 'requirements.txt' file. + "} + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn multiple_package_managers_detected() { + TestRunner::default().build( + default_build_config("tests/fixtures/pip_and_poetry") + .expected_pack_result(PackResult::Failure), + |context| { + assert_contains!( + context.pack_stderr, + indoc! {" + [Error: Multiple Python package manager files were found] + Exactly one package manager file must be present in your app's source code, + however, several were found: + + requirements.txt (pip) + poetry.lock (Poetry) + + Decide which package manager you want to use with your app, and then delete + the file(s) and any config from the others. "} ); }, diff --git a/tests/pip_test.rs b/tests/pip_test.rs index dbc4079..c6052a3 100644 --- a/tests/pip_test.rs +++ b/tests/pip_test.rs @@ -1,5 +1,5 @@ use crate::packaging_tool_versions::PIP_VERSION; -use crate::tests::{default_build_config, DEFAULT_PYTHON_VERSION, LATEST_PYTHON_3_11}; +use crate::tests::{default_build_config, DEFAULT_PYTHON_VERSION}; use indoc::{formatdoc, indoc}; use libcnb_test::{assert_contains, assert_empty, BuildpackReference, PackResult, TestRunner}; @@ -132,8 +132,8 @@ fn pip_basic_install_and_cache_reuse() { #[test] #[ignore = "integration test"] -fn pip_cache_invalidation_python_version_changed() { - let config = default_build_config("tests/fixtures/python_3.11"); +fn pip_cache_invalidation_package_manager_changed() { + let config = default_build_config("tests/fixtures/poetry_basic"); let rebuild_config = default_build_config("tests/fixtures/pip_basic"); TestRunner::default().build(config, |context| { @@ -147,16 +147,12 @@ fn pip_cache_invalidation_python_version_changed() { To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes [Installing Python] - Discarding cached Python {LATEST_PYTHON_3_11} since: - - The Python version has changed from {LATEST_PYTHON_3_11} to {DEFAULT_PYTHON_VERSION} - Installing Python {DEFAULT_PYTHON_VERSION} + Using cached Python {DEFAULT_PYTHON_VERSION} [Installing pip] - Discarding cached pip {PIP_VERSION} Installing pip {PIP_VERSION} [Installing dependencies using pip] - Discarding cached pip download/wheel cache Creating virtual environment Running 'pip install -r requirements.txt' Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) @@ -177,7 +173,7 @@ fn pip_cache_invalidation_python_version_changed() { fn pip_cache_previous_buildpack_version() { let mut config = default_build_config("tests/fixtures/pip_basic"); config.buildpacks([BuildpackReference::Other( - "docker://docker.io/heroku/buildpack-python:0.14.0".to_string(), + "docker://docker.io/heroku/buildpack-python:0.16.0".to_string(), )]); let rebuild_config = default_build_config("tests/fixtures/pip_basic"); @@ -192,19 +188,18 @@ fn pip_cache_previous_buildpack_version() { To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes [Installing Python] - Discarding cached Python since its layer metadata can't be parsed - Installing Python {DEFAULT_PYTHON_VERSION} + Using cached Python {DEFAULT_PYTHON_VERSION} [Installing pip] - Installing pip {PIP_VERSION} + Using cached pip {PIP_VERSION} [Installing dependencies using pip] - Discarding cached pip download/wheel cache + Using cached pip download/wheel cache Creating virtual environment Running 'pip install -r requirements.txt' Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) - Downloading typing_extensions-4.12.2-py3-none-any.whl.metadata (3.0 kB) - Downloading typing_extensions-4.12.2-py3-none-any.whl (37 kB) + Using cached typing_extensions-4.12.2-py3-none-any.whl.metadata (3.0 kB) + Using cached typing_extensions-4.12.2-py3-none-any.whl (37 kB) Installing collected packages: typing-extensions Successfully installed typing-extensions-4.12.2 "} diff --git a/tests/poetry_test.rs b/tests/poetry_test.rs new file mode 100644 index 0000000..d8d41ec --- /dev/null +++ b/tests/poetry_test.rs @@ -0,0 +1,246 @@ +use crate::packaging_tool_versions::POETRY_VERSION; +use crate::tests::{default_build_config, DEFAULT_PYTHON_VERSION}; +use indoc::{formatdoc, indoc}; +use libcnb_test::{assert_contains, assert_empty, BuildpackReference, PackResult, TestRunner}; + +#[test] +#[ignore = "integration test"] +fn poetry_basic_install_and_cache_reuse() { + let mut config = default_build_config("tests/fixtures/poetry_basic"); + config.buildpacks(vec![ + BuildpackReference::CurrentCrate, + BuildpackReference::Other("file://tests/fixtures/testing_buildpack".to_string()), + ]); + + TestRunner::default().build(&config, |context| { + assert_empty!(context.pack_stderr); + assert_contains!( + context.pack_stdout, + &formatdoc! {" + [Determining Python version] + No Python version specified, using the current default of Python {DEFAULT_PYTHON_VERSION}. + To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + + [Installing Python] + Installing Python {DEFAULT_PYTHON_VERSION} + + [Installing Poetry] + Installing Poetry {POETRY_VERSION} + + [Installing dependencies using Poetry] + Creating virtual environment + Running 'poetry install --sync --only main' + Installing dependencies from lock file + + Package operations: 1 install, 0 updates, 0 removals + + - Installing typing-extensions (4.12.2) + + ## Testing buildpack ## + CPATH=/layers/heroku_python/venv/include:/layers/heroku_python/python/include/python3.12:/layers/heroku_python/python/include + LANG=C.UTF-8 + LD_LIBRARY_PATH=/layers/heroku_python/venv/lib:/layers/heroku_python/python/lib:/layers/heroku_python/poetry/lib + LIBRARY_PATH=/layers/heroku_python/venv/lib:/layers/heroku_python/python/lib:/layers/heroku_python/poetry/lib + PATH=/layers/heroku_python/venv/bin:/layers/heroku_python/python/bin:/layers/heroku_python/poetry/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + PKG_CONFIG_PATH=/layers/heroku_python/python/lib/pkgconfig + PYTHONHOME=/layers/heroku_python/python + PYTHONUNBUFFERED=1 + PYTHONUSERBASE=/layers/heroku_python/poetry + SOURCE_DATE_EPOCH=315532801 + VIRTUAL_ENV=/layers/heroku_python/venv + + ['', + '/layers/heroku_python/python/lib/python312.zip', + '/layers/heroku_python/python/lib/python3.12', + '/layers/heroku_python/python/lib/python3.12/lib-dynload', + '/layers/heroku_python/venv/lib/python3.12/site-packages'] + + Poetry (version {POETRY_VERSION}) + typing-extensions 4.12.2 Backported and Experimental Type Hints for Python ... + + "} + ); + + // Check that at run-time: + // - The correct env vars are set. + // - Poetry isn't available. + // - Python can find the typing-extensions package. + let command_output = context.run_shell_command( + indoc! {" + set -euo pipefail + printenv | sort | grep -vE '^(_|HOME|HOSTNAME|OLDPWD|PWD|SHLVL)=' + ! command -v poetry > /dev/null || { echo 'Poetry unexpectedly found!' && exit 1; } + python -c 'import typing_extensions' + "} + ); + assert_empty!(command_output.stderr); + assert_eq!( + command_output.stdout, + formatdoc! {" + LANG=C.UTF-8 + LD_LIBRARY_PATH=/layers/heroku_python/venv/lib:/layers/heroku_python/python/lib + PATH=/layers/heroku_python/venv/bin:/layers/heroku_python/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + PYTHONHOME=/layers/heroku_python/python + PYTHONUNBUFFERED=1 + VIRTUAL_ENV=/layers/heroku_python/venv + "} + ); + + context.rebuild(&config, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + [Determining Python version] + No Python version specified, using the current default of Python {DEFAULT_PYTHON_VERSION}. + To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + + [Installing Python] + Using cached Python {DEFAULT_PYTHON_VERSION} + + [Installing Poetry] + Using cached Poetry {POETRY_VERSION} + + [Installing dependencies using Poetry] + Using cached virtual environment + Running 'poetry install --sync --only main' + Installing dependencies from lock file + + No dependencies to install or update + "} + ); + }); + }); +} + +#[test] +#[ignore = "integration test"] +fn poetry_cache_invalidation_package_manager_changed() { + let config = default_build_config("tests/fixtures/pip_basic"); + let rebuild_config = default_build_config("tests/fixtures/poetry_basic"); + + TestRunner::default().build(config, |context| { + context.rebuild(rebuild_config, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + [Determining Python version] + No Python version specified, using the current default of Python {DEFAULT_PYTHON_VERSION}. + To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + + [Installing Python] + Using cached Python {DEFAULT_PYTHON_VERSION} + + [Installing Poetry] + Installing Poetry {POETRY_VERSION} + + [Installing dependencies using Poetry] + Creating virtual environment + Running 'poetry install --sync --only main' + Installing dependencies from lock file + + Package operations: 1 install, 0 updates, 0 removals + + - Installing typing-extensions (4.12.2) + "} + ); + }); + }); +} + +// This tests that cached layers from a previous buildpack version are compatible, or if we've +// decided to break compatibility recently, that the layers are at least invalidated gracefully. +#[test] +#[ignore = "integration test"] +fn poetry_cache_previous_buildpack_version() { + #![allow(unreachable_code)] + // TODO: Enable this test once a previous buildpack release exists that supports Poetry. + return; + + let mut config = default_build_config("tests/fixtures/poetry_basic"); + config.buildpacks([BuildpackReference::Other( + "docker://docker.io/heroku/buildpack-python:TODO".to_string(), + )]); + let rebuild_config = default_build_config("tests/fixtures/poetry_basic"); + + TestRunner::default().build(config, |context| { + context.rebuild(rebuild_config, |rebuild_context| { + assert_empty!(rebuild_context.pack_stderr); + assert_contains!( + rebuild_context.pack_stdout, + &formatdoc! {" + TODO + "} + ); + }); + }); +} + +// This tests that: +// - Git from the stack image can be found (ie: the system PATH has been correctly propagated to Poetry). +// - The editable mode repository clone is saved into the dependencies layer not the app dir. +// - Compiling a source distribution package (as opposed to a pre-built wheel) works. +// - The Python headers can be found in the `include/pythonX.Y/` directory of the Python layer. +#[test] +#[ignore = "integration test"] +fn poetry_editable_git_compiled() { + let config = default_build_config("tests/fixtures/poetry_editable_git_compiled"); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stdout, + indoc! {" + [Installing dependencies using Poetry] + Creating virtual environment + Running 'poetry install --sync --only main' + Installing dependencies from lock file + + Package operations: 1 install, 0 updates, 0 removals + + - Installing extension-dist (0.1 7bb46d7) + "} + ); + + let command_output = + context.run_shell_command("python -c 'import extension; print(extension)'"); + assert_empty!(command_output.stderr); + assert_contains!( + command_output.stdout, + "