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} + "} + ); + }); }