Skip to content

Commit 5bafc43

Browse files
authored
Move pip into its own layer (#258)
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.
1 parent 43f66bc commit 5bafc43

File tree

11 files changed

+289
-195
lines changed

11 files changed

+289
-195
lines changed

CHANGELOG.md

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

1212
- App dependencies are now installed into a virtual environment instead of user site-packages. ([#257](https://github.com/heroku/buildpacks-python/pull/257))
13+
- 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))
1314

1415
## [0.15.0] - 2024-08-07
1516

src/errors.rs

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::django::DjangoCollectstaticError;
2+
use crate::layers::pip::PipLayerError;
23
use crate::layers::pip_dependencies::PipDependenciesLayerError;
34
use crate::layers::python::PythonLayerError;
45
use crate::package_manager::DeterminePackageManagerError;
@@ -46,6 +47,7 @@ fn on_buildpack_error(error: BuildpackError) {
4647
BuildpackError::DjangoCollectstatic(error) => on_django_collectstatic_error(error),
4748
BuildpackError::DjangoDetection(error) => on_django_detection_error(&error),
4849
BuildpackError::PipDependenciesLayer(error) => on_pip_dependencies_layer_error(error),
50+
BuildpackError::PipLayer(error) => on_pip_layer_error(error),
4951
BuildpackError::PythonLayer(error) => on_python_layer_error(error),
5052
BuildpackError::PythonVersion(error) => on_python_version_error(error),
5153
};
@@ -126,28 +128,6 @@ fn on_python_version_error(error: PythonVersionError) {
126128

127129
fn on_python_layer_error(error: PythonLayerError) {
128130
match error {
129-
PythonLayerError::BootstrapPipCommand(error) => match error {
130-
StreamedCommandError::Io(io_error) => log_io_error(
131-
"Unable to bootstrap pip",
132-
"running the command to install pip",
133-
&io_error,
134-
),
135-
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
136-
"Unable to bootstrap pip",
137-
formatdoc! {"
138-
The command to install pip did not exit successfully ({exit_status}).
139-
140-
See the log output above for more information.
141-
142-
In some cases, this happens due to an unstable network connection.
143-
Please try again to see if the error resolves itself.
144-
145-
If that does not help, check the status of PyPI (the upstream Python
146-
package repository service), here:
147-
https://status.python.org
148-
"},
149-
),
150-
},
151131
PythonLayerError::DownloadUnpackPythonArchive(error) => match error {
152132
DownloadUnpackArchiveError::Request(ureq_error) => log_error(
153133
"Unable to download Python",
@@ -166,11 +146,6 @@ fn on_python_layer_error(error: PythonLayerError) {
166146
&io_error,
167147
),
168148
},
169-
PythonLayerError::LocateBundledPip(io_error) => log_io_error(
170-
"Unable to locate the bundled copy of pip",
171-
"locating the pip wheel file bundled inside the Python 'ensurepip' module",
172-
&io_error,
173-
),
174149
// This error will change once the Python version is validated against a manifest.
175150
// TODO: (W-12613425) Write the supported Python versions inline, instead of linking out to Dev Center.
176151
// TODO: Decide how to explain to users how stacks, base images and builder images versions relate to each other.
@@ -189,6 +164,38 @@ fn on_python_layer_error(error: PythonLayerError) {
189164
};
190165
}
191166

167+
fn on_pip_layer_error(error: PipLayerError) {
168+
match error {
169+
PipLayerError::InstallPipCommand(error) => match error {
170+
StreamedCommandError::Io(io_error) => log_io_error(
171+
"Unable to install pip",
172+
"running 'python' to install pip",
173+
&io_error,
174+
),
175+
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
176+
"Unable to install pip",
177+
formatdoc! {"
178+
The command to install pip did not exit successfully ({exit_status}).
179+
180+
See the log output above for more information.
181+
182+
In some cases, this happens due to an unstable network connection.
183+
Please try again to see if the error resolves itself.
184+
185+
If that does not help, check the status of PyPI (the upstream Python
186+
package repository service), here:
187+
https://status.python.org
188+
"},
189+
),
190+
},
191+
PipLayerError::LocateBundledPip(io_error) => log_io_error(
192+
"Unable to locate the bundled copy of pip",
193+
"locating the pip wheel file bundled inside the Python 'ensurepip' module",
194+
&io_error,
195+
),
196+
};
197+
}
198+
192199
fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) {
193200
match error {
194201
PipDependenciesLayerError::CreateVenvCommand(error) => match error {
@@ -210,7 +217,7 @@ fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) {
210217
PipDependenciesLayerError::PipInstallCommand(error) => match error {
211218
StreamedCommandError::Io(io_error) => log_io_error(
212219
"Unable to install dependencies using pip",
213-
"running the 'pip install' command to install the application's dependencies",
220+
"running 'pip install' to install the app's dependencies",
214221
&io_error,
215222
),
216223
// TODO: Add more suggestions here as to causes (eg network, invalid requirements.txt,

src/layers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub(crate) mod pip;
12
pub(crate) mod pip_cache;
23
pub(crate) mod pip_dependencies;
34
pub(crate) mod python;

src/layers/pip.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
use crate::packaging_tool_versions::PIP_VERSION;
2+
use crate::python_version::PythonVersion;
3+
use crate::utils::StreamedCommandError;
4+
use crate::{utils, BuildpackError, PythonBuildpack};
5+
use libcnb::build::BuildContext;
6+
use libcnb::data::layer_name;
7+
use libcnb::layer::{
8+
CachedLayerDefinition, EmptyLayerCause, InvalidMetadataAction, LayerState, RestoredLayerAction,
9+
};
10+
use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope};
11+
use libcnb::Env;
12+
use libherokubuildpack::log::log_info;
13+
use serde::{Deserialize, Serialize};
14+
use std::io;
15+
use std::path::Path;
16+
use std::process::Command;
17+
18+
/// Creates a layer containing pip.
19+
pub(crate) fn install_pip(
20+
context: &BuildContext<PythonBuildpack>,
21+
env: &mut Env,
22+
python_version: &PythonVersion,
23+
python_layer_path: &Path,
24+
) -> Result<(), libcnb::Error<BuildpackError>> {
25+
let new_metadata = PipLayerMetadata {
26+
python_version: python_version.to_string(),
27+
pip_version: PIP_VERSION.to_string(),
28+
};
29+
30+
let layer = context.cached_layer(
31+
layer_name!("pip"),
32+
CachedLayerDefinition {
33+
build: true,
34+
launch: true,
35+
invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer,
36+
restored_layer_action: &|cached_metadata: &PipLayerMetadata, _| {
37+
let cached_pip_version = cached_metadata.pip_version.clone();
38+
if cached_metadata == &new_metadata {
39+
(RestoredLayerAction::KeepLayer, cached_pip_version)
40+
} else {
41+
(RestoredLayerAction::DeleteLayer, cached_pip_version)
42+
}
43+
},
44+
},
45+
)?;
46+
47+
let mut layer_env = LayerEnv::new()
48+
// We use a curated pip version, so disable the update check to speed up pip invocations,
49+
// reduce build log spam and prevent users from thinking they need to manually upgrade.
50+
// https://pip.pypa.io/en/stable/cli/pip/#cmdoption-disable-pip-version-check
51+
.chainable_insert(
52+
Scope::All,
53+
ModificationBehavior::Override,
54+
"PIP_DISABLE_PIP_VERSION_CHECK",
55+
"1",
56+
)
57+
// Move the Python user base directory to this layer instead of under HOME:
58+
// https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUSERBASE
59+
.chainable_insert(
60+
Scope::All,
61+
ModificationBehavior::Override,
62+
"PYTHONUSERBASE",
63+
layer.path(),
64+
);
65+
66+
match layer.state {
67+
LayerState::Restored {
68+
cause: ref cached_pip_version,
69+
} => {
70+
log_info(format!("Using cached pip {cached_pip_version}"));
71+
}
72+
LayerState::Empty { ref cause } => {
73+
match cause {
74+
EmptyLayerCause::InvalidMetadataAction { .. } => {
75+
log_info("Discarding cached pip since its layer metadata can't be parsed");
76+
}
77+
EmptyLayerCause::RestoredLayerAction {
78+
cause: cached_pip_version,
79+
} => {
80+
log_info(format!("Discarding cached pip {cached_pip_version}"));
81+
}
82+
EmptyLayerCause::NewlyCreated => {}
83+
}
84+
85+
log_info(format!("Installing pip {PIP_VERSION}"));
86+
87+
// We use the pip wheel bundled within Python's standard library to install our chosen
88+
// pip version, since it's faster than `ensurepip` followed by an upgrade in place.
89+
let bundled_pip_module_path =
90+
utils::bundled_pip_module_path(python_layer_path, python_version)
91+
.map_err(PipLayerError::LocateBundledPip)?;
92+
93+
utils::run_command_and_stream_output(
94+
Command::new("python")
95+
.args([
96+
&bundled_pip_module_path.to_string_lossy(),
97+
"install",
98+
// There is no point using pip's cache here, since the layer itself will be cached.
99+
"--no-cache-dir",
100+
"--no-input",
101+
"--no-warn-script-location",
102+
"--quiet",
103+
"--user",
104+
format!("pip=={PIP_VERSION}").as_str(),
105+
])
106+
.env_clear()
107+
.envs(&layer_env.apply(Scope::Build, env)),
108+
)
109+
.map_err(PipLayerError::InstallPipCommand)?;
110+
111+
layer.write_metadata(new_metadata)?;
112+
}
113+
}
114+
115+
layer.write_env(&layer_env)?;
116+
// Required to pick up the automatic PATH env var. See: https://github.com/heroku/libcnb.rs/issues/842
117+
layer_env = layer.read_env()?;
118+
env.clone_from(&layer_env.apply(Scope::Build, env));
119+
120+
Ok(())
121+
}
122+
123+
// pip's wheel is a pure Python package with no dependencies, so the layer is not arch or distro
124+
// specific. However, the generated .pyc files vary by Python version.
125+
#[derive(Deserialize, PartialEq, Serialize)]
126+
#[serde(deny_unknown_fields)]
127+
struct PipLayerMetadata {
128+
python_version: String,
129+
pip_version: String,
130+
}
131+
132+
/// Errors that can occur when installing pip into a layer.
133+
#[derive(Debug)]
134+
pub(crate) enum PipLayerError {
135+
InstallPipCommand(StreamedCommandError),
136+
LocateBundledPip(io::Error),
137+
}
138+
139+
impl From<PipLayerError> for libcnb::Error<BuildpackError> {
140+
fn from(error: PipLayerError) -> Self {
141+
Self::BuildpackError(BuildpackError::PipLayer(error))
142+
}
143+
}

src/layers/pip_cache.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ pub(crate) fn prepare_pip_cache(
3434
invalid_metadata_action: &|_| InvalidMetadataAction::DeleteLayer,
3535
restored_layer_action: &|cached_metadata: &PipCacheLayerMetadata, _| {
3636
if cached_metadata == &new_metadata {
37-
Ok(RestoredLayerAction::KeepLayer)
37+
RestoredLayerAction::KeepLayer
3838
} else {
39-
Ok(RestoredLayerAction::DeleteLayer)
39+
RestoredLayerAction::DeleteLayer
4040
}
4141
},
4242
},

0 commit comments

Comments
 (0)