Skip to content

Add support for the .python-version file #272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- The Python version can now be configured using a `.python-version` file. Both the `3.X` and `3.X.Y` version forms are supported. ([#272](https://github.com/heroku/buildpacks-python/pull/272))

### Changed

- pip is now only available during the build, and is longer included in the final app image. ([#264](https://github.com/heroku/buildpacks-python/pull/264))
- Improved the error messages shown when an end-of-life or unknown Python version is requested. ([#272](https://github.com/heroku/buildpacks-python/pull/272))

## [0.17.1] - 2024-09-07

Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,15 @@ A `requirements.txt` or `poetry.lock` file must be present in the root (top-leve

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 `.python-version` file to your app's root directory that declares the version number to use:

```term
$ cat runtime.txt
python-3.12.6
$ cat .python-version
3.12
```

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
Expand Down
161 changes: 129 additions & 32 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ 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};
use crate::runtime_txt::{ParseRuntimeTxtError, RuntimeTxtError};
use crate::python_version::{
RequestedPythonVersion, RequestedPythonVersionError, ResolvePythonVersionError,
DEFAULT_PYTHON_FULL_VERSION, DEFAULT_PYTHON_VERSION,
};
use crate::python_version_file::ParsePythonVersionFileError;
use crate::runtime_txt::ParseRuntimeTxtError;
use crate::utils::{CapturedCommandError, DownloadUnpackArchiveError, StreamedCommandError};
use crate::BuildpackError;
use indoc::{formatdoc, indoc};
Expand Down Expand Up @@ -53,7 +57,8 @@ fn on_buildpack_error(error: BuildpackError) {
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),
BuildpackError::RequestedPythonVersion(error) => on_requested_python_version_error(error),
BuildpackError::ResolvePythonVersion(error) => on_resolve_python_version_error(error),
};
}

Expand Down Expand Up @@ -117,47 +122,139 @@ fn on_determine_package_manager_error(error: DeterminePackageManagerError) {
};
}

fn on_python_version_error(error: PythonVersionError) {
fn on_requested_python_version_error(error: RequestedPythonVersionError) {
match error {
PythonVersionError::RuntimeTxt(error) => match error {
// TODO: (W-12613425) Write the supported Python versions inline, instead of linking out to Dev Center.
RuntimeTxtError::Parse(ParseRuntimeTxtError { cleaned_contents }) => {
let PythonVersion {
major,
minor,
patch,
} = DEFAULT_PYTHON_VERSION;
RequestedPythonVersionError::ReadPythonVersionFile(io_error) => log_io_error(
"Unable to read .python-version",
"reading the .python-version file",
&io_error,
),
RequestedPythonVersionError::ReadRuntimeTxt(io_error) => log_io_error(
"Unable to read runtime.txt",
"reading the runtime.txt file",
&io_error,
),
RequestedPythonVersionError::ParsePythonVersionFile(error) => match error {
ParsePythonVersionFileError::InvalidVersion(version) => log_error(
"Invalid Python version in .python-version",
formatdoc! {"
The Python version specified in '.python-version' is not in the correct format.

The following version was found:
{version}

However, the version must be specified as either:
1. '<major>.<minor>' (recommended, for automatic security updates)
2. '<major>.<minor>.<patch>' (to pin to an exact Python version)

Do not include quotes or a 'python-' prefix. To include comments, add them
on their own line, prefixed with '#'.

For example, to request the latest version of Python {DEFAULT_PYTHON_VERSION},
update the '.python-version' file so it contains:
{DEFAULT_PYTHON_VERSION}
"},
),
ParsePythonVersionFileError::MultipleVersions(versions) => {
let version_list = versions.join("\n");
log_error(
"Invalid Python version in runtime.txt",
"Invalid Python version in .python-version",
formatdoc! {"
The Python version specified in 'runtime.txt' is not in the correct format.

The following file contents were found:
{cleaned_contents}
Multiple Python versions were found in '.python-version':

However, the file contents must begin with a 'python-' prefix, followed by the
version specified as '<major>.<minor>.<patch>'. Comments are not supported.
{version_list}

For example, to request Python {DEFAULT_PYTHON_VERSION}, the correct version format is:
python-{major}.{minor}.{patch}
Update the file so it contains only one Python version.

Please update 'runtime.txt' to use the correct version format, or else remove
the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}).

For a list of the supported Python versions, see:
https://devcenter.heroku.com/articles/python-support#supported-runtimes
If the additional versions are actually comments, prefix those lines with '#'.
"},
);
}
RuntimeTxtError::Read(io_error) => log_io_error(
"Unable to read runtime.txt",
"reading the (optional) runtime.txt file",
&io_error,
ParsePythonVersionFileError::NoVersion => log_error(
"Invalid Python version in .python-version",
formatdoc! {"
No Python version was found in the '.python-version' file.

Update the file so that it contain a valid Python version (such as '{DEFAULT_PYTHON_VERSION}'),
or else delete the file to use the default version (currently Python {DEFAULT_PYTHON_VERSION}).

If the file already contains a version, check the line is not prefixed by
a '#', since otherwise it will be treated as a comment.
"},
),
},
RequestedPythonVersionError::ParseRuntimeTxt(ParseRuntimeTxtError { cleaned_contents }) => {
log_error(
"Invalid Python version in runtime.txt",
formatdoc! {"
The Python version specified in 'runtime.txt' is not in the correct format.

The following file contents were found:
{cleaned_contents}

However, the file contents must begin with a 'python-' prefix, followed by the
version specified as '<major>.<minor>.<patch>'. Comments are not supported.

For example, to request Python {DEFAULT_PYTHON_FULL_VERSION}, update the 'runtime.txt' file so it
contains exactly:
python-{DEFAULT_PYTHON_FULL_VERSION}
"},
);
}
};
}

fn on_resolve_python_version_error(error: ResolvePythonVersionError) {
match error {
ResolvePythonVersionError::EolVersion(requested_python_version) => {
let RequestedPythonVersion {
major,
minor,
origin,
..
} = requested_python_version;
log_error(
"Requested Python version has reached end-of-life",
formatdoc! {"
The requested Python version {major}.{minor} has reached its upstream end-of-life,
and is therefore no longer receiving security updates:
https://devguide.python.org/versions/#supported-versions

As such, it is no longer supported by this buildpack.

Please upgrade to a newer Python version by updating the version
configured via the {origin} file.

If possible, we recommend upgrading all the way to Python {DEFAULT_PYTHON_VERSION},
since it contains many performance and usability improvements.
"},
);
}
ResolvePythonVersionError::UnknownVersion(requested_python_version) => {
let RequestedPythonVersion {
major,
minor,
origin,
..
} = requested_python_version;
log_error(
"Requested Python version is not recognised",
formatdoc! {"
The requested Python version {major}.{minor} is not recognised.

Check that this Python version has been officially released:
https://devguide.python.org/versions/#supported-versions

If it has, make sure that you are using the latest version of this buildpack.

If it has not, please switch to a supported version (such as Python {DEFAULT_PYTHON_VERSION})
by updating the version configured via the {origin} file.
"},
);
}
}
}

fn on_python_layer_error(error: PythonLayerError) {
match error {
PythonLayerError::DownloadUnpackPythonArchive(error) => match error {
Expand Down Expand Up @@ -186,8 +283,8 @@ fn on_python_layer_error(error: PythonLayerError) {
formatdoc! {"
The requested Python version ({python_version}) is not available for this builder image.

Please update the version in 'runtime.txt' to a supported Python version, or else
remove the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}).
Please switch to a supported Python version, or else don't specify a version
and the buildpack will use a default version (currently Python {DEFAULT_PYTHON_VERSION}).

For a list of the supported Python versions, see:
https://devcenter.heroku.com/articles/python-support#supported-runtimes
Expand Down
9 changes: 1 addition & 8 deletions src/layers/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,14 +335,7 @@ mod tests {
base_env.insert("PYTHONHOME", "this-should-be-overridden");
base_env.insert("PYTHONUNBUFFERED", "this-should-be-overridden");

let layer_env = generate_layer_env(
Path::new("/layer-dir"),
&PythonVersion {
major: 3,
minor: 11,
patch: 1,
},
);
let layer_env = generate_layer_env(Path::new("/layer-dir"), &PythonVersion::new(3, 11, 1));

assert_eq!(
utils::environment_as_sorted_vector(&layer_env.apply(Scope::Build, &base_env)),
Expand Down
40 changes: 33 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod layers;
mod package_manager;
mod packaging_tool_versions;
mod python_version;
mod python_version_file;
mod runtime_txt;
mod utils;

Expand All @@ -16,7 +17,10 @@ use crate::layers::poetry_dependencies::PoetryDependenciesLayerError;
use crate::layers::python::PythonLayerError;
use crate::layers::{pip, pip_cache, pip_dependencies, poetry, poetry_dependencies, python};
use crate::package_manager::{DeterminePackageManagerError, PackageManager};
use crate::python_version::PythonVersionError;
use crate::python_version::{
PythonVersionOrigin, RequestedPythonVersionError, ResolvePythonVersionError,
};
use indoc::formatdoc;
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder};
use libcnb::generic::{GenericMetadata, GenericPlatform};
Expand Down Expand Up @@ -53,8 +57,28 @@ impl Buildpack for PythonBuildpack {
.map_err(BuildpackError::DeterminePackageManager)?;

log_header("Determining Python version");
let python_version = python_version::determine_python_version(&context.app_dir)
.map_err(BuildpackError::PythonVersion)?;

let requested_python_version =
python_version::read_requested_python_version(&context.app_dir)
.map_err(BuildpackError::RequestedPythonVersion)?;
let python_version = python_version::resolve_python_version(&requested_python_version)
.map_err(BuildpackError::ResolvePythonVersion)?;

match requested_python_version.origin {
PythonVersionOrigin::BuildpackDefault => log_info(formatdoc! {"
No Python version specified, using the current default of Python {requested_python_version}.
We recommend setting an explicit version. In the root of your app create
a '.python-version' file, containing a Python version like '{requested_python_version}'."
}),
PythonVersionOrigin::PythonVersionFile => log_info(format!(
"Using Python version {requested_python_version} specified in .python-version"
)),
// TODO: Add a deprecation message for runtime.txt once .python-version support has been
// released for both the CNB and the classic buildpack.
PythonVersionOrigin::RuntimeTxt => log_info(format!(
"Using Python version {requested_python_version} specified in runtime.txt"
)),
}

// We inherit the current process's env vars, since we want `PATH` and `HOME` from the OS
// to be set (so that later commands can find tools like Git in the base image), along
Expand Down Expand Up @@ -100,13 +124,13 @@ impl Buildpack for PythonBuildpack {

#[derive(Debug)]
pub(crate) enum BuildpackError {
/// IO errors when performing buildpack detection.
/// I/O errors when performing buildpack detection.
BuildpackDetection(io::Error),
/// Errors determining which Python package manager to use for a project.
DeterminePackageManager(DeterminePackageManagerError),
/// Errors running the Django collectstatic command.
DjangoCollectstatic(DjangoCollectstaticError),
/// IO errors when detecting whether Django is installed.
/// I/O errors when detecting whether Django is installed.
DjangoDetection(io::Error),
/// Errors installing the project's dependencies into a layer using pip.
PipDependenciesLayer(PipDependenciesLayerError),
Expand All @@ -118,8 +142,10 @@ pub(crate) enum BuildpackError {
PoetryLayer(PoetryLayerError),
/// Errors installing Python into a layer.
PythonLayer(PythonLayerError),
/// Errors determining which Python version to use for a project.
PythonVersion(PythonVersionError),
/// Errors determining which Python version was requested for a project.
RequestedPythonVersion(RequestedPythonVersionError),
/// Errors resolving a requested Python version to a specific Python version.
ResolvePythonVersion(ResolvePythonVersionError),
}

impl From<BuildpackError> for libcnb::Error<BuildpackError> {
Expand Down
Loading