From 5b889110be55743f7747e160becea70b90b3c3b9 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Mon, 16 Sep 2024 20:07:46 +0100 Subject: [PATCH] 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. 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 be deprecated (and eventually removed) in favour of the `.python-version` file. We support the following `.python-version` syntax: - Major Python version (e.g. `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 (e.g. `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) 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 to be fixed by the user. Closes #6. Closes #9. GUS-W-12151504. GUS-W-11475071. --- CHANGELOG.md | 5 + README.md | 7 +- src/errors.rs | 161 ++++++-- src/layers/python.rs | 9 +- src/main.rs | 40 +- src/python_version.rs | 335 ++++++++++++++-- src/python_version_file.rs | 184 +++++++++ src/runtime_txt.rs | 106 ++--- src/utils.rs | 6 +- .../.python-version | 1 + .../runtime.txt | 1 - tests/fixtures/python_3.10/.python-version | 1 + tests/fixtures/python_3.10/runtime.txt | 1 - tests/fixtures/python_3.11/.python-version | 1 + tests/fixtures/python_3.11/runtime.txt | 1 - tests/fixtures/python_3.12/.python-version | 1 + tests/fixtures/python_3.12/runtime.txt | 1 - tests/fixtures/python_3.7/.python-version | 1 + tests/fixtures/python_3.7/runtime.txt | 1 - tests/fixtures/python_3.8/.python-version | 1 + tests/fixtures/python_3.8/runtime.txt | 1 - tests/fixtures/python_3.9/.python-version | 1 + tests/fixtures/python_3.9/runtime.txt | 1 - .../.python-version | 1 + .../requirements.txt | 0 .../.python-version | 1 + .../requirements.txt | 0 .../.python-version | 3 + .../requirements.txt | 0 .../.python-version | 1 + .../requirements.txt | 0 .../.python-version | 1 + .../requirements.txt | 0 .../.python-version | 1 + .../requirements.txt | 0 .../runtime.txt | 1 + .../runtime.txt | 1 - tests/mod.rs | 8 - tests/pip_test.rs | 25 +- tests/poetry_test.rs | 39 +- tests/python_version_test.rs | 368 +++++++++++++----- 41 files changed, 996 insertions(+), 321 deletions(-) create mode 100644 src/python_version_file.rs create mode 100644 tests/fixtures/django_staticfiles_legacy_django/.python-version delete mode 100644 tests/fixtures/django_staticfiles_legacy_django/runtime.txt create mode 100644 tests/fixtures/python_3.10/.python-version delete mode 100644 tests/fixtures/python_3.10/runtime.txt create mode 100644 tests/fixtures/python_3.11/.python-version delete mode 100644 tests/fixtures/python_3.11/runtime.txt create mode 100644 tests/fixtures/python_3.12/.python-version delete mode 100644 tests/fixtures/python_3.12/runtime.txt create mode 100644 tests/fixtures/python_3.7/.python-version delete mode 100644 tests/fixtures/python_3.7/runtime.txt create mode 100644 tests/fixtures/python_3.8/.python-version delete mode 100644 tests/fixtures/python_3.8/runtime.txt create mode 100644 tests/fixtures/python_3.9/.python-version delete mode 100644 tests/fixtures/python_3.9/runtime.txt create mode 100644 tests/fixtures/python_version_file_invalid_unicode/.python-version rename tests/fixtures/{runtime_txt_non_existent_version => python_version_file_invalid_unicode}/requirements.txt (100%) create mode 100644 tests/fixtures/python_version_file_invalid_version/.python-version create mode 100644 tests/fixtures/python_version_file_invalid_version/requirements.txt create mode 100644 tests/fixtures/python_version_file_multiple_versions/.python-version create mode 100644 tests/fixtures/python_version_file_multiple_versions/requirements.txt create mode 100644 tests/fixtures/python_version_file_no_version/.python-version create mode 100644 tests/fixtures/python_version_file_no_version/requirements.txt create mode 100644 tests/fixtures/python_version_file_unknown_version/.python-version create mode 100644 tests/fixtures/python_version_file_unknown_version/requirements.txt create mode 100644 tests/fixtures/runtime_txt_and_python_version_file/.python-version create mode 100644 tests/fixtures/runtime_txt_and_python_version_file/requirements.txt create mode 100644 tests/fixtures/runtime_txt_and_python_version_file/runtime.txt delete mode 100644 tests/fixtures/runtime_txt_non_existent_version/runtime.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6f277..c8b6de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b52c35c..237bc20 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/errors.rs b/src/errors.rs index 58e8050..2c49051 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -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}; @@ -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), }; } @@ -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. '.' (recommended, for automatic security updates) + 2. '..' (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 '..'. 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 '..'. 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 { @@ -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 diff --git a/src/layers/python.rs b/src/layers/python.rs index bd8438c..368f794 100644 --- a/src/layers/python.rs +++ b/src/layers/python.rs @@ -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)), diff --git a/src/main.rs b/src/main.rs index dcb456f..6ce43d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; @@ -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}; @@ -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 @@ -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), @@ -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 for libcnb::Error { diff --git a/src/python_version.rs b/src/python_version.rs index c971fef..ac1afea 100644 --- a/src/python_version.rs +++ b/src/python_version.rs @@ -1,16 +1,68 @@ -use crate::runtime_txt::{self, RuntimeTxtError}; -use indoc::formatdoc; +use crate::python_version_file::{self, ParsePythonVersionFileError}; +use crate::runtime_txt::{self, ParseRuntimeTxtError}; +use crate::utils; use libcnb::Target; -use libherokubuildpack::log::log_info; use std::fmt::{self, Display}; +use std::io; use std::path::Path; /// The Python version that will be installed if the project does not specify an explicit version. -pub(crate) const DEFAULT_PYTHON_VERSION: PythonVersion = PythonVersion { +pub(crate) const DEFAULT_PYTHON_VERSION: RequestedPythonVersion = RequestedPythonVersion { major: 3, minor: 12, - patch: 6, + patch: None, + origin: PythonVersionOrigin::BuildpackDefault, }; +pub(crate) const DEFAULT_PYTHON_FULL_VERSION: PythonVersion = LATEST_PYTHON_3_12; + +pub(crate) const LATEST_PYTHON_3_8: PythonVersion = PythonVersion::new(3, 8, 20); +pub(crate) const LATEST_PYTHON_3_9: PythonVersion = PythonVersion::new(3, 9, 20); +pub(crate) const LATEST_PYTHON_3_10: PythonVersion = PythonVersion::new(3, 10, 15); +pub(crate) const LATEST_PYTHON_3_11: PythonVersion = PythonVersion::new(3, 11, 10); +pub(crate) const LATEST_PYTHON_3_12: PythonVersion = PythonVersion::new(3, 12, 6); + +/// The Python version that was requested for a project. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RequestedPythonVersion { + pub(crate) major: u16, + pub(crate) minor: u16, + pub(crate) patch: Option, + pub(crate) origin: PythonVersionOrigin, +} + +impl Display for RequestedPythonVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + major, + minor, + patch, + .. + } = self; + if let Some(patch) = patch { + write!(f, "{major}.{minor}.{patch}") + } else { + write!(f, "{major}.{minor}") + } + } +} + +/// The origin of the [`RequestedPythonVersion`]. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PythonVersionOrigin { + BuildpackDefault, + PythonVersionFile, + RuntimeTxt, +} + +impl Display for PythonVersionOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BuildpackDefault => write!(f, "buildpack default"), + Self::PythonVersionFile => write!(f, ".python-version"), + Self::RuntimeTxt => write!(f, "runtime.txt"), + } + } +} /// Representation of a specific Python `X.Y.Z` version. #[derive(Clone, Debug, PartialEq)] @@ -21,7 +73,7 @@ pub(crate) struct PythonVersion { } impl PythonVersion { - pub(crate) fn new(major: u16, minor: u16, patch: u16) -> Self { + pub(crate) const fn new(major: u16, minor: u16, patch: u16) -> Self { Self { major, minor, @@ -50,46 +102,88 @@ impl PythonVersion { impl Display for PythonVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + let Self { + major, + minor, + patch, + } = self; + write!(f, "{major}.{minor}.{patch}") } } -/// Determine the Python version that should be installed for the project. +/// Determine the Python version that has been requested for the project. /// /// If no known version specifier file is found a default Python version will be used. -pub(crate) fn determine_python_version( +pub(crate) fn read_requested_python_version( app_dir: &Path, -) -> Result { - if let Some(runtime_txt_version) = - runtime_txt::read_version(app_dir).map_err(PythonVersionError::RuntimeTxt)? +) -> Result { + if let Some(contents) = utils::read_optional_file(&app_dir.join("runtime.txt")) + .map_err(RequestedPythonVersionError::ReadRuntimeTxt)? { - // TODO: Consider passing this back as a `source` field on PythonVersion - // so this can be logged by the caller. - log_info(format!( - "Using Python version {runtime_txt_version} specified in runtime.txt" - )); - return Ok(runtime_txt_version); + runtime_txt::parse(&contents).map_err(RequestedPythonVersionError::ParseRuntimeTxt) + } else if let Some(contents) = utils::read_optional_file(&app_dir.join(".python-version")) + .map_err(RequestedPythonVersionError::ReadPythonVersionFile)? + { + python_version_file::parse(&contents) + .map_err(RequestedPythonVersionError::ParsePythonVersionFile) + } else { + Ok(DEFAULT_PYTHON_VERSION) } - - // TODO: (W-12613425) Write this content inline, instead of linking out to Dev Center. - // Also adjust wording to mention pinning as a use-case, not just using a different version. - log_info(formatdoc! {" - 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"}); - Ok(DEFAULT_PYTHON_VERSION) } -/// Errors that can occur when determining which Python version to use for a project. +/// Errors that can occur when determining which Python version was requested for a project. #[derive(Debug)] -pub(crate) enum PythonVersionError { - /// Errors reading and parsing a `runtime.txt` file. - RuntimeTxt(RuntimeTxtError), +pub(crate) enum RequestedPythonVersionError { + /// Errors parsing a `.python-version` file. + ParsePythonVersionFile(ParsePythonVersionFileError), + /// Errors parsing a `runtime.txt` file. + ParseRuntimeTxt(ParseRuntimeTxtError), + /// Errors reading a `.python-version` file. + ReadPythonVersionFile(io::Error), + /// Errors reading a `runtime.txt` file. + ReadRuntimeTxt(io::Error), +} + +pub(crate) fn resolve_python_version( + requested_python_version: &RequestedPythonVersion, +) -> Result { + let &RequestedPythonVersion { + major, + minor, + patch, + .. + } = requested_python_version; + + match (major, minor, patch) { + (..3, _, _) | (3, ..8, _) => Err(ResolvePythonVersionError::EolVersion( + requested_python_version.clone(), + )), + (3, 8, None) => Ok(LATEST_PYTHON_3_8), + (3, 9, None) => Ok(LATEST_PYTHON_3_9), + (3, 10, None) => Ok(LATEST_PYTHON_3_10), + (3, 11, None) => Ok(LATEST_PYTHON_3_11), + (3, 12, None) => Ok(LATEST_PYTHON_3_12), + (3, 13.., _) | (4.., _, _) => Err(ResolvePythonVersionError::UnknownVersion( + requested_python_version.clone(), + )), + (major, minor, Some(patch)) => Ok(PythonVersion::new(major, minor, patch)), + } +} + +/// Errors that can occur when resolving a requested Python version to a specific Python version. +#[derive(Debug, PartialEq)] +pub(crate) enum ResolvePythonVersionError { + EolVersion(RequestedPythonVersion), + UnknownVersion(RequestedPythonVersion), } #[cfg(test)] mod tests { use super::*; + const OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION: u16 = 8; + const NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION: u16 = 12; + #[test] fn python_version_url() { assert_eq!( @@ -115,32 +209,187 @@ mod tests { } #[test] - fn determine_python_version_runtime_txt_valid() { + fn read_requested_python_version_runtime_txt() { assert_eq!( - determine_python_version(Path::new("tests/fixtures/python_3.7")).unwrap(), - PythonVersion::new(3, 7, 17) + read_requested_python_version(Path::new( + "tests/fixtures/runtime_txt_and_python_version_file" + )) + .unwrap(), + RequestedPythonVersion { + major: 3, + minor: 10, + patch: Some(0), + origin: PythonVersionOrigin::RuntimeTxt, + } ); + assert!(matches!( + read_requested_python_version(Path::new("tests/fixtures/runtime_txt_invalid_unicode")) + .unwrap_err(), + RequestedPythonVersionError::ReadRuntimeTxt(_) + )); + assert!(matches!( + read_requested_python_version(Path::new("tests/fixtures/runtime_txt_invalid_version")) + .unwrap_err(), + RequestedPythonVersionError::ParseRuntimeTxt(_) + )); + } + + #[test] + fn read_requested_python_version_python_version_file() { assert_eq!( - determine_python_version(Path::new("tests/fixtures/runtime_txt_non_existent_version")) + read_requested_python_version(Path::new("tests/fixtures/python_3.7")).unwrap(), + RequestedPythonVersion { + major: 3, + minor: 7, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + } + ); + assert!(matches!( + read_requested_python_version(Path::new( + "tests/fixtures/python_version_file_invalid_unicode" + )) + .unwrap_err(), + RequestedPythonVersionError::ReadPythonVersionFile(_) + )); + assert!(matches!( + read_requested_python_version(Path::new( + "tests/fixtures/python_version_file_invalid_version" + )) + .unwrap_err(), + RequestedPythonVersionError::ParsePythonVersionFile(_) + )); + } + + #[test] + fn read_requested_python_version_none_specified() { + assert_eq!( + read_requested_python_version(Path::new("tests/fixtures/python_version_unspecified")) .unwrap(), - PythonVersion::new(999, 888, 777) + RequestedPythonVersion { + major: 3, + minor: 12, + patch: None, + origin: PythonVersionOrigin::BuildpackDefault + } ); } #[test] - fn determine_python_version_runtime_txt_error() { - assert!(matches!( - determine_python_version(Path::new("tests/fixtures/runtime_txt_invalid_version")) - .unwrap_err(), - PythonVersionError::RuntimeTxt(RuntimeTxtError::Parse(_)) - )); + fn resolve_python_version_valid() { + // Buildpack default version + assert_eq!( + resolve_python_version(&DEFAULT_PYTHON_VERSION), + Ok(DEFAULT_PYTHON_FULL_VERSION) + ); + + for minor in + OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION..=NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION + { + // Major-minor version + let python_version = resolve_python_version(&RequestedPythonVersion { + major: 3, + minor, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + }) + .unwrap(); + assert_eq!((python_version.major, python_version.minor), (3, minor)); + + // Exact version + assert_eq!( + resolve_python_version(&RequestedPythonVersion { + major: 3, + minor, + patch: Some(1), + origin: PythonVersionOrigin::RuntimeTxt + }), + Ok(PythonVersion::new(3, minor, 1)) + ); + } + } + + #[test] + fn resolve_python_version_eol() { + let requested_python_version = RequestedPythonVersion { + major: 3, + minor: OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION - 1, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + }; + assert_eq!( + resolve_python_version(&requested_python_version), + Err(ResolvePythonVersionError::EolVersion( + requested_python_version + )) + ); + + let requested_python_version = RequestedPythonVersion { + major: 3, + minor: OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION - 1, + patch: Some(0), + origin: PythonVersionOrigin::PythonVersionFile, + }; + assert_eq!( + resolve_python_version(&requested_python_version), + Err(ResolvePythonVersionError::EolVersion( + requested_python_version + )) + ); + + let requested_python_version = RequestedPythonVersion { + major: 2, + minor: 7, + patch: Some(18), + origin: PythonVersionOrigin::RuntimeTxt, + }; + assert_eq!( + resolve_python_version(&requested_python_version), + Err(ResolvePythonVersionError::EolVersion( + requested_python_version + )) + ); } #[test] - fn determine_python_version_none_specified() { + fn resolve_python_version_unsupported() { + let requested_python_version = RequestedPythonVersion { + major: 3, + minor: NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION + 1, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + }; + assert_eq!( + resolve_python_version(&requested_python_version), + Err(ResolvePythonVersionError::UnknownVersion( + requested_python_version + )) + ); + + let requested_python_version = RequestedPythonVersion { + major: 3, + minor: NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION + 1, + patch: Some(0), + origin: PythonVersionOrigin::PythonVersionFile, + }; + assert_eq!( + resolve_python_version(&requested_python_version), + Err(ResolvePythonVersionError::UnknownVersion( + requested_python_version + )) + ); + + let requested_python_version = RequestedPythonVersion { + major: 4, + minor: 0, + patch: Some(0), + origin: PythonVersionOrigin::RuntimeTxt, + }; assert_eq!( - determine_python_version(Path::new("tests/fixtures/empty")).unwrap(), - DEFAULT_PYTHON_VERSION + resolve_python_version(&requested_python_version), + Err(ResolvePythonVersionError::UnknownVersion( + requested_python_version + )) ); } } diff --git a/src/python_version_file.rs b/src/python_version_file.rs new file mode 100644 index 0000000..08d3438 --- /dev/null +++ b/src/python_version_file.rs @@ -0,0 +1,184 @@ +use crate::python_version::{PythonVersionOrigin, RequestedPythonVersion}; + +/// Parse the contents of a `.python-version` file into a [`RequestedPythonVersion`]. +/// +/// The file is expected to contain a string of form `X.Y` or `X.Y.Z`. Leading and trailing +/// whitespace will be removed from each line. Lines which are either comments (that begin +/// with `#`) or are empty will be ignored. Multiple Python versions are not permitted. +pub(crate) fn parse(contents: &str) -> Result { + let versions = contents + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect::>(); + + match versions.as_slice() { + [version] => match version + .split('.') + .map(str::parse) + .collect::, _>>() + .unwrap_or_default()[..] + { + [major, minor, patch] => Ok(RequestedPythonVersion { + major, + minor, + patch: Some(patch), + origin: PythonVersionOrigin::PythonVersionFile, + }), + [major, minor] => Ok(RequestedPythonVersion { + major, + minor, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + }), + _ => Err(ParsePythonVersionFileError::InvalidVersion(version.clone())), + }, + [] => Err(ParsePythonVersionFileError::NoVersion), + _ => Err(ParsePythonVersionFileError::MultipleVersions(versions)), + } +} + +/// Errors that can occur when parsing the contents of a `.python-version` file. +#[derive(Debug, PartialEq)] +pub(crate) enum ParsePythonVersionFileError { + InvalidVersion(String), + MultipleVersions(Vec), + NoVersion, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid() { + assert_eq!( + parse("1.2"), + Ok(RequestedPythonVersion { + major: 1, + minor: 2, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + }) + ); + assert_eq!( + parse("987.654.3210"), + Ok(RequestedPythonVersion { + major: 987, + minor: 654, + patch: Some(3210), + origin: PythonVersionOrigin::PythonVersionFile, + }) + ); + assert_eq!( + parse("1.2\n"), + Ok(RequestedPythonVersion { + major: 1, + minor: 2, + patch: None, + origin: PythonVersionOrigin::PythonVersionFile, + }) + ); + assert_eq!( + parse(" # Comment 1\n 1.2.3 \n # Comment 2"), + Ok(RequestedPythonVersion { + major: 1, + minor: 2, + patch: Some(3), + origin: PythonVersionOrigin::PythonVersionFile, + }) + ); + } + + #[test] + fn parse_invalid_version() { + assert_eq!( + parse("1"), + Err(ParsePythonVersionFileError::InvalidVersion("1".to_string())) + ); + assert_eq!( + parse("1.2.3.4"), + Err(ParsePythonVersionFileError::InvalidVersion( + "1.2.3.4".to_string() + )) + ); + assert_eq!( + parse("1..3"), + Err(ParsePythonVersionFileError::InvalidVersion( + "1..3".to_string() + )) + ); + assert_eq!( + parse("1.2.3."), + Err(ParsePythonVersionFileError::InvalidVersion( + "1.2.3.".to_string() + )) + ); + assert_eq!( + parse("1.2rc1"), + Err(ParsePythonVersionFileError::InvalidVersion( + "1.2rc1".to_string() + )) + ); + assert_eq!( + parse("1.2.3-dev"), + Err(ParsePythonVersionFileError::InvalidVersion( + "1.2.3-dev".to_string() + )) + ); + // We don't support the `python-` prefix form since it's undocumented and will likely + // be deprecated: https://github.com/pyenv/pyenv/issues/3054#issuecomment-2341316638 + assert_eq!( + parse("python-1.2.3"), + Err(ParsePythonVersionFileError::InvalidVersion( + "python-1.2.3".to_string() + )) + ); + assert_eq!( + parse("system"), + Err(ParsePythonVersionFileError::InvalidVersion( + "system".to_string() + )) + ); + assert_eq!( + parse(" # Comment 1\n 1 2 3 \n # Comment 2"), + Err(ParsePythonVersionFileError::InvalidVersion( + "1 2 3".to_string() + )) + ); + } + + #[test] + fn parse_no_version() { + assert_eq!(parse(""), Err(ParsePythonVersionFileError::NoVersion)); + assert_eq!(parse("\n"), Err(ParsePythonVersionFileError::NoVersion)); + assert_eq!( + parse("# Comment 1\n \n # Comment 2"), + Err(ParsePythonVersionFileError::NoVersion) + ); + } + + #[test] + fn parse_multiple_versions() { + assert_eq!( + parse("1.2\n3.4"), + Err(ParsePythonVersionFileError::MultipleVersions(vec![ + "1.2".to_string(), + "3.4".to_string() + ])) + ); + assert_eq!( + parse(" # Comment 1\n 1.2 \n # Comment 2\npython-3.4"), + Err(ParsePythonVersionFileError::MultipleVersions(vec![ + "1.2".to_string(), + "python-3.4".to_string() + ])) + ); + } +} diff --git a/src/runtime_txt.rs b/src/runtime_txt.rs index e4092a8..70b7f9d 100644 --- a/src/runtime_txt.rs +++ b/src/runtime_txt.rs @@ -1,27 +1,10 @@ -use crate::python_version::PythonVersion; -use crate::utils; -use std::io; -use std::path::Path; +use crate::python_version::{PythonVersionOrigin, RequestedPythonVersion}; -/// Retrieve a parsed Python version from a `runtime.txt` file if it exists in the -/// specified project directory. -/// -/// Returns `Ok(None)` if the file does not exist, but returns the error for all other -/// forms of IO or parsing errors. -pub(crate) fn read_version(app_dir: &Path) -> Result, RuntimeTxtError> { - let runtime_txt_path = app_dir.join("runtime.txt"); - - utils::read_optional_file(&runtime_txt_path) - .map_err(RuntimeTxtError::Read)? - .map(|contents| parse(&contents).map_err(RuntimeTxtError::Parse)) - .transpose() -} - -/// Parse the contents of a `runtime.txt` file into a [`PythonVersion`]. +/// Parse the contents of a `runtime.txt` file into a [`RequestedPythonVersion`]. /// /// The file is expected to contain a string of form `python-X.Y.Z`. /// Any leading or trailing whitespace will be removed. -fn parse(contents: &str) -> Result { +pub(crate) fn parse(contents: &str) -> Result { // All leading/trailing whitespace is trimmed, since that's what the classic buildpack // permitted (however it's primarily trailing newlines that we need to support). The // string is then escaped, to aid debugging when non-ascii characters have inadvertently @@ -38,24 +21,21 @@ fn parse(contents: &str) -> Result { match version_substring .split('.') .map(str::parse) - .collect::, _>>() - .unwrap_or_default() - .as_slice() + .collect::, _>>() + .unwrap_or_default()[..] { - &[major, minor, patch] => Ok(PythonVersion::new(major, minor, patch)), + [major, minor, patch] => Ok(RequestedPythonVersion { + major, + minor, + patch: Some(patch), + origin: PythonVersionOrigin::RuntimeTxt, + }), _ => Err(ParseRuntimeTxtError { cleaned_contents: cleaned_contents.clone(), }), } } -/// Errors that can occur when reading and parsing a `runtime.txt` file. -#[derive(Debug)] -pub(crate) enum RuntimeTxtError { - Parse(ParseRuntimeTxtError), - Read(io::Error), -} - /// Errors that can occur when parsing the contents of a `runtime.txt` file. #[derive(Debug, PartialEq)] pub(crate) struct ParseRuntimeTxtError { @@ -68,14 +48,32 @@ mod tests { #[test] fn parse_valid() { - assert_eq!(parse("python-1.2.3"), Ok(PythonVersion::new(1, 2, 3))); + assert_eq!( + parse("python-1.2.3"), + Ok(RequestedPythonVersion { + major: 1, + minor: 2, + patch: Some(3), + origin: PythonVersionOrigin::RuntimeTxt + }) + ); assert_eq!( parse("python-987.654.3210"), - Ok(PythonVersion::new(987, 654, 3210)) + Ok(RequestedPythonVersion { + major: 987, + minor: 654, + patch: Some(3210), + origin: PythonVersionOrigin::RuntimeTxt + }) ); assert_eq!( parse("\n python-1.2.3 \n"), - Ok(PythonVersion::new(1, 2, 3)) + Ok(RequestedPythonVersion { + major: 1, + minor: 2, + patch: Some(3), + origin: PythonVersionOrigin::RuntimeTxt + }) ); } @@ -191,44 +189,4 @@ mod tests { }) ); } - - #[test] - fn read_version_valid_runtime_txt() { - assert_eq!( - read_version(Path::new("tests/fixtures/python_3.7")).unwrap(), - Some(PythonVersion::new(3, 7, 17)) - ); - assert_eq!( - read_version(Path::new("tests/fixtures/runtime_txt_non_existent_version")).unwrap(), - Some(PythonVersion::new(999, 888, 777)) - ); - } - - #[test] - fn read_version_runtime_txt_not_present() { - assert_eq!( - read_version(Path::new("tests/fixtures/empty")).unwrap(), - None - ); - } - - #[test] - fn read_version_io_error() { - assert!(matches!( - read_version(Path::new("tests/fixtures/empty/.gitkeep")).unwrap_err(), - RuntimeTxtError::Read(_) - )); - assert!(matches!( - read_version(Path::new("tests/fixtures/runtime_txt_invalid_unicode")).unwrap_err(), - RuntimeTxtError::Read(_) - )); - } - - #[test] - fn read_version_parse_error() { - assert!(matches!( - read_version(Path::new("tests/fixtures/runtime_txt_invalid_version")).unwrap_err(), - RuntimeTxtError::Parse(_) - )); - } } diff --git a/src/utils.rs b/src/utils.rs index f4cd724..f594ca4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,7 +6,7 @@ use tar::Archive; use zstd::Decoder; /// Read the contents of the provided filepath if the file exists, gracefully handling -/// the file not being present, but still returning any other form of IO error. +/// the file not being present, but still returning any other form of I/O error. pub(crate) fn read_optional_file(path: &Path) -> io::Result> { fs::read_to_string(path) .map(Some) @@ -139,8 +139,8 @@ mod tests { #[test] fn read_optional_file_valid_file() { assert_eq!( - read_optional_file(Path::new("tests/fixtures/python_3.7/runtime.txt")).unwrap(), - Some("python-3.7.17\n".to_string()) + read_optional_file(Path::new("tests/fixtures/python_3.11/.python-version")).unwrap(), + Some("3.11\n".to_string()) ); } diff --git a/tests/fixtures/django_staticfiles_legacy_django/.python-version b/tests/fixtures/django_staticfiles_legacy_django/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/tests/fixtures/django_staticfiles_legacy_django/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/tests/fixtures/django_staticfiles_legacy_django/runtime.txt b/tests/fixtures/django_staticfiles_legacy_django/runtime.txt deleted file mode 100644 index bb60b7f..0000000 --- a/tests/fixtures/django_staticfiles_legacy_django/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.10.15 diff --git a/tests/fixtures/python_3.10/.python-version b/tests/fixtures/python_3.10/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/tests/fixtures/python_3.10/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/tests/fixtures/python_3.10/runtime.txt b/tests/fixtures/python_3.10/runtime.txt deleted file mode 100644 index bb60b7f..0000000 --- a/tests/fixtures/python_3.10/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.10.15 diff --git a/tests/fixtures/python_3.11/.python-version b/tests/fixtures/python_3.11/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/tests/fixtures/python_3.11/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/tests/fixtures/python_3.11/runtime.txt b/tests/fixtures/python_3.11/runtime.txt deleted file mode 100644 index e345195..0000000 --- a/tests/fixtures/python_3.11/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.11.10 diff --git a/tests/fixtures/python_3.12/.python-version b/tests/fixtures/python_3.12/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/tests/fixtures/python_3.12/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/tests/fixtures/python_3.12/runtime.txt b/tests/fixtures/python_3.12/runtime.txt deleted file mode 100644 index 32bcba6..0000000 --- a/tests/fixtures/python_3.12/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.6 diff --git a/tests/fixtures/python_3.7/.python-version b/tests/fixtures/python_3.7/.python-version new file mode 100644 index 0000000..475ba51 --- /dev/null +++ b/tests/fixtures/python_3.7/.python-version @@ -0,0 +1 @@ +3.7 diff --git a/tests/fixtures/python_3.7/runtime.txt b/tests/fixtures/python_3.7/runtime.txt deleted file mode 100644 index 113b0bc..0000000 --- a/tests/fixtures/python_3.7/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.7.17 diff --git a/tests/fixtures/python_3.8/.python-version b/tests/fixtures/python_3.8/.python-version new file mode 100644 index 0000000..cc1923a --- /dev/null +++ b/tests/fixtures/python_3.8/.python-version @@ -0,0 +1 @@ +3.8 diff --git a/tests/fixtures/python_3.8/runtime.txt b/tests/fixtures/python_3.8/runtime.txt deleted file mode 100644 index 7494875..0000000 --- a/tests/fixtures/python_3.8/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.8.20 diff --git a/tests/fixtures/python_3.9/.python-version b/tests/fixtures/python_3.9/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/tests/fixtures/python_3.9/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/tests/fixtures/python_3.9/runtime.txt b/tests/fixtures/python_3.9/runtime.txt deleted file mode 100644 index 57f5588..0000000 --- a/tests/fixtures/python_3.9/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.9.20 diff --git a/tests/fixtures/python_version_file_invalid_unicode/.python-version b/tests/fixtures/python_version_file_invalid_unicode/.python-version new file mode 100644 index 0000000..bdeb23f --- /dev/null +++ b/tests/fixtures/python_version_file_invalid_unicode/.python-version @@ -0,0 +1 @@ + diff --git a/tests/fixtures/runtime_txt_non_existent_version/requirements.txt b/tests/fixtures/python_version_file_invalid_unicode/requirements.txt similarity index 100% rename from tests/fixtures/runtime_txt_non_existent_version/requirements.txt rename to tests/fixtures/python_version_file_invalid_unicode/requirements.txt diff --git a/tests/fixtures/python_version_file_invalid_version/.python-version b/tests/fixtures/python_version_file_invalid_version/.python-version new file mode 100644 index 0000000..3b4b471 --- /dev/null +++ b/tests/fixtures/python_version_file_invalid_version/.python-version @@ -0,0 +1 @@ +an.invalid.version diff --git a/tests/fixtures/python_version_file_invalid_version/requirements.txt b/tests/fixtures/python_version_file_invalid_version/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/python_version_file_multiple_versions/.python-version b/tests/fixtures/python_version_file_multiple_versions/.python-version new file mode 100644 index 0000000..7d20be0 --- /dev/null +++ b/tests/fixtures/python_version_file_multiple_versions/.python-version @@ -0,0 +1,3 @@ +// invalid comment +3.12 +2.7 diff --git a/tests/fixtures/python_version_file_multiple_versions/requirements.txt b/tests/fixtures/python_version_file_multiple_versions/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/python_version_file_no_version/.python-version b/tests/fixtures/python_version_file_no_version/.python-version new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/fixtures/python_version_file_no_version/.python-version @@ -0,0 +1 @@ + diff --git a/tests/fixtures/python_version_file_no_version/requirements.txt b/tests/fixtures/python_version_file_no_version/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/python_version_file_unknown_version/.python-version b/tests/fixtures/python_version_file_unknown_version/.python-version new file mode 100644 index 0000000..d1bca03 --- /dev/null +++ b/tests/fixtures/python_version_file_unknown_version/.python-version @@ -0,0 +1 @@ +3.99 diff --git a/tests/fixtures/python_version_file_unknown_version/requirements.txt b/tests/fixtures/python_version_file_unknown_version/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/runtime_txt_and_python_version_file/.python-version b/tests/fixtures/runtime_txt_and_python_version_file/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/tests/fixtures/runtime_txt_and_python_version_file/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/tests/fixtures/runtime_txt_and_python_version_file/requirements.txt b/tests/fixtures/runtime_txt_and_python_version_file/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/runtime_txt_and_python_version_file/runtime.txt b/tests/fixtures/runtime_txt_and_python_version_file/runtime.txt new file mode 100644 index 0000000..fadb070 --- /dev/null +++ b/tests/fixtures/runtime_txt_and_python_version_file/runtime.txt @@ -0,0 +1 @@ +python-3.10.0 diff --git a/tests/fixtures/runtime_txt_non_existent_version/runtime.txt b/tests/fixtures/runtime_txt_non_existent_version/runtime.txt deleted file mode 100644 index f5bde40..0000000 --- a/tests/fixtures/runtime_txt_non_existent_version/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-999.888.777 diff --git a/tests/mod.rs b/tests/mod.rs index e06e095..d428c08 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -14,14 +14,6 @@ use libcnb_test::BuildConfig; use std::env; use std::path::Path; -const LATEST_PYTHON_3_7: &str = "3.7.17"; -const LATEST_PYTHON_3_8: &str = "3.8.20"; -const LATEST_PYTHON_3_9: &str = "3.9.20"; -const LATEST_PYTHON_3_10: &str = "3.10.15"; -const LATEST_PYTHON_3_11: &str = "3.11.10"; -const LATEST_PYTHON_3_12: &str = "3.12.6"; -const DEFAULT_PYTHON_VERSION: &str = LATEST_PYTHON_3_12; - const DEFAULT_BUILDER: &str = "heroku/builder:24"; fn default_build_config(fixture_path: impl AsRef) -> BuildConfig { diff --git a/tests/pip_test.rs b/tests/pip_test.rs index c9ebf7a..3182a53 100644 --- a/tests/pip_test.rs +++ b/tests/pip_test.rs @@ -1,5 +1,6 @@ use crate::packaging_tool_versions::PIP_VERSION; -use crate::tests::{default_build_config, DEFAULT_PYTHON_VERSION}; +use crate::python_version::{DEFAULT_PYTHON_FULL_VERSION, DEFAULT_PYTHON_VERSION}; +use crate::tests::default_build_config; use indoc::{formatdoc, indoc}; use libcnb_test::{assert_contains, assert_empty, BuildpackReference, PackResult, TestRunner}; @@ -19,10 +20,11 @@ fn pip_basic_install_and_cache_reuse() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] - Installing Python {DEFAULT_PYTHON_VERSION} + Installing Python {DEFAULT_PYTHON_FULL_VERSION} [Installing pip] Installing pip {PIP_VERSION} @@ -99,10 +101,11 @@ fn pip_basic_install_and_cache_reuse() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] - Using cached Python {DEFAULT_PYTHON_VERSION} + Using cached Python {DEFAULT_PYTHON_FULL_VERSION} [Installing pip] Using cached pip {PIP_VERSION} @@ -136,10 +139,11 @@ fn pip_cache_invalidation_package_manager_changed() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] - Using cached Python {DEFAULT_PYTHON_VERSION} + Using cached Python {DEFAULT_PYTHON_FULL_VERSION} [Installing pip] Installing pip {PIP_VERSION} @@ -177,12 +181,13 @@ fn pip_cache_previous_buildpack_version() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] Discarding cached Python 3.12.5 since: - - The Python version has changed from 3.12.5 to {DEFAULT_PYTHON_VERSION} - Installing Python {DEFAULT_PYTHON_VERSION} + - The Python version has changed from 3.12.5 to {DEFAULT_PYTHON_FULL_VERSION} + Installing Python {DEFAULT_PYTHON_FULL_VERSION} [Installing pip] Discarding cached pip {PIP_VERSION} diff --git a/tests/poetry_test.rs b/tests/poetry_test.rs index c316192..8910fc4 100644 --- a/tests/poetry_test.rs +++ b/tests/poetry_test.rs @@ -1,5 +1,6 @@ use crate::packaging_tool_versions::POETRY_VERSION; -use crate::tests::{default_build_config, DEFAULT_PYTHON_VERSION}; +use crate::python_version::{DEFAULT_PYTHON_FULL_VERSION, DEFAULT_PYTHON_VERSION}; +use crate::tests::default_build_config; use indoc::{formatdoc, indoc}; use libcnb_test::{assert_contains, assert_empty, BuildpackReference, PackResult, TestRunner}; @@ -19,10 +20,11 @@ fn poetry_basic_install_and_cache_reuse() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] - Installing Python {DEFAULT_PYTHON_VERSION} + Installing Python {DEFAULT_PYTHON_FULL_VERSION} [Installing Poetry] Installing Poetry {POETRY_VERSION} @@ -93,10 +95,11 @@ fn poetry_basic_install_and_cache_reuse() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] - Using cached Python {DEFAULT_PYTHON_VERSION} + Using cached Python {DEFAULT_PYTHON_FULL_VERSION} [Installing Poetry] Using cached Poetry {POETRY_VERSION} @@ -127,10 +130,11 @@ fn poetry_cache_invalidation_package_manager_changed() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] - Using cached Python {DEFAULT_PYTHON_VERSION} + Using cached Python {DEFAULT_PYTHON_FULL_VERSION} [Installing Poetry] Installing Poetry {POETRY_VERSION} @@ -168,12 +172,13 @@ fn poetry_cache_previous_buildpack_version() { &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 + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. [Installing Python] Discarding cached Python 3.12.5 since: - - The Python version has changed from 3.12.5 to {DEFAULT_PYTHON_VERSION} - Installing Python {DEFAULT_PYTHON_VERSION} + - The Python version has changed from 3.12.5 to {DEFAULT_PYTHON_FULL_VERSION} + Installing Python {DEFAULT_PYTHON_FULL_VERSION} [Installing Poetry] Discarding cached Poetry {POETRY_VERSION} @@ -241,21 +246,21 @@ fn poetry_install_error() { assert_contains!( context.pack_stdout, indoc! {" - [Installing dependencies using Poetry] - Creating virtual environment - Running 'poetry install --sync --only main' - Installing dependencies from lock file - "} + [Installing dependencies using Poetry] + Creating virtual environment + Running 'poetry install --sync --only main' + Installing dependencies from lock file + "} ); assert_contains!( context.pack_stderr, indoc! {" pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock [--no-update]` to fix the lock file. - + [Error: Unable to install dependencies using Poetry] The 'poetry install --sync --only main' command to install the app's dependencies failed (exit status: 1). - + See the log output above for more information. "} ); diff --git a/tests/python_version_test.rs b/tests/python_version_test.rs index f96f869..2fbd7c3 100644 --- a/tests/python_version_test.rs +++ b/tests/python_version_test.rs @@ -1,37 +1,58 @@ -use crate::tests::{ - builder, default_build_config, DEFAULT_PYTHON_VERSION, LATEST_PYTHON_3_10, LATEST_PYTHON_3_11, - LATEST_PYTHON_3_12, LATEST_PYTHON_3_7, LATEST_PYTHON_3_8, LATEST_PYTHON_3_9, +use crate::python_version::{ + PythonVersion, DEFAULT_PYTHON_FULL_VERSION, DEFAULT_PYTHON_VERSION, LATEST_PYTHON_3_10, + LATEST_PYTHON_3_11, LATEST_PYTHON_3_12, LATEST_PYTHON_3_8, LATEST_PYTHON_3_9, }; +use crate::tests::{builder, default_build_config}; use indoc::{formatdoc, indoc}; use libcnb_test::{assert_contains, assert_empty, PackResult, TestRunner}; #[test] #[ignore = "integration test"] fn python_version_unspecified() { - TestRunner::default().build( - default_build_config( "tests/fixtures/python_version_unspecified"), - |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} - "} - ); - }, - ); + let config = default_build_config("tests/fixtures/python_version_unspecified"); + + 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}. + We recommend setting an explicit version. In the root of your app create + a '.python-version' file, containing a Python version like '{DEFAULT_PYTHON_VERSION}'. + + [Installing Python] + Installing Python {DEFAULT_PYTHON_FULL_VERSION} + "} + ); + }); } #[test] #[ignore = "integration test"] fn python_3_7() { - // Python 3.7 is EOL and so archives for it don't exist at the new S3 filenames. - rejects_non_existent_python_version("tests/fixtures/python_3.7", LATEST_PYTHON_3_7); + let mut config = default_build_config("tests/fixtures/python_3.7"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Requested Python version has reached end-of-life] + The requested Python version 3.7 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 .python-version file. + + If possible, we recommend upgrading all the way to Python {DEFAULT_PYTHON_VERSION}, + since it contains many performance and usability improvements. + "} + ); + }); } #[test] @@ -40,8 +61,8 @@ fn python_3_8() { // Python 3.8 is only available on Heroku-20 and older. let fixture = "tests/fixtures/python_3.8"; match builder().as_str() { - "heroku/builder:20" => builds_with_python_version(fixture, LATEST_PYTHON_3_8), - _ => rejects_non_existent_python_version(fixture, LATEST_PYTHON_3_8), + "heroku/builder:20" => builds_with_python_version(fixture, &LATEST_PYTHON_3_8), + _ => rejects_non_existent_python_version(fixture, &LATEST_PYTHON_3_8), }; } @@ -52,41 +73,47 @@ fn python_3_9() { let fixture = "tests/fixtures/python_3.9"; match builder().as_str() { "heroku/builder:20" | "heroku/builder:22" => { - builds_with_python_version(fixture, LATEST_PYTHON_3_9); + builds_with_python_version(fixture, &LATEST_PYTHON_3_9); } - _ => rejects_non_existent_python_version(fixture, LATEST_PYTHON_3_9), + _ => rejects_non_existent_python_version(fixture, &LATEST_PYTHON_3_9), }; } #[test] #[ignore = "integration test"] fn python_3_10() { - builds_with_python_version("tests/fixtures/python_3.10", LATEST_PYTHON_3_10); + builds_with_python_version("tests/fixtures/python_3.10", &LATEST_PYTHON_3_10); } #[test] #[ignore = "integration test"] fn python_3_11() { - builds_with_python_version("tests/fixtures/python_3.11", LATEST_PYTHON_3_11); + builds_with_python_version("tests/fixtures/python_3.11", &LATEST_PYTHON_3_11); } #[test] #[ignore = "integration test"] fn python_3_12() { - builds_with_python_version("tests/fixtures/python_3.12", LATEST_PYTHON_3_12); + builds_with_python_version("tests/fixtures/python_3.12", &LATEST_PYTHON_3_12); } -fn builds_with_python_version(fixture_path: &str, python_version: &str) { +fn builds_with_python_version(fixture_path: &str, python_version: &PythonVersion) { + let PythonVersion { + major, + minor, + patch, + } = python_version; + TestRunner::default().build(default_build_config(fixture_path), |context| { assert_empty!(context.pack_stderr); assert_contains!( context.pack_stdout, &formatdoc! {" [Determining Python version] - Using Python version {python_version} specified in runtime.txt + Using Python version {major}.{minor} specified in .python-version [Installing Python] - Installing Python {python_version} + Installing Python {major}.{minor}.{patch} "} ); // There's no sensible default process type we can set for Python apps. @@ -96,11 +123,11 @@ fn builds_with_python_version(fixture_path: &str, python_version: &str) { let command_output = context.run_shell_command( indoc! {r#" set -euo pipefail - + # Check that we installed the correct Python version, and that the command # 'python' works (since it's a symlink to the actual 'python3' binary). python --version - + # Check that the Python binary is using its own 'libpython' and not the system one: # https://github.com/docker-library/python/issues/784 # Note: This has to handle Python 3.9 and older not being built in shared library mode. @@ -110,7 +137,7 @@ fn builds_with_python_version(fixture_path: &str, python_version: &str) { echo "${libpython_path}" exit 1 fi - + # Check all required dynamically linked libraries can be found in the run image. ldd_output=$(find /layers -type f,l \( -name 'python3' -o -name '*.so*' \) -exec ldd '{}' +) if grep 'not found' <<<"${ldd_output}" | sort --unique; then @@ -122,90 +149,217 @@ fn builds_with_python_version(fixture_path: &str, python_version: &str) { assert_empty!(command_output.stderr); assert_eq!( command_output.stdout, - format!("Python {python_version}\n") + format!("Python {major}.{minor}.{patch}\n") + ); + }); +} + +fn rejects_non_existent_python_version(fixture_path: &str, python_version: &PythonVersion) { + let mut config = default_build_config(fixture_path); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Requested Python version is not available] + The requested Python version ({python_version}) is not available for this builder image. + + 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 + "} ); }); } #[test] #[ignore = "integration test"] -fn runtime_txt_io_error() { - TestRunner::default().build( - default_build_config("tests/fixtures/runtime_txt_invalid_unicode") - .expected_pack_result(PackResult::Failure), - |context| { - assert_contains!( - context.pack_stderr, - &formatdoc! {" - [Error: Unable to read runtime.txt] - An unexpected error occurred whilst reading the (optional) runtime.txt file. - - Details: I/O Error: stream did not contain valid UTF-8 - "} - ); - }, - ); +fn python_version_file_io_error() { + let mut config = default_build_config("tests/fixtures/python_version_file_invalid_unicode"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + indoc! {" + [Error: Unable to read .python-version] + An unexpected error occurred whilst reading the .python-version file. + + Details: I/O Error: stream did not contain valid UTF-8 + "} + ); + }); } #[test] #[ignore = "integration test"] -fn runtime_txt_invalid_version() { - TestRunner::default().build( - default_build_config( "tests/fixtures/runtime_txt_invalid_version") - .expected_pack_result(PackResult::Failure), - |context| { - assert_contains!( - context.pack_stderr, - &formatdoc! {" - [Error: Invalid Python version in runtime.txt] - The Python version specified in 'runtime.txt' is not in the correct format. - - The following file contents were found: - python-an.invalid.version - - However, the file contents must begin with a 'python-' prefix, followed by the - version specified as '..'. Comments are not supported. - - For example, to request Python {DEFAULT_PYTHON_VERSION}, the correct version format is: - python-{DEFAULT_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 - "} - ); - }, - ); +fn python_version_file_invalid_version() { + let mut config = default_build_config("tests/fixtures/python_version_file_invalid_version"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Invalid Python version in .python-version] + The Python version specified in '.python-version' is not in the correct format. + + The following version was found: + an.invalid.version + + However, the version must be specified as either: + 1. '.' (recommended, for automatic security updates) + 2. '..' (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} + "} + ); + }); } #[test] #[ignore = "integration test"] -fn runtime_txt_non_existent_version() { - rejects_non_existent_python_version( - "tests/fixtures/runtime_txt_non_existent_version", - "999.888.777", - ); -} - -fn rejects_non_existent_python_version(fixture_path: &str, python_version: &str) { - TestRunner::default().build( - default_build_config(fixture_path).expected_pack_result(PackResult::Failure), - |context| { - assert_contains!( - context.pack_stderr, - &formatdoc! {" - [Error: Requested Python version is not available] - 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}). - - For a list of the supported Python versions, see: - https://devcenter.heroku.com/articles/python-support#supported-runtimes - "} - ); - }, - ); +fn python_version_file_multiple_versions() { + let mut config = default_build_config("tests/fixtures/python_version_file_multiple_versions"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + indoc! {" + [Error: Invalid Python version in .python-version] + Multiple Python versions were found in '.python-version': + + // invalid comment + 3.12 + 2.7 + + Update the file so it contains only one Python version. + + If the additional versions are actually comments, prefix those lines with '#'. + "} + ); + }); +} + +#[test] +#[ignore = "integration test"] +fn python_version_file_no_version() { + let mut config = default_build_config("tests/fixtures/python_version_file_no_version"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Invalid Python version in .python-version] + 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. + "} + ); + }); +} + +#[test] +#[ignore = "integration test"] +fn python_version_file_unknown_version() { + let mut config = default_build_config("tests/fixtures/python_version_file_unknown_version"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Requested Python version is not recognised] + The requested Python version 3.99 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 .python-version file. + "} + ); + }); +} + +#[test] +#[ignore = "integration test"] +fn runtime_txt() { + let config = default_build_config("tests/fixtures/runtime_txt_and_python_version_file"); + + TestRunner::default().build(config, |context| { + assert_empty!(context.pack_stderr); + assert_contains!( + context.pack_stdout, + indoc! {" + [Determining Python version] + Using Python version 3.10.0 specified in runtime.txt + + [Installing Python] + Installing Python 3.10.0 + "} + ); + }); +} + +#[test] +#[ignore = "integration test"] +fn runtime_txt_io_error() { + let mut config = default_build_config("tests/fixtures/runtime_txt_invalid_unicode"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + indoc! {" + [Error: Unable to read runtime.txt] + An unexpected error occurred whilst reading the runtime.txt file. + + Details: I/O Error: stream did not contain valid UTF-8 + "} + ); + }); +} + +#[test] +#[ignore = "integration test"] +fn runtime_txt_invalid_version() { + let mut config = default_build_config("tests/fixtures/runtime_txt_invalid_version"); + config.expected_pack_result(PackResult::Failure); + + TestRunner::default().build(config, |context| { + assert_contains!( + context.pack_stderr, + &formatdoc! {" + [Error: Invalid Python version in runtime.txt] + The Python version specified in 'runtime.txt' is not in the correct format. + + The following file contents were found: + python-an.invalid.version + + However, the file contents must begin with a 'python-' prefix, followed by the + version specified as '..'. Comments are not supported. + + For example, to request Python 3.12.6, update the 'runtime.txt' file so it + contains exactly: + python-{DEFAULT_PYTHON_FULL_VERSION} + "} + ); + }); }