Skip to content

Commit

Permalink
Move pip into its own layer (#258)
Browse files Browse the repository at this point in the history
pip is now installed into its own layer (as a user site-packages
install) instead of into system site-packages in the Python layer.

This is possible now that the user site-packages location is no longer
being used for app dependencies, after the switch to venvs in #257.

pip being in its own layer has the following advantages:
1. We can more easily exclude pip from the build/run images when using
   other packages managers (such as for the upcoming Poetry support).
2. A change in pip version no longer unnecessarily invalidates the
   Python layer.
3. In the future we could more easily exclude pip from the run image
   entirely, should we wish (see #255).

This has been split out of the Poetry PR for easier review.

Closes #254.
GUS-W-16616956.
  • Loading branch information
edmorley authored Aug 30, 2024
1 parent 43f66bc commit 5bafc43
Show file tree
Hide file tree
Showing 11 changed files with 289 additions and 195 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- App dependencies are now installed into a virtual environment instead of user site-packages. ([#257](https://github.com/heroku/buildpacks-python/pull/257))
- pip is now installed into its own layer (as a user site-packages install) instead of into system site-packages in the Python layer. ([#258](https://github.com/heroku/buildpacks-python/pull/258))

## [0.15.0] - 2024-08-07

Expand Down
63 changes: 35 additions & 28 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::django::DjangoCollectstaticError;
use crate::layers::pip::PipLayerError;
use crate::layers::pip_dependencies::PipDependenciesLayerError;
use crate::layers::python::PythonLayerError;
use crate::package_manager::DeterminePackageManagerError;
Expand Down Expand Up @@ -46,6 +47,7 @@ fn on_buildpack_error(error: BuildpackError) {
BuildpackError::DjangoCollectstatic(error) => on_django_collectstatic_error(error),
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::PythonLayer(error) => on_python_layer_error(error),
BuildpackError::PythonVersion(error) => on_python_version_error(error),
};
Expand Down Expand Up @@ -126,28 +128,6 @@ fn on_python_version_error(error: PythonVersionError) {

fn on_python_layer_error(error: PythonLayerError) {
match error {
PythonLayerError::BootstrapPipCommand(error) => match error {
StreamedCommandError::Io(io_error) => log_io_error(
"Unable to bootstrap pip",
"running the command to install pip",
&io_error,
),
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
"Unable to bootstrap pip",
formatdoc! {"
The command to install pip 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
"},
),
},
PythonLayerError::DownloadUnpackPythonArchive(error) => match error {
DownloadUnpackArchiveError::Request(ureq_error) => log_error(
"Unable to download Python",
Expand All @@ -166,11 +146,6 @@ fn on_python_layer_error(error: PythonLayerError) {
&io_error,
),
},
PythonLayerError::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,
),
// This error will change once the Python version is validated against a manifest.
// TODO: (W-12613425) Write the supported Python versions inline, instead of linking out to Dev Center.
// TODO: Decide how to explain to users how stacks, base images and builder images versions relate to each other.
Expand All @@ -189,6 +164,38 @@ fn on_python_layer_error(error: PythonLayerError) {
};
}

fn on_pip_layer_error(error: PipLayerError) {
match error {
PipLayerError::InstallPipCommand(error) => match error {
StreamedCommandError::Io(io_error) => log_io_error(
"Unable to install pip",
"running 'python' to install pip",
&io_error,
),
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
"Unable to install pip",
formatdoc! {"
The command to install pip 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
"},
),
},
PipLayerError::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_pip_dependencies_layer_error(error: PipDependenciesLayerError) {
match error {
PipDependenciesLayerError::CreateVenvCommand(error) => match error {
Expand All @@ -210,7 +217,7 @@ fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) {
PipDependenciesLayerError::PipInstallCommand(error) => match error {
StreamedCommandError::Io(io_error) => log_io_error(
"Unable to install dependencies using pip",
"running the 'pip install' command to install the application's dependencies",
"running 'pip install' to install the app's dependencies",
&io_error,
),
// TODO: Add more suggestions here as to causes (eg network, invalid requirements.txt,
Expand Down
1 change: 1 addition & 0 deletions src/layers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod pip;
pub(crate) mod pip_cache;
pub(crate) mod pip_dependencies;
pub(crate) mod python;
143 changes: 143 additions & 0 deletions src/layers/pip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use crate::packaging_tool_versions::PIP_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 layer containing pip.
pub(crate) fn install_pip(
context: &BuildContext<PythonBuildpack>,
env: &mut Env,
python_version: &PythonVersion,
python_layer_path: &Path,
) -> Result<(), libcnb::Error<BuildpackError>> {
let new_metadata = PipLayerMetadata {
python_version: python_version.to_string(),
pip_version: PIP_VERSION.to_string(),
};

let layer = context.cached_layer(
layer_name!("pip"),
CachedLayerDefinition {
build: true,
launch: true,
invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer,
restored_layer_action: &|cached_metadata: &PipLayerMetadata, _| {
let cached_pip_version = cached_metadata.pip_version.clone();
if cached_metadata == &new_metadata {
(RestoredLayerAction::KeepLayer, cached_pip_version)
} else {
(RestoredLayerAction::DeleteLayer, cached_pip_version)
}
},
},
)?;

let mut layer_env = LayerEnv::new()
// We use a curated pip version, so disable the update check to speed up pip invocations,
// reduce build log spam and prevent users from thinking they need to manually upgrade.
// https://pip.pypa.io/en/stable/cli/pip/#cmdoption-disable-pip-version-check
.chainable_insert(
Scope::All,
ModificationBehavior::Override,
"PIP_DISABLE_PIP_VERSION_CHECK",
"1",
)
// Move the Python user base directory to this layer instead of under HOME:
// https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUSERBASE
.chainable_insert(
Scope::All,
ModificationBehavior::Override,
"PYTHONUSERBASE",
layer.path(),
);

match layer.state {
LayerState::Restored {
cause: ref cached_pip_version,
} => {
log_info(format!("Using cached pip {cached_pip_version}"));
}
LayerState::Empty { ref cause } => {
match cause {
EmptyLayerCause::InvalidMetadataAction { .. } => {
log_info("Discarding cached pip since its layer metadata can't be parsed");
}
EmptyLayerCause::RestoredLayerAction {
cause: cached_pip_version,
} => {
log_info(format!("Discarding cached pip {cached_pip_version}"));
}
EmptyLayerCause::NewlyCreated => {}
}

log_info(format!("Installing pip {PIP_VERSION}"));

// We use the pip wheel bundled within Python's standard library to install our chosen
// pip version, since it's faster than `ensurepip` followed by an upgrade in place.
let bundled_pip_module_path =
utils::bundled_pip_module_path(python_layer_path, python_version)
.map_err(PipLayerError::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!("pip=={PIP_VERSION}").as_str(),
])
.env_clear()
.envs(&layer_env.apply(Scope::Build, env)),
)
.map_err(PipLayerError::InstallPipCommand)?;

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(())
}

// pip's wheel is a pure Python package with no dependencies, so the layer is not arch or distro
// specific. However, the generated .pyc files vary by Python version.
#[derive(Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
struct PipLayerMetadata {
python_version: String,
pip_version: String,
}

/// Errors that can occur when installing pip into a layer.
#[derive(Debug)]
pub(crate) enum PipLayerError {
InstallPipCommand(StreamedCommandError),
LocateBundledPip(io::Error),
}

impl From<PipLayerError> for libcnb::Error<BuildpackError> {
fn from(error: PipLayerError) -> Self {
Self::BuildpackError(BuildpackError::PipLayer(error))
}
}
4 changes: 2 additions & 2 deletions src/layers/pip_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ pub(crate) fn prepare_pip_cache(
invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer,
restored_layer_action: &|cached_metadata: &PipCacheLayerMetadata, _| {
if cached_metadata == &new_metadata {
Ok(RestoredLayerAction::KeepLayer)
RestoredLayerAction::KeepLayer
} else {
Ok(RestoredLayerAction::DeleteLayer)
RestoredLayerAction::DeleteLayer
}
},
},
Expand Down
Loading

0 comments on commit 5bafc43

Please sign in to comment.