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..f274829 --- /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 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, + "