Skip to content

Commit b6a853a

Browse files
committed
Add support for the .python-version file
This adds support for configuring the app's Python version using a `.python-version` file. This file is used by several tools in the Python ecosystem (such as pyenv, `actions/setup-python`, uv), whereas the existing `runtime.txt` file is proprietary to Heroku. We support the following `.python-version` syntax: - Major Python version (eg `3.12`, which will then be resolved to the latest Python 3.12). (This form is recommended, since it allows for Python security updates to be pulled in without having to manually bump the version.) - Exact Python version (eg `3.12.6`) - Comments (lines starting with `#`) - Blank lines We don't support the following `.python-version` features: - Specifying multiple Python versions - Prefixing versions with `python-` (since this form is undocumented and will likely be deprecated in the future) For now, if both a `runtime.txt` file and a `.python-version` file are present, then the `runtime.txt` file will take precedence. In the future, support for `runtime.txt` will eventually be deprecated (and eventually removed) in favour of the `.python-version` file. Since the `.python-version` file (unlike `runtime.txt`) supports specifying just the Python major version, adding support also required: - adding a mapping of major versions to the latest patch releases - explicit handling for EOL/unrecognised major versions - adding the concept of a "requested Python version" vs the resolved Python version (which should hopefully tie in well with use of a manifest in the future) In addition, the "origin" of a Python version now has to be tracked, so that build output can state which file was used, or in the case of invalid version errors, which file needs fixing by the user. Closes #6. Closes #9. GUS-W-12151504. GUS-W-11475071.
1 parent 05aa01e commit b6a853a

File tree

41 files changed

+996
-321
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+996
-321
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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))
13+
1014
### Changed
1115

1216
- 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))
17+
- 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))
1318

1419
## [0.17.1] - 2024-09-07
1520

README.md

+3-4
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,15 @@ A `requirements.txt` or `poetry.lock` file must be present in the root (top-leve
3939

4040
By default, the buildpack will install the latest version of Python 3.12.
4141

42-
To install a different version, add a `runtime.txt` file to your app's root directory that declares the exact version number to use:
42+
To install a different version, add a `.python-version` file to your app's root directory that declares the version number to use:
4343

4444
```term
45-
$ cat runtime.txt
46-
python-3.12.6
45+
$ cat .python-version
46+
3.12
4747
```
4848

4949
In the future this buildpack will also support specifying the Python version using:
5050

51-
- A `.python-version` file: [#6](https://github.com/heroku/buildpacks-python/issues/6)
5251
- `tool.poetry.dependencies.python` in `pyproject.toml`: [#260](https://github.com/heroku/buildpacks-python/issues/260)
5352

5453
## Contributing

src/errors.rs

+129-32
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ use crate::layers::poetry::PoetryLayerError;
55
use crate::layers::poetry_dependencies::PoetryDependenciesLayerError;
66
use crate::layers::python::PythonLayerError;
77
use crate::package_manager::DeterminePackageManagerError;
8-
use crate::python_version::{PythonVersion, PythonVersionError, DEFAULT_PYTHON_VERSION};
9-
use crate::runtime_txt::{ParseRuntimeTxtError, RuntimeTxtError};
8+
use crate::python_version::{
9+
RequestedPythonVersion, RequestedPythonVersionError, ResolvePythonVersionError,
10+
DEFAULT_PYTHON_FULL_VERSION, DEFAULT_PYTHON_VERSION,
11+
};
12+
use crate::python_version_file::ParsePythonVersionFileError;
13+
use crate::runtime_txt::ParseRuntimeTxtError;
1014
use crate::utils::{CapturedCommandError, DownloadUnpackArchiveError, StreamedCommandError};
1115
use crate::BuildpackError;
1216
use indoc::{formatdoc, indoc};
@@ -53,7 +57,8 @@ fn on_buildpack_error(error: BuildpackError) {
5357
BuildpackError::PoetryDependenciesLayer(error) => on_poetry_dependencies_layer_error(error),
5458
BuildpackError::PoetryLayer(error) => on_poetry_layer_error(error),
5559
BuildpackError::PythonLayer(error) => on_python_layer_error(error),
56-
BuildpackError::PythonVersion(error) => on_python_version_error(error),
60+
BuildpackError::RequestedPythonVersion(error) => on_requested_python_version_error(error),
61+
BuildpackError::ResolvePythonVersion(error) => on_resolve_python_version_error(error),
5762
};
5863
}
5964

@@ -117,47 +122,139 @@ fn on_determine_package_manager_error(error: DeterminePackageManagerError) {
117122
};
118123
}
119124

120-
fn on_python_version_error(error: PythonVersionError) {
125+
fn on_requested_python_version_error(error: RequestedPythonVersionError) {
121126
match error {
122-
PythonVersionError::RuntimeTxt(error) => match error {
123-
// TODO: (W-12613425) Write the supported Python versions inline, instead of linking out to Dev Center.
124-
RuntimeTxtError::Parse(ParseRuntimeTxtError { cleaned_contents }) => {
125-
let PythonVersion {
126-
major,
127-
minor,
128-
patch,
129-
} = DEFAULT_PYTHON_VERSION;
127+
RequestedPythonVersionError::ReadPythonVersionFile(io_error) => log_io_error(
128+
"Unable to read .python-version",
129+
"reading the .python-version file",
130+
&io_error,
131+
),
132+
RequestedPythonVersionError::ReadRuntimeTxt(io_error) => log_io_error(
133+
"Unable to read runtime.txt",
134+
"reading the runtime.txt file",
135+
&io_error,
136+
),
137+
RequestedPythonVersionError::ParsePythonVersionFile(error) => match error {
138+
ParsePythonVersionFileError::InvalidVersion(version) => log_error(
139+
"Invalid Python version in .python-version",
140+
formatdoc! {"
141+
The Python version specified in '.python-version' is not in the correct format.
142+
143+
The following version was found:
144+
{version}
145+
146+
However, the version must be specified as either:
147+
1. '<major>.<minor>' (recommended, for automatic security updates)
148+
2. '<major>.<minor>.<patch>' (to pin to an exact Python version)
149+
150+
Do not include quotes or a 'python-' prefix. To include comments, add them
151+
on their own line, prefixed with '#'.
152+
153+
For example, to request the latest version of Python {DEFAULT_PYTHON_VERSION},
154+
update the '.python-version' file so it contains:
155+
{DEFAULT_PYTHON_VERSION}
156+
"},
157+
),
158+
ParsePythonVersionFileError::MultipleVersions(versions) => {
159+
let version_list = versions.join("\n");
130160
log_error(
131-
"Invalid Python version in runtime.txt",
161+
"Invalid Python version in .python-version",
132162
formatdoc! {"
133-
The Python version specified in 'runtime.txt' is not in the correct format.
134-
135-
The following file contents were found:
136-
{cleaned_contents}
163+
Multiple Python versions were found in '.python-version':
137164
138-
However, the file contents must begin with a 'python-' prefix, followed by the
139-
version specified as '<major>.<minor>.<patch>'. Comments are not supported.
165+
{version_list}
140166
141-
For example, to request Python {DEFAULT_PYTHON_VERSION}, the correct version format is:
142-
python-{major}.{minor}.{patch}
167+
Update the file so it contains only one Python version.
143168
144-
Please update 'runtime.txt' to use the correct version format, or else remove
145-
the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}).
146-
147-
For a list of the supported Python versions, see:
148-
https://devcenter.heroku.com/articles/python-support#supported-runtimes
169+
If the additional versions are actually comments, prefix those lines with '#'.
149170
"},
150171
);
151172
}
152-
RuntimeTxtError::Read(io_error) => log_io_error(
153-
"Unable to read runtime.txt",
154-
"reading the (optional) runtime.txt file",
155-
&io_error,
173+
ParsePythonVersionFileError::NoVersion => log_error(
174+
"Invalid Python version in .python-version",
175+
formatdoc! {"
176+
No Python version was found in the '.python-version' file.
177+
178+
Update the file so that it contain a valid Python version (such as '{DEFAULT_PYTHON_VERSION}'),
179+
or else delete the file to use the default version (currently Python {DEFAULT_PYTHON_VERSION}).
180+
181+
If the file already contains a version, check the line is not prefixed by
182+
a '#', since otherwise it will be treated as a comment.
183+
"},
156184
),
157185
},
186+
RequestedPythonVersionError::ParseRuntimeTxt(ParseRuntimeTxtError { cleaned_contents }) => {
187+
log_error(
188+
"Invalid Python version in runtime.txt",
189+
formatdoc! {"
190+
The Python version specified in 'runtime.txt' is not in the correct format.
191+
192+
The following file contents were found:
193+
{cleaned_contents}
194+
195+
However, the file contents must begin with a 'python-' prefix, followed by the
196+
version specified as '<major>.<minor>.<patch>'. Comments are not supported.
197+
198+
For example, to request Python {DEFAULT_PYTHON_FULL_VERSION}, update the 'runtime.txt' file so it
199+
contains exactly:
200+
python-{DEFAULT_PYTHON_FULL_VERSION}
201+
"},
202+
);
203+
}
158204
};
159205
}
160206

207+
fn on_resolve_python_version_error(error: ResolvePythonVersionError) {
208+
match error {
209+
ResolvePythonVersionError::EolVersion(requested_python_version) => {
210+
let RequestedPythonVersion {
211+
major,
212+
minor,
213+
origin,
214+
..
215+
} = requested_python_version;
216+
log_error(
217+
"Requested Python version has reached end-of-life",
218+
formatdoc! {"
219+
The requested Python version {major}.{minor} has reached its upstream end-of-life,
220+
and is therefore no longer receiving security updates:
221+
https://devguide.python.org/versions/#supported-versions
222+
223+
As such, it is no longer supported by this buildpack.
224+
225+
Please upgrade to a newer Python version by updating the version
226+
configured via the {origin} file.
227+
228+
If possible, we recommend upgrading all the way to Python {DEFAULT_PYTHON_VERSION},
229+
since it contains many performance and usability improvements.
230+
"},
231+
);
232+
}
233+
ResolvePythonVersionError::UnknownVersion(requested_python_version) => {
234+
let RequestedPythonVersion {
235+
major,
236+
minor,
237+
origin,
238+
..
239+
} = requested_python_version;
240+
log_error(
241+
"Requested Python version is not recognised",
242+
formatdoc! {"
243+
The requested Python version {major}.{minor} is not recognised.
244+
245+
Check that this Python version has been officially released:
246+
https://devguide.python.org/versions/#supported-versions
247+
248+
If it has, make sure that you are using the latest version of this buildpack.
249+
250+
If it has not, please switch to a supported version (such as Python {DEFAULT_PYTHON_VERSION})
251+
by updating the version configured via the {origin} file.
252+
"},
253+
);
254+
}
255+
}
256+
}
257+
161258
fn on_python_layer_error(error: PythonLayerError) {
162259
match error {
163260
PythonLayerError::DownloadUnpackPythonArchive(error) => match error {
@@ -186,8 +283,8 @@ fn on_python_layer_error(error: PythonLayerError) {
186283
formatdoc! {"
187284
The requested Python version ({python_version}) is not available for this builder image.
188285
189-
Please update the version in 'runtime.txt' to a supported Python version, or else
190-
remove the file to instead use the default version (currently Python {DEFAULT_PYTHON_VERSION}).
286+
Please switch to a supported Python version, or else don't specify a version
287+
and the buildpack will use a default version (currently Python {DEFAULT_PYTHON_VERSION}).
191288
192289
For a list of the supported Python versions, see:
193290
https://devcenter.heroku.com/articles/python-support#supported-runtimes

src/layers/python.rs

+1-8
Original file line numberDiff line numberDiff line change
@@ -335,14 +335,7 @@ mod tests {
335335
base_env.insert("PYTHONHOME", "this-should-be-overridden");
336336
base_env.insert("PYTHONUNBUFFERED", "this-should-be-overridden");
337337

338-
let layer_env = generate_layer_env(
339-
Path::new("/layer-dir"),
340-
&PythonVersion {
341-
major: 3,
342-
minor: 11,
343-
patch: 1,
344-
},
345-
);
338+
let layer_env = generate_layer_env(Path::new("/layer-dir"), &PythonVersion::new(3, 11, 1));
346339

347340
assert_eq!(
348341
utils::environment_as_sorted_vector(&layer_env.apply(Scope::Build, &base_env)),

src/main.rs

+33-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod layers;
55
mod package_manager;
66
mod packaging_tool_versions;
77
mod python_version;
8+
mod python_version_file;
89
mod runtime_txt;
910
mod utils;
1011

@@ -16,7 +17,10 @@ use crate::layers::poetry_dependencies::PoetryDependenciesLayerError;
1617
use crate::layers::python::PythonLayerError;
1718
use crate::layers::{pip, pip_cache, pip_dependencies, poetry, poetry_dependencies, python};
1819
use crate::package_manager::{DeterminePackageManagerError, PackageManager};
19-
use crate::python_version::PythonVersionError;
20+
use crate::python_version::{
21+
PythonVersionOrigin, RequestedPythonVersionError, ResolvePythonVersionError,
22+
};
23+
use indoc::formatdoc;
2024
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
2125
use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder};
2226
use libcnb::generic::{GenericMetadata, GenericPlatform};
@@ -53,8 +57,28 @@ impl Buildpack for PythonBuildpack {
5357
.map_err(BuildpackError::DeterminePackageManager)?;
5458

5559
log_header("Determining Python version");
56-
let python_version = python_version::determine_python_version(&context.app_dir)
57-
.map_err(BuildpackError::PythonVersion)?;
60+
61+
let requested_python_version =
62+
python_version::read_requested_python_version(&context.app_dir)
63+
.map_err(BuildpackError::RequestedPythonVersion)?;
64+
let python_version = python_version::resolve_python_version(&requested_python_version)
65+
.map_err(BuildpackError::ResolvePythonVersion)?;
66+
67+
match requested_python_version.origin {
68+
PythonVersionOrigin::BuildpackDefault => log_info(formatdoc! {"
69+
No Python version specified, using the current default of Python {requested_python_version}.
70+
We recommend setting an explicit version. In the root of your app create
71+
a '.python-version' file, containing a Python version like '{requested_python_version}'."
72+
}),
73+
PythonVersionOrigin::PythonVersionFile => log_info(format!(
74+
"Using Python version {requested_python_version} specified in .python-version"
75+
)),
76+
// TODO: Add a deprecation message for runtime.txt once .python-version support has been
77+
// released for both the CNB and the classic buildpack.
78+
PythonVersionOrigin::RuntimeTxt => log_info(format!(
79+
"Using Python version {requested_python_version} specified in runtime.txt"
80+
)),
81+
}
5882

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

101125
#[derive(Debug)]
102126
pub(crate) enum BuildpackError {
103-
/// IO errors when performing buildpack detection.
127+
/// I/O errors when performing buildpack detection.
104128
BuildpackDetection(io::Error),
105129
/// Errors determining which Python package manager to use for a project.
106130
DeterminePackageManager(DeterminePackageManagerError),
107131
/// Errors running the Django collectstatic command.
108132
DjangoCollectstatic(DjangoCollectstaticError),
109-
/// IO errors when detecting whether Django is installed.
133+
/// I/O errors when detecting whether Django is installed.
110134
DjangoDetection(io::Error),
111135
/// Errors installing the project's dependencies into a layer using pip.
112136
PipDependenciesLayer(PipDependenciesLayerError),
@@ -118,8 +142,10 @@ pub(crate) enum BuildpackError {
118142
PoetryLayer(PoetryLayerError),
119143
/// Errors installing Python into a layer.
120144
PythonLayer(PythonLayerError),
121-
/// Errors determining which Python version to use for a project.
122-
PythonVersion(PythonVersionError),
145+
/// Errors determining which Python version was requested for a project.
146+
RequestedPythonVersion(RequestedPythonVersionError),
147+
/// Errors resolving a requested Python version to a specific Python version.
148+
ResolvePythonVersion(ResolvePythonVersionError),
123149
}
124150

125151
impl From<BuildpackError> for libcnb::Error<BuildpackError> {

0 commit comments

Comments
 (0)