From d3a9e24fa6d1d614d6daff1bca066cb192177f85 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Mon, 14 Oct 2024 14:33:11 +0100 Subject: [PATCH] Add support for the `.python-version` file Following on from the recent Python version refactoring, this now 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. This change is the classic Python buildpack equivalent of the Python CNB change here: https://github.com/heroku/buildpacks-python/pull/272 If both a `runtime.txt` file and a `.python-version` file are present, then the `runtime.txt` file will take precedence. However, use of the `.python-version` file is now recommended, since `runtime.txt` will be deprecated in the future. Both the `runtime.txt` file and `.python-version` file take precedence over any Python version specified in a `Pipfile.lock` for Pipenv users. We support the following `.python-version` syntax: - Major Python version (e.g. `3.13`, which will then be resolved to the latest Python 3.13). (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.13.0`) - 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 by pyenv in the future) In addition, the existing `runtime.txt` support has been updated to allow specifying just the major Python version, in order to increase feature parity between the files, and avoid confusion if users try to use the major version only syntax from `.python-version` in their `runtime.txt`. Refs #913. Refs #932. GUS-W-7671453. GUS-W-16821357. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 + README.md | 107 +++--- bin/compile | 3 + bin/steps/python | 4 +- lib/python_version.sh | 159 +++++++-- spec/fixtures/ci_pipenv/.python-version | 1 + spec/fixtures/ci_requirements/.python-version | 1 + .../pipenv_and_runtime_txt/runtime.txt | 1 - .../Pipfile | 2 +- .../Pipfile.lock | 10 +- .../bin/compile | 0 .../bin/detect | 0 .../setup.py | 0 spec/fixtures/pipenv_no_lockfile/Pipfile | 5 +- spec/fixtures/pipenv_python_3.10/Pipfile | 12 - spec/fixtures/pipenv_python_3.11/Pipfile | 12 - spec/fixtures/pipenv_python_3.11/Pipfile.lock | 30 -- spec/fixtures/pipenv_python_3.13/Pipfile | 12 - spec/fixtures/pipenv_python_3.13/Pipfile.lock | 30 -- spec/fixtures/pipenv_python_3.7/Pipfile.lock | 30 -- spec/fixtures/pipenv_python_3.8/Pipfile | 12 - spec/fixtures/pipenv_python_3.8/Pipfile.lock | 30 -- spec/fixtures/pipenv_python_3.9/Pipfile | 12 - spec/fixtures/pipenv_python_3.9/Pipfile.lock | 30 -- .../pipenv_python_full_version/Pipfile | 5 +- .../pipenv_python_full_version/Pipfile.lock | 10 +- .../Pipfile | 2 +- .../Pipfile.lock | 10 +- .../.python-version | 2 + .../Pipfile | 3 +- .../Pipfile.lock | 10 +- .../Pipfile | 2 +- .../Pipfile.lock | 12 +- .../pipenv_python_version_invalid/Pipfile | 2 +- .../Pipfile.lock | 10 +- .../pyproject.toml} | 0 .../subdir/some-file | 0 spec/fixtures/python_3.10/.python-version | 2 + spec/fixtures/python_3.10/requirements.txt | 3 +- spec/fixtures/python_3.10/runtime.txt | 1 - spec/fixtures/python_3.11/.python-version | 2 + spec/fixtures/python_3.11/requirements.txt | 3 +- spec/fixtures/python_3.11/runtime.txt | 1 - spec/fixtures/python_3.12/.python-version | 2 + spec/fixtures/python_3.12/requirements.txt | 3 +- spec/fixtures/python_3.12/runtime.txt | 1 - spec/fixtures/python_3.13/.python-version | 2 + spec/fixtures/python_3.13/requirements.txt | 3 +- spec/fixtures/python_3.13/runtime.txt | 1 - spec/fixtures/python_3.7/runtime.txt | 1 - spec/fixtures/python_3.8/.python-version | 2 + spec/fixtures/python_3.8/requirements.txt | 3 +- spec/fixtures/python_3.8/runtime.txt | 1 - spec/fixtures/python_3.9/.python-version | 2 + spec/fixtures/python_3.9/requirements.txt | 3 +- spec/fixtures/python_3.9/runtime.txt | 1 - .../python_version_eol/.python-version | 8 + .../requirements.txt | 0 .../.python-version | 10 + .../requirements.txt | 0 .../.python-version | 4 + .../requirements.txt | 0 .../.python-version | 0 .../requirements.txt | 0 .../.python-version | 1 + .../runtime.txt | 1 - .../.python-version | 1 + .../runtime.txt | 1 - .../python_version_outdated/.python-version | 1 + .../python_version_outdated/runtime.txt | 1 - .../requirements.txt | 3 +- .../requirements_basic/.python-version | 1 + .../.python-version | 2 + .../requirements.txt | 0 .../runtime.txt | 3 + .../runtime_txt_eol_version/requirements.txt | 0 .../runtime.txt | 0 .../requirements.txt | 0 .../runtime.txt | 0 spec/fixtures/runtime_txt_only/runtime.txt | 1 - .../requirements.txt | 1 - .../runtime.txt | 3 - spec/fixtures/setup_py_only/.python-version | 1 + spec/hatchet/ci_spec.rb | 12 +- spec/hatchet/package_manager_spec.rb | 4 +- spec/hatchet/pip_spec.rb | 22 +- spec/hatchet/pipenv_spec.rb | 322 ++++++----------- spec/hatchet/python_update_warning_spec.rb | 6 +- spec/hatchet/python_version_spec.rb | 331 +++++++++++------- spec/hatchet/stack_spec.rb | 4 +- 91 files changed, 630 insertions(+), 726 deletions(-) create mode 100644 spec/fixtures/ci_pipenv/.python-version create mode 100644 spec/fixtures/ci_requirements/.python-version delete mode 100644 spec/fixtures/pipenv_and_runtime_txt/runtime.txt rename spec/fixtures/{pipenv_python_3.12 => pipenv_basic}/Pipfile (85%) rename spec/fixtures/{pipenv_python_3.12 => pipenv_basic}/Pipfile.lock (59%) rename spec/fixtures/{pipenv_python_version_unspecified => pipenv_basic}/bin/compile (100%) rename spec/fixtures/{pipenv_python_version_unspecified => pipenv_basic}/bin/detect (100%) rename spec/fixtures/{pipenv_python_version_unspecified => pipenv_basic}/setup.py (100%) delete mode 100644 spec/fixtures/pipenv_python_3.10/Pipfile delete mode 100644 spec/fixtures/pipenv_python_3.11/Pipfile delete mode 100644 spec/fixtures/pipenv_python_3.11/Pipfile.lock delete mode 100644 spec/fixtures/pipenv_python_3.13/Pipfile delete mode 100644 spec/fixtures/pipenv_python_3.13/Pipfile.lock delete mode 100644 spec/fixtures/pipenv_python_3.7/Pipfile.lock delete mode 100644 spec/fixtures/pipenv_python_3.8/Pipfile delete mode 100644 spec/fixtures/pipenv_python_3.8/Pipfile.lock delete mode 100644 spec/fixtures/pipenv_python_3.9/Pipfile delete mode 100644 spec/fixtures/pipenv_python_3.9/Pipfile.lock create mode 100644 spec/fixtures/pipenv_python_version_and_python_version_file/.python-version rename spec/fixtures/{pipenv_and_runtime_txt => pipenv_python_version_and_python_version_file}/Pipfile (60%) rename spec/fixtures/{pipenv_and_runtime_txt => pipenv_python_version_and_python_version_file}/Pipfile.lock (59%) rename spec/fixtures/{pipenv_python_3.7 => pipenv_python_version_eol}/Pipfile (85%) rename spec/fixtures/{pipenv_python_3.10 => pipenv_python_version_eol}/Pipfile.lock (55%) rename spec/fixtures/{python_2.7/requirements.txt => pyproject_toml_only/pyproject.toml} (100%) rename spec/fixtures/{runtime_txt_only => pyproject_toml_only}/subdir/some-file (100%) create mode 100644 spec/fixtures/python_3.10/.python-version delete mode 100644 spec/fixtures/python_3.10/runtime.txt create mode 100644 spec/fixtures/python_3.11/.python-version delete mode 100644 spec/fixtures/python_3.11/runtime.txt create mode 100644 spec/fixtures/python_3.12/.python-version delete mode 100644 spec/fixtures/python_3.12/runtime.txt create mode 100644 spec/fixtures/python_3.13/.python-version delete mode 100644 spec/fixtures/python_3.13/runtime.txt delete mode 100644 spec/fixtures/python_3.7/runtime.txt create mode 100644 spec/fixtures/python_3.8/.python-version delete mode 100644 spec/fixtures/python_3.8/runtime.txt create mode 100644 spec/fixtures/python_3.9/.python-version delete mode 100644 spec/fixtures/python_3.9/runtime.txt create mode 100644 spec/fixtures/python_version_eol/.python-version rename spec/fixtures/{python_3.7 => python_version_eol}/requirements.txt (100%) create mode 100644 spec/fixtures/python_version_file_invalid_version/.python-version rename spec/fixtures/{python_version_invalid => python_version_file_invalid_version}/requirements.txt (100%) create mode 100644 spec/fixtures/python_version_file_multiple_versions/.python-version create mode 100644 spec/fixtures/python_version_file_multiple_versions/requirements.txt create mode 100644 spec/fixtures/python_version_file_no_version/.python-version create mode 100644 spec/fixtures/python_version_file_no_version/requirements.txt create mode 100644 spec/fixtures/python_version_non_existent_major/.python-version delete mode 100644 spec/fixtures/python_version_non_existent_major/runtime.txt create mode 100644 spec/fixtures/python_version_non_existent_patch/.python-version delete mode 100644 spec/fixtures/python_version_non_existent_patch/runtime.txt create mode 100644 spec/fixtures/python_version_outdated/.python-version delete mode 100644 spec/fixtures/python_version_outdated/runtime.txt create mode 100644 spec/fixtures/requirements_basic/.python-version create mode 100644 spec/fixtures/runtime_txt_and_python_version_file/.python-version create mode 100644 spec/fixtures/runtime_txt_and_python_version_file/requirements.txt create mode 100644 spec/fixtures/runtime_txt_and_python_version_file/runtime.txt create mode 100644 spec/fixtures/runtime_txt_eol_version/requirements.txt rename spec/fixtures/{python_2.7 => runtime_txt_eol_version}/runtime.txt (100%) create mode 100644 spec/fixtures/runtime_txt_invalid_version/requirements.txt rename spec/fixtures/{python_version_invalid => runtime_txt_invalid_version}/runtime.txt (100%) delete mode 100644 spec/fixtures/runtime_txt_only/runtime.txt delete mode 100644 spec/fixtures/runtime_txt_with_stray_whitespace/requirements.txt delete mode 100644 spec/fixtures/runtime_txt_with_stray_whitespace/runtime.txt create mode 100644 spec/fixtures/setup_py_only/.python-version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a319a1b86..d754e02ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,4 +73,4 @@ jobs: - name: Run buildpack using default app fixture run: make run - name: Run buildpack using an app fixture that's expected to fail - run: make run FIXTURE=spec/fixtures/python_version_invalid/ + run: make run FIXTURE=spec/fixtures/python_version_file_invalid_version/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aada1653b..7563bc94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added support for configuring the Python version using a `.python-version` file. Both the `3.N` and `3.N.N` version forms are supported (the former is recommended). The existing `runtime.txt` file will take precedence if both files are found, however, we recommend switching to `.python-version` since it is more commonly supported in the Python ecosystem. ([#1664](https://github.com/heroku/heroku-buildpack-python/pull/1664)) +- Added support for specifying only the Python major version in `runtime.txt` instead of requiring the full Python version (for example `python-3.N` instead of `python-3.N.N`). ([#1664](https://github.com/heroku/heroku-buildpack-python/pull/1664)) ## [v260] - 2024-10-10 diff --git a/README.md b/README.md index f590addf9..235dffcd4 100644 --- a/README.md +++ b/README.md @@ -8,61 +8,52 @@ This is the official [Heroku buildpack](https://devcenter.heroku.com/articles/bu Recommended web frameworks include **Django** and **Flask**, among others. The recommended webserver is **Gunicorn**. There are no restrictions around what software can be used (as long as it's pip-installable). Web processes must bind to `$PORT`, and only the HTTP protocol is permitted for incoming connections. -See it in Action ----------------- -``` -$ ls -my-application requirements.txt runtime.txt - -$ git push heroku main -Counting objects: 4, done. -Delta compression using up to 8 threads. -Compressing objects: 100% (2/2), done. -Writing objects: 100% (4/4), 276 bytes | 276.00 KiB/s, done. -Total 4 (delta 0), reused 0 (delta 0) -remote: Compressing source files... done. -remote: Building source: -remote: -remote: -----> Python app detected -remote: -----> Installing python -remote: -----> Installing pip -remote: -----> Installing SQLite3 -remote: -----> Installing requirements with pip -remote: Collecting flask (from -r /tmp/build_c2c067ef79ff14c9bf1aed6796f9ed1f/requirements.txt (line 1)) -remote: Downloading ... -remote: Installing collected packages: Werkzeug, click, MarkupSafe, Jinja2, itsdangerous, flask -remote: Successfully installed Jinja2-2.10 MarkupSafe-1.1.0 Werkzeug-0.14.1 click-7.0 flask-1.0.2 itsdangerous-1.1.0 -remote: -remote: -----> Discovering process types -remote: Procfile declares types -> (none) -remote: -``` - -A `requirements.txt` must be present at the root of your application's repository to deploy. - -To specify your python version, you also need a `runtime.txt` file - unless you are using the default Python runtime version. - -Current default Python Runtime: Python 3.12.7 - -Alternatively, you can provide a `setup.py` file, or a `Pipfile`. -Using `pipenv` will generate `runtime.txt` at build time if one of the field `python_version` or `python_full_version` is specified in the `requires` section of your `Pipfile`. - -Specify a Buildpack Version ---------------------------- - -You can specify the latest production release of this buildpack for upcoming builds of an existing application: - - $ heroku buildpacks:set heroku/python - - -Specify a Python Runtime ------------------------- - -Supported runtime options include: - -- `python-3.13.0` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.12.7` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.11.10` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.10.15` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.9.20` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.8.20` on Heroku-20 only +## Getting Started + +See the [Getting Started on Heroku with Python](https://devcenter.heroku.com/articles/getting-started-with-python) tutorial. + +## Application Requirements + +A `requirements.txt` or `Pipfile` file must be present in the root (top-level) directory of your app's source code. + +## Configuration + +### Python Version + +We recommend that you specify a Python version for your app rather than relying on the buildpack's default Python version. + +For example, to request the latest patch release of Python 3.13, create a `.python-version` file in +the root directory of your app containing: +`3.13` + +The buildpack will look for a Python version in the following places (in descending order of precedence): + +1. `runtime.txt` file (deprecated) +2. `.python-version` file (recommended) +3. The `python_full_version` field in the `Pipfile.lock` file +4. The `python_version` field in the `Pipfile.lock` file + +If none of those are found, the buildpack will use a default Python version for the first +build of an app, and then subsequent builds of that app will be pinned to that version +unless the build cache is cleared or you request a different version. + +The current default Python version is: 3.12 + +The supported Python versions are: + +- Python 3.13 +- Python 3.12 +- Python 3.11 +- Python 3.10 + +These Python versions are deprecated on Heroku: + +- Python 3.9 +- Python 3.8 (only available on the [Heroku-20](https://devcenter.heroku.com/articles/heroku-20-stack) stack) + +Python versions older than those listed above are no longer supported, since they have reached +end-of-life [upstream](https://devguide.python.org/versions/#supported-versions). + +## Documentation + +For more information about using Python on Heroku, see [Dev Center](https://devcenter.heroku.com/categories/python-support). diff --git a/bin/compile b/bin/compile index bc6621c15..f751965fc 100755 --- a/bin/compile +++ b/bin/compile @@ -154,6 +154,9 @@ fi python_version::read_requested_python_version "${BUILD_DIR}" "${package_manager}" "${cached_python_version}" requested_python_version python_version_origin meta_set "python_version_reason" "${python_version_origin}" +# TODO: More strongly recommend specifying a Python version (eg switch the messaging to +# be a warning instead, after version resolution, and mention .python-version inline) +# TODO: Add runtime.txt deprecation warning. case "${python_version_origin}" in default) puts-step "No Python version was specified. Using the buildpack default: Python ${requested_python_version}" diff --git a/bin/steps/python b/bin/steps/python index 725846d15..e4b605df2 100755 --- a/bin/steps/python +++ b/bin/steps/python @@ -18,7 +18,7 @@ PYTHON_URL="${S3_BASE_URL}/python-${python_full_version}-ubuntu-${UBUNTU_VERSION # TODO: Update this message to be more specific once Python 3.8 support is dropped. if ! curl --output /dev/null --silent --head --fail --retry 3 --retry-connrefused --connect-timeout 10 "${PYTHON_URL}"; then display_error <<-EOF - Error: Python ${python_full_version} is not available for this stack (${STACK}). + Error: Python ${python_full_version} isn't available for this stack (${STACK}). For a list of the supported Python versions, see: https://devcenter.heroku.com/articles/python-support#supported-runtimes @@ -35,6 +35,8 @@ function warn_if_patch_update_available() { # Extract the patch version component of the version strings (ie: the '5' in '3.10.5'). local requested_patch_number="${requested_full_version##*.}" local latest_patch_number="${latest_patch_version##*.}" + # TODO: Update this message to suggest using the .python-version major version syntax to stay up to date, + # once runtime.txt is deprecated and sticky-versioning only pins to the major version. if ((requested_patch_number < latest_patch_number)); then puts-warn puts-warn "A Python security update is available! Upgrade as soon as possible to: Python ${latest_patch_version}" diff --git a/lib/python_version.sh b/lib/python_version.sh index 820daf3a9..aa4df3539 100644 --- a/lib/python_version.sh +++ b/lib/python_version.sh @@ -18,8 +18,6 @@ DEFAULT_PYTHON_MAJOR_VERSION="${DEFAULT_PYTHON_FULL_VERSION%.*}" INT_REGEX="(0|[1-9][0-9]*)" # Versions of form N.N or N.N.N. PYTHON_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}(\.${INT_REGEX})?" -# Versions of form N.N.N only. -PYTHON_FULL_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}\.${INT_REGEX}" # Determine what Python version has been requested for the project. # @@ -29,8 +27,9 @@ PYTHON_FULL_VERSION_REGEX="${INT_REGEX}\.${INT_REGEX}\.${INT_REGEX}" # # If an app specifies the Python version via multiple means, then the order of precedence is: # 1. runtime.txt -# 2. Pipfile.lock (`python_full_version` field) -# 3. Pipfile.lock (`python_version` field) +# 2. .python-version +# 3. Pipfile.lock (`python_full_version` field) +# 4. Pipfile.lock (`python_version` field) # # If a version wasn't specified by the app, then new apps/those with an empty cache will use # a buildpack default version for the first build, and then subsequent cached builds will use @@ -58,6 +57,14 @@ function python_version::read_requested_python_version() { return 0 fi + local python_version_file_path="${build_dir}/.python-version" + if [[ -f "${python_version_file_path}" ]]; then + contents="$(cat "${python_version_file_path}")" + version="$(python_version::parse_python_version_file "${contents}")" + origin=".python-version" + return 0 + fi + if [[ "${package_manager}" == "pipenv" ]]; then version="$(python_version::read_pipenv_python_version "${build_dir}")" # The Python version fields in a Pipfile.lock are optional. @@ -79,40 +86,122 @@ function python_version::read_requested_python_version() { fi } -# Parse the contents of a runtime.txt file and return the Python version substring (e.g. `3.12.0`). +# Parse the contents of a runtime.txt file and return the Python version substring (e.g. `3.12` or `3.12.0`). function python_version::parse_runtime_txt() { local contents="${1}" - # The file must contain a string of form `python-N.N.N` (leading/trailing whitespace is permitted). - if [[ "${contents}" =~ ^[[:space:]]*python-(${PYTHON_FULL_VERSION_REGEX})[[:space:]]*$ ]]; then + # The file must contain a string of form `python-N.N` or `python-N.N.N`. + # Leading/trailing whitespace is permitted. + if [[ "${contents}" =~ ^[[:space:]]*python-(${PYTHON_VERSION_REGEX})[[:space:]]*$ ]]; then local version="${BASH_REMATCH[1]}" echo "${version}" else display_error <<-EOF Error: Invalid Python version in runtime.txt. - The Python version specified in 'runtime.txt' is not in + The Python version specified in 'runtime.txt' isn't in the correct format. The following file contents were found: ${contents} - However, the version string must begin with a 'python-' prefix, - followed by the version specified as '..'. - Comments are not supported. + However, the version must be specified as either: + 1. 'python-.' (recommended, for automatic patch updates) + 2. 'python-..' (to pin to an exact patch version) - For example, to request Python ${DEFAULT_PYTHON_FULL_VERSION}, use: - python-${DEFAULT_PYTHON_FULL_VERSION} + Remember to include the 'python-' prefix. Comments aren't supported. - Please update 'runtime.txt' to use a valid version string, or - else remove the file to instead use the default version - (currently Python ${DEFAULT_PYTHON_FULL_VERSION}). + For example, to request the latest version of Python ${DEFAULT_PYTHON_MAJOR_VERSION}, + update the 'runtime.txt' file so it contains: + python-${DEFAULT_PYTHON_MAJOR_VERSION} EOF - meta_set "failure_reason" "python-version-invalid" + meta_set "failure_reason" "runtime-txt::invalid-version" return 1 fi } +# Parse the contents of a .python-version file and return the Python version substring (e.g. `3.12` or `3.12.0`). +function python_version::parse_python_version_file() { + local contents="${1}" + local version_lines=() + + while IFS= read -r line; do + # Ignore lines that only contain whitespace and/or comments. + if [[ ! "${line}" =~ ^[[:space:]]*(#.*)?$ ]]; then + version_lines+=("${line}") + fi + done <<<"${contents}" + + case "${#version_lines[@]}" in + 1) + local line="${version_lines[0]}" + if [[ "${line}" =~ ^[[:space:]]*(${PYTHON_VERSION_REGEX})[[:space:]]*$ ]]; then + local version="${BASH_REMATCH[1]}" + echo "${version}" + return 0 + else + display_error <<-EOF + Error: Invalid Python version in .python-version. + + The Python version specified in '.python-version' isn't in + the correct format. + + The following version was found: + ${line} + + However, the version must be specified as either: + 1. '.' (recommended, for automatic patch updates) + 2. '..' (to pin to an exact patch version) + + Don't 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_MAJOR_VERSION}, + update the '.python-version' file so it contains: + ${DEFAULT_PYTHON_MAJOR_VERSION} + EOF + meta_set "failure_reason" "python-version-file::invalid-version" + return 1 + fi + ;; + 0) + display_error <<-EOF + Error: Invalid Python version in .python-version. + + No Python version was found in the '.python-version' file. + + Update the file so that it contains a valid Python version + such as '${DEFAULT_PYTHON_MAJOR_VERSION}'. + + If the file already contains a version, check the line doesn't + begin with a '#', otherwise it will be treated as a comment. + EOF + meta_set "failure_reason" "python-version-file::no-version" + return 1 + ;; + *) + display_error <<-EOF + Error: Invalid Python version in .python-version. + + Multiple Python versions were found in the '.python-version' + file: + + $( + IFS=$'\n' + echo "${version_lines[*]}" + ) + + Update the file so it contains only one Python version. + + If the additional versions are actually comments, prefix + those lines with '#'. + EOF + meta_set "failure_reason" "python-version-file::multiple-versions" + return 1 + ;; + esac +} + # Read the Python version from a Pipfile.lock, which can exist in one of two optional fields, # `python_full_version` (as N.N.N) and `python_version` (as N.N). If both fields are # defined, we will use the value set in `python_full_version`. See: @@ -130,16 +219,16 @@ function python_version::read_pipenv_python_version() { if ! version=$(jq --raw-output '._meta.requires.python_full_version // ._meta.requires.python_version' "${pipfile_lock_path}" 2>&1); then display_error <<-EOF - Error: Cannot parse Pipfile.lock. + Error: Can't parse Pipfile.lock. - A Pipfile.lock file was found, however, it could not be parsed: + A Pipfile.lock file was found, however, it couldn't be parsed: ${version} This is likely due to it not being valid JSON. Run 'pipenv lock' to regenerate/fix the lockfile. EOF - meta_set "failure_reason" "pipfile-lock-invalid" + meta_set "failure_reason" "pipfile-lock::invalid-json" return 1 fi @@ -158,14 +247,14 @@ function python_version::read_pipenv_python_version() { Error: Invalid Python version in Pipfile / Pipfile.lock. The Python version specified in Pipfile / Pipfile.lock by the - 'python_version' or 'python_full_version' field is not valid. + 'python_version' or 'python_full_version' field isn't valid. 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) + 1. '.' (recommended, for automatic patch updates) + 2. '..' (to pin to an exact patch version) Please update your 'Pipfile' to use a valid Python version and then run 'pipenv lock' to regenerate the lockfile. @@ -173,13 +262,13 @@ function python_version::read_pipenv_python_version() { For more information, see: https://pipenv.pypa.io/en/latest/specifiers.html#specifying-versions-of-python EOF - meta_set "failure_reason" "python-version-invalid" + meta_set "failure_reason" "pipfile-lock::invalid-version" return 1 fi } # Resolve a requested Python version (which can be of form N.N or N.N.N) to a specific -# Python version of form N.N.N. Rejects Python major versions that are not supported. +# Python version of form N.N.N. Rejects Python major versions that aren't supported. function python_version::resolve_python_version() { local requested_python_version="${1}" local python_version_origin="${2}" @@ -197,7 +286,7 @@ function python_version::resolve_python_version() { display_error <<-EOF Error: The cached Python version has reached end-of-life. - Your app does not specify a Python version, and so normally + Your app doesn't specify a Python version, and so normally would use the version cached from the last build (${requested_python_version}). However, Python ${major}.${minor} has reached its upstream end-of-life, @@ -207,8 +296,8 @@ function python_version::resolve_python_version() { As such, it is no longer supported by this buildpack. Please upgrade to a newer Python version by creating a - 'runtime.txt' file that contains a Python version like: - python-${DEFAULT_PYTHON_FULL_VERSION} + '.python-version' file that contains a Python version like: + ${DEFAULT_PYTHON_MAJOR_VERSION} For a list of the supported Python versions, see: https://devcenter.heroku.com/articles/python-support#supported-runtimes @@ -230,19 +319,19 @@ function python_version::resolve_python_version() { https://devcenter.heroku.com/articles/python-support#supported-runtimes EOF fi - meta_set "failure_reason" "python-version-eol" + meta_set "failure_reason" "python-version::eol" return 1 fi if (((major == 3 && minor > 13) || major >= 4)); then if [[ "${python_version_origin}" == "cached" ]]; then display_error <<-EOF - Error: The cached Python version is not recognised. + Error: The cached Python version isn't recognised. - Your app does not specify a Python version, and so normally + Your app doesn't specify a Python version, and so normally would use the version cached from the last build (${requested_python_version}). - However, Python ${major}.${minor} is not recognised by this version + However, Python ${major}.${minor} isn't recognised by this version of the buildpack. This can occur if you have downgraded the version of the @@ -252,9 +341,9 @@ function python_version::resolve_python_version() { EOF else display_error <<-EOF - Error: The requested Python version is not recognised. + Error: The requested Python version isn't recognised. - The requested Python version ${major}.${minor} is not recognised. + The requested Python version ${major}.${minor} isn't recognised. Check that this Python version has been officially released, and that the Python buildpack has added support for it: @@ -269,7 +358,7 @@ function python_version::resolve_python_version() { by updating the version configured via the '${python_version_origin}' file. EOF fi - meta_set "failure_reason" "python-version-unknown" + meta_set "failure_reason" "python-version::unknown-major" return 1 fi diff --git a/spec/fixtures/ci_pipenv/.python-version b/spec/fixtures/ci_pipenv/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/ci_pipenv/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/spec/fixtures/ci_requirements/.python-version b/spec/fixtures/ci_requirements/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/ci_requirements/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/spec/fixtures/pipenv_and_runtime_txt/runtime.txt b/spec/fixtures/pipenv_and_runtime_txt/runtime.txt deleted file mode 100644 index 32905d6e0..000000000 --- a/spec/fixtures/pipenv_and_runtime_txt/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.7 diff --git a/spec/fixtures/pipenv_python_3.12/Pipfile b/spec/fixtures/pipenv_basic/Pipfile similarity index 85% rename from spec/fixtures/pipenv_python_3.12/Pipfile rename to spec/fixtures/pipenv_basic/Pipfile index 9bf60112c..883fd6c01 100644 --- a/spec/fixtures/pipenv_python_3.12/Pipfile +++ b/spec/fixtures/pipenv_basic/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] diff --git a/spec/fixtures/pipenv_python_3.12/Pipfile.lock b/spec/fixtures/pipenv_basic/Pipfile.lock similarity index 59% rename from spec/fixtures/pipenv_python_3.12/Pipfile.lock rename to spec/fixtures/pipenv_basic/Pipfile.lock index cc5ce05bc..30824a47d 100644 --- a/spec/fixtures/pipenv_python_3.12/Pipfile.lock +++ b/spec/fixtures/pipenv_basic/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "13de6076b4dc8bbad334b7e4ae8e8ae63f7f8b4aad794c7f0173871b6927c8d0" + "sha256": "9661ed313a79ccb68c7dc4e639068f86ddd91e307ec2ed60498858d002e9b547" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==4.12.2" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_version_unspecified/bin/compile b/spec/fixtures/pipenv_basic/bin/compile similarity index 100% rename from spec/fixtures/pipenv_python_version_unspecified/bin/compile rename to spec/fixtures/pipenv_basic/bin/compile diff --git a/spec/fixtures/pipenv_python_version_unspecified/bin/detect b/spec/fixtures/pipenv_basic/bin/detect similarity index 100% rename from spec/fixtures/pipenv_python_version_unspecified/bin/detect rename to spec/fixtures/pipenv_basic/bin/detect diff --git a/spec/fixtures/pipenv_python_version_unspecified/setup.py b/spec/fixtures/pipenv_basic/setup.py similarity index 100% rename from spec/fixtures/pipenv_python_version_unspecified/setup.py rename to spec/fixtures/pipenv_basic/setup.py diff --git a/spec/fixtures/pipenv_no_lockfile/Pipfile b/spec/fixtures/pipenv_no_lockfile/Pipfile index 67803f45b..883fd6c01 100644 --- a/spec/fixtures/pipenv_no_lockfile/Pipfile +++ b/spec/fixtures/pipenv_no_lockfile/Pipfile @@ -4,6 +4,9 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] + +[requires] +python_version = "3.12" diff --git a/spec/fixtures/pipenv_python_3.10/Pipfile b/spec/fixtures/pipenv_python_3.10/Pipfile deleted file mode 100644 index 37291dc4f..000000000 --- a/spec/fixtures/pipenv_python_3.10/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -urllib3 = "*" - -[dev-packages] - -[requires] -python_version = "3.10" diff --git a/spec/fixtures/pipenv_python_3.11/Pipfile b/spec/fixtures/pipenv_python_3.11/Pipfile deleted file mode 100644 index 1d28b2380..000000000 --- a/spec/fixtures/pipenv_python_3.11/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -urllib3 = "*" - -[dev-packages] - -[requires] -python_version = "3.11" diff --git a/spec/fixtures/pipenv_python_3.11/Pipfile.lock b/spec/fixtures/pipenv_python_3.11/Pipfile.lock deleted file mode 100644 index 5b0ccff7d..000000000 --- a/spec/fixtures/pipenv_python_3.11/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "c23e4774efa7153ba8addfbe118e9cc0c526a063275824cf25cb2198e0ff1b33" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.11" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "urllib3": { - "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" - } - }, - "develop": {} -} diff --git a/spec/fixtures/pipenv_python_3.13/Pipfile b/spec/fixtures/pipenv_python_3.13/Pipfile deleted file mode 100644 index 73f50fc86..000000000 --- a/spec/fixtures/pipenv_python_3.13/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -urllib3 = "*" - -[dev-packages] - -[requires] -python_version = "3.13" diff --git a/spec/fixtures/pipenv_python_3.13/Pipfile.lock b/spec/fixtures/pipenv_python_3.13/Pipfile.lock deleted file mode 100644 index dcd0b3ca7..000000000 --- a/spec/fixtures/pipenv_python_3.13/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "60dc67ee4223a391c5e0ae2b4d7ea54a7b245773d76b6ff82156dda97a3e4fb2" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.13" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "urllib3": { - "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.3" - } - }, - "develop": {} -} diff --git a/spec/fixtures/pipenv_python_3.7/Pipfile.lock b/spec/fixtures/pipenv_python_3.7/Pipfile.lock deleted file mode 100644 index 90c8735f4..000000000 --- a/spec/fixtures/pipenv_python_3.7/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "b4a533c276cc9b89b506de9007fbe2210b33f8322ea81f62989117e5ec3adc54" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "urllib3": { - "hashes": [ - "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594", - "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.0.5" - } - }, - "develop": {} -} diff --git a/spec/fixtures/pipenv_python_3.8/Pipfile b/spec/fixtures/pipenv_python_3.8/Pipfile deleted file mode 100644 index 5aea20cfa..000000000 --- a/spec/fixtures/pipenv_python_3.8/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -urllib3 = "*" - -[dev-packages] - -[requires] -python_version = "3.8" diff --git a/spec/fixtures/pipenv_python_3.8/Pipfile.lock b/spec/fixtures/pipenv_python_3.8/Pipfile.lock deleted file mode 100644 index f1618e6a9..000000000 --- a/spec/fixtures/pipenv_python_3.8/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "e9eb0bba6cb0a97533d25cd3d68db92d4afd6b1dc88cd7a51607d8c34475eae0" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "urllib3": { - "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" - } - }, - "develop": {} -} diff --git a/spec/fixtures/pipenv_python_3.9/Pipfile b/spec/fixtures/pipenv_python_3.9/Pipfile deleted file mode 100644 index 7e9cf996d..000000000 --- a/spec/fixtures/pipenv_python_3.9/Pipfile +++ /dev/null @@ -1,12 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -urllib3 = "*" - -[dev-packages] - -[requires] -python_version = "3.9" diff --git a/spec/fixtures/pipenv_python_3.9/Pipfile.lock b/spec/fixtures/pipenv_python_3.9/Pipfile.lock deleted file mode 100644 index 527944fb6..000000000 --- a/spec/fixtures/pipenv_python_3.9/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "7776f372e3411c742b3e8a4209e67f0832f9e249d4c86ce28ece146afaef68d1" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.9" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "urllib3": { - "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" - } - }, - "develop": {} -} diff --git a/spec/fixtures/pipenv_python_full_version/Pipfile b/spec/fixtures/pipenv_python_full_version/Pipfile index aa0852315..f3e9b3e9e 100644 --- a/spec/fixtures/pipenv_python_full_version/Pipfile +++ b/spec/fixtures/pipenv_python_full_version/Pipfile @@ -4,12 +4,13 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] [requires] # Uses the oldest Python version supported by all stacks, to validate Pipenv works with it. python_full_version = "3.9.0" -# Tests that `python_full_version` takes precedence. + +# `python_full_version` should take precedence over this. python_version = "3.12" diff --git a/spec/fixtures/pipenv_python_full_version/Pipfile.lock b/spec/fixtures/pipenv_python_full_version/Pipfile.lock index 888c9982e..3269fa9ce 100644 --- a/spec/fixtures/pipenv_python_full_version/Pipfile.lock +++ b/spec/fixtures/pipenv_python_full_version/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "068e8668ba7ae121efca9caf66d4796267713afe33c711e01a0852fe5e9a1848" + "sha256": "20aa9cfaef42c18684f934bc2c4e062167465b9a062fa284441acf1fe712dc60" }, "pipfile-spec": 6, "requires": { @@ -17,14 +17,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.3" + "version": "==4.12.2" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile index 1782422d3..4016fd876 100644 --- a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile +++ b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] diff --git a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock index 107e6f348..a9d6269b5 100644 --- a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock +++ b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c629159445d228792003fe74526738c9155072a74a29f2d4920fc8aab878db0a" + "sha256": "254efba85c5858a7dfe232cb38f9ead91bf6e50bbf82f51a8a5a01904ca8712e" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.3" + "version": "==4.12.2" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_version_and_python_version_file/.python-version b/spec/fixtures/pipenv_python_version_and_python_version_file/.python-version new file mode 100644 index 000000000..6d977fdcf --- /dev/null +++ b/spec/fixtures/pipenv_python_version_and_python_version_file/.python-version @@ -0,0 +1,2 @@ +# This should take precedence over the Python version in Pipfile.lock. +3.13 diff --git a/spec/fixtures/pipenv_and_runtime_txt/Pipfile b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile similarity index 60% rename from spec/fixtures/pipenv_and_runtime_txt/Pipfile rename to spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile index 1d28b2380..5afe25b60 100644 --- a/spec/fixtures/pipenv_and_runtime_txt/Pipfile +++ b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile @@ -4,9 +4,10 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] [requires] +# The version in .python-version should take precedence over this. python_version = "3.11" diff --git a/spec/fixtures/pipenv_and_runtime_txt/Pipfile.lock b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile.lock similarity index 59% rename from spec/fixtures/pipenv_and_runtime_txt/Pipfile.lock rename to spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile.lock index 5b0ccff7d..c505f1d3c 100644 --- a/spec/fixtures/pipenv_and_runtime_txt/Pipfile.lock +++ b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c23e4774efa7153ba8addfbe118e9cc0c526a063275824cf25cb2198e0ff1b33" + "sha256": "5a5846099fd5ceb0291e704a94f49cf0b8a226109fdae915a61253b56eaf3ed6" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==4.12.2" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_3.7/Pipfile b/spec/fixtures/pipenv_python_version_eol/Pipfile similarity index 85% rename from spec/fixtures/pipenv_python_3.7/Pipfile rename to spec/fixtures/pipenv_python_version_eol/Pipfile index c973bafe3..afe33e1b7 100644 --- a/spec/fixtures/pipenv_python_3.7/Pipfile +++ b/spec/fixtures/pipenv_python_version_eol/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] diff --git a/spec/fixtures/pipenv_python_3.10/Pipfile.lock b/spec/fixtures/pipenv_python_version_eol/Pipfile.lock similarity index 55% rename from spec/fixtures/pipenv_python_3.10/Pipfile.lock rename to spec/fixtures/pipenv_python_version_eol/Pipfile.lock index 7b07be0dc..cf0460577 100644 --- a/spec/fixtures/pipenv_python_3.10/Pipfile.lock +++ b/spec/fixtures/pipenv_python_version_eol/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "0975b9caa75f76ce36c7cf6ee6fc4b63510c0970ff8e322e0b5e6ee7af2d32e8" + "sha256": "319488142f6df2cfe3244f5ebb99c88eaa0ef808d4560570175577ce045dd02e" }, "pipfile-spec": 6, "requires": { - "python_version": "3.10" + "python_version": "3.7" }, "sources": [ { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==4.12.2" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_version_invalid/Pipfile b/spec/fixtures/pipenv_python_version_invalid/Pipfile index 326889f7a..43fe5b889 100644 --- a/spec/fixtures/pipenv_python_version_invalid/Pipfile +++ b/spec/fixtures/pipenv_python_version_invalid/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] diff --git a/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock b/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock index 47d197cdd..054e8d6cc 100644 --- a/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock +++ b/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "584096cd3f6a5c68b5f2e21993fc104773efb507ed1945ca26c789d86b22d883" + "sha256": "c86b61d10a1742dfbc75378ee86a81c24420506b154b9c19d69aa6e6c02c61b2" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==4.12.2" } }, "develop": {} diff --git a/spec/fixtures/python_2.7/requirements.txt b/spec/fixtures/pyproject_toml_only/pyproject.toml similarity index 100% rename from spec/fixtures/python_2.7/requirements.txt rename to spec/fixtures/pyproject_toml_only/pyproject.toml diff --git a/spec/fixtures/runtime_txt_only/subdir/some-file b/spec/fixtures/pyproject_toml_only/subdir/some-file similarity index 100% rename from spec/fixtures/runtime_txt_only/subdir/some-file rename to spec/fixtures/pyproject_toml_only/subdir/some-file diff --git a/spec/fixtures/python_3.10/.python-version b/spec/fixtures/python_3.10/.python-version new file mode 100644 index 000000000..3aa278d1a --- /dev/null +++ b/spec/fixtures/python_3.10/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.10 diff --git a/spec/fixtures/python_3.10/requirements.txt b/spec/fixtures/python_3.10/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_3.10/requirements.txt +++ b/spec/fixtures/python_3.10/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/python_3.10/runtime.txt b/spec/fixtures/python_3.10/runtime.txt deleted file mode 100644 index bb60b7f69..000000000 --- a/spec/fixtures/python_3.10/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.10.15 diff --git a/spec/fixtures/python_3.11/.python-version b/spec/fixtures/python_3.11/.python-version new file mode 100644 index 000000000..576fc2bfe --- /dev/null +++ b/spec/fixtures/python_3.11/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.11 diff --git a/spec/fixtures/python_3.11/requirements.txt b/spec/fixtures/python_3.11/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_3.11/requirements.txt +++ b/spec/fixtures/python_3.11/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/python_3.11/runtime.txt b/spec/fixtures/python_3.11/runtime.txt deleted file mode 100644 index e34519556..000000000 --- a/spec/fixtures/python_3.11/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.11.10 diff --git a/spec/fixtures/python_3.12/.python-version b/spec/fixtures/python_3.12/.python-version new file mode 100644 index 000000000..936a75516 --- /dev/null +++ b/spec/fixtures/python_3.12/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.12 diff --git a/spec/fixtures/python_3.12/requirements.txt b/spec/fixtures/python_3.12/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_3.12/requirements.txt +++ b/spec/fixtures/python_3.12/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/python_3.12/runtime.txt b/spec/fixtures/python_3.12/runtime.txt deleted file mode 100644 index 32905d6e0..000000000 --- a/spec/fixtures/python_3.12/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.7 diff --git a/spec/fixtures/python_3.13/.python-version b/spec/fixtures/python_3.13/.python-version new file mode 100644 index 000000000..d23c34390 --- /dev/null +++ b/spec/fixtures/python_3.13/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.13 diff --git a/spec/fixtures/python_3.13/requirements.txt b/spec/fixtures/python_3.13/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_3.13/requirements.txt +++ b/spec/fixtures/python_3.13/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/python_3.13/runtime.txt b/spec/fixtures/python_3.13/runtime.txt deleted file mode 100644 index dae7fec92..000000000 --- a/spec/fixtures/python_3.13/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.13.0 diff --git a/spec/fixtures/python_3.7/runtime.txt b/spec/fixtures/python_3.7/runtime.txt deleted file mode 100644 index 113b0bcca..000000000 --- a/spec/fixtures/python_3.7/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.7.17 diff --git a/spec/fixtures/python_3.8/.python-version b/spec/fixtures/python_3.8/.python-version new file mode 100644 index 000000000..6a8981ea9 --- /dev/null +++ b/spec/fixtures/python_3.8/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.8 diff --git a/spec/fixtures/python_3.8/requirements.txt b/spec/fixtures/python_3.8/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_3.8/requirements.txt +++ b/spec/fixtures/python_3.8/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/python_3.8/runtime.txt b/spec/fixtures/python_3.8/runtime.txt deleted file mode 100644 index 7494875b9..000000000 --- a/spec/fixtures/python_3.8/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.8.20 diff --git a/spec/fixtures/python_3.9/.python-version b/spec/fixtures/python_3.9/.python-version new file mode 100644 index 000000000..54f2f0246 --- /dev/null +++ b/spec/fixtures/python_3.9/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.9 diff --git a/spec/fixtures/python_3.9/requirements.txt b/spec/fixtures/python_3.9/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_3.9/requirements.txt +++ b/spec/fixtures/python_3.9/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/python_3.9/runtime.txt b/spec/fixtures/python_3.9/runtime.txt deleted file mode 100644 index 57f558859..000000000 --- a/spec/fixtures/python_3.9/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.9.20 diff --git a/spec/fixtures/python_version_eol/.python-version b/spec/fixtures/python_version_eol/.python-version new file mode 100644 index 000000000..81cd8ab6c --- /dev/null +++ b/spec/fixtures/python_version_eol/.python-version @@ -0,0 +1,8 @@ +# Comments are supported. + # Even when indented +# +# So are empty lines, and leading/trailing whitespace. + + + 3.7 + diff --git a/spec/fixtures/python_3.7/requirements.txt b/spec/fixtures/python_version_eol/requirements.txt similarity index 100% rename from spec/fixtures/python_3.7/requirements.txt rename to spec/fixtures/python_version_eol/requirements.txt diff --git a/spec/fixtures/python_version_file_invalid_version/.python-version b/spec/fixtures/python_version_file_invalid_version/.python-version new file mode 100644 index 000000000..4ffb299eb --- /dev/null +++ b/spec/fixtures/python_version_file_invalid_version/.python-version @@ -0,0 +1,10 @@ +# Comments are supported. + # Even when indented +# +# So are empty lines, and leading/trailing whitespace. + + + 3.12.0invalid + + +# 2.7.18 diff --git a/spec/fixtures/python_version_invalid/requirements.txt b/spec/fixtures/python_version_file_invalid_version/requirements.txt similarity index 100% rename from spec/fixtures/python_version_invalid/requirements.txt rename to spec/fixtures/python_version_file_invalid_version/requirements.txt diff --git a/spec/fixtures/python_version_file_multiple_versions/.python-version b/spec/fixtures/python_version_file_multiple_versions/.python-version new file mode 100644 index 000000000..99b9a4e4c --- /dev/null +++ b/spec/fixtures/python_version_file_multiple_versions/.python-version @@ -0,0 +1,4 @@ +// invalid comment +# Valid comment +3.12 +2.7 diff --git a/spec/fixtures/python_version_file_multiple_versions/requirements.txt b/spec/fixtures/python_version_file_multiple_versions/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_file_no_version/.python-version b/spec/fixtures/python_version_file_no_version/.python-version new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_file_no_version/requirements.txt b/spec/fixtures/python_version_file_no_version/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_non_existent_major/.python-version b/spec/fixtures/python_version_non_existent_major/.python-version new file mode 100644 index 000000000..f1f7e4608 --- /dev/null +++ b/spec/fixtures/python_version_non_existent_major/.python-version @@ -0,0 +1 @@ +3.999 diff --git a/spec/fixtures/python_version_non_existent_major/runtime.txt b/spec/fixtures/python_version_non_existent_major/runtime.txt deleted file mode 100644 index b6885d9e1..000000000 --- a/spec/fixtures/python_version_non_existent_major/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.999.0 diff --git a/spec/fixtures/python_version_non_existent_patch/.python-version b/spec/fixtures/python_version_non_existent_patch/.python-version new file mode 100644 index 000000000..bb40ea005 --- /dev/null +++ b/spec/fixtures/python_version_non_existent_patch/.python-version @@ -0,0 +1 @@ +3.12.999 diff --git a/spec/fixtures/python_version_non_existent_patch/runtime.txt b/spec/fixtures/python_version_non_existent_patch/runtime.txt deleted file mode 100644 index f9907fe4a..000000000 --- a/spec/fixtures/python_version_non_existent_patch/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.999 diff --git a/spec/fixtures/python_version_outdated/.python-version b/spec/fixtures/python_version_outdated/.python-version new file mode 100644 index 000000000..a5c4c7633 --- /dev/null +++ b/spec/fixtures/python_version_outdated/.python-version @@ -0,0 +1 @@ +3.9.0 diff --git a/spec/fixtures/python_version_outdated/runtime.txt b/spec/fixtures/python_version_outdated/runtime.txt deleted file mode 100644 index f72c5111f..000000000 --- a/spec/fixtures/python_version_outdated/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.9.0 diff --git a/spec/fixtures/python_version_unspecified/requirements.txt b/spec/fixtures/python_version_unspecified/requirements.txt index a42590beb..eec3a2223 100644 --- a/spec/fixtures/python_version_unspecified/requirements.txt +++ b/spec/fixtures/python_version_unspecified/requirements.txt @@ -1 +1,2 @@ -urllib3 +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.12.2 diff --git a/spec/fixtures/requirements_basic/.python-version b/spec/fixtures/requirements_basic/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/requirements_basic/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/spec/fixtures/runtime_txt_and_python_version_file/.python-version b/spec/fixtures/runtime_txt_and_python_version_file/.python-version new file mode 100644 index 000000000..154e97ed3 --- /dev/null +++ b/spec/fixtures/runtime_txt_and_python_version_file/.python-version @@ -0,0 +1,2 @@ +# The version in runtime.txt should take precedence over this. +3.9 diff --git a/spec/fixtures/runtime_txt_and_python_version_file/requirements.txt b/spec/fixtures/runtime_txt_and_python_version_file/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/runtime_txt_and_python_version_file/runtime.txt b/spec/fixtures/runtime_txt_and_python_version_file/runtime.txt new file mode 100644 index 000000000..406dcdde4 --- /dev/null +++ b/spec/fixtures/runtime_txt_and_python_version_file/runtime.txt @@ -0,0 +1,3 @@ + + python-3.13 + \ No newline at end of file diff --git a/spec/fixtures/runtime_txt_eol_version/requirements.txt b/spec/fixtures/runtime_txt_eol_version/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_2.7/runtime.txt b/spec/fixtures/runtime_txt_eol_version/runtime.txt similarity index 100% rename from spec/fixtures/python_2.7/runtime.txt rename to spec/fixtures/runtime_txt_eol_version/runtime.txt diff --git a/spec/fixtures/runtime_txt_invalid_version/requirements.txt b/spec/fixtures/runtime_txt_invalid_version/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_invalid/runtime.txt b/spec/fixtures/runtime_txt_invalid_version/runtime.txt similarity index 100% rename from spec/fixtures/python_version_invalid/runtime.txt rename to spec/fixtures/runtime_txt_invalid_version/runtime.txt diff --git a/spec/fixtures/runtime_txt_only/runtime.txt b/spec/fixtures/runtime_txt_only/runtime.txt deleted file mode 100644 index 32905d6e0..000000000 --- a/spec/fixtures/runtime_txt_only/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.7 diff --git a/spec/fixtures/runtime_txt_with_stray_whitespace/requirements.txt b/spec/fixtures/runtime_txt_with_stray_whitespace/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/runtime_txt_with_stray_whitespace/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/runtime_txt_with_stray_whitespace/runtime.txt b/spec/fixtures/runtime_txt_with_stray_whitespace/runtime.txt deleted file mode 100644 index 05a24a304..000000000 --- a/spec/fixtures/runtime_txt_with_stray_whitespace/runtime.txt +++ /dev/null @@ -1,3 +0,0 @@ - - python-3.12.7 - \ No newline at end of file diff --git a/spec/fixtures/setup_py_only/.python-version b/spec/fixtures/setup_py_only/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/setup_py_only/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/spec/hatchet/ci_spec.rb b/spec/hatchet/ci_spec.rb index 444b65e55..80a97706c 100644 --- a/spec/hatchet/ci_spec.rb +++ b/spec/hatchet/ci_spec.rb @@ -12,8 +12,7 @@ app.run_ci do |test_run| expect(test_run.output).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) -----> Python app detected - -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} - To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} -----> Installing SQLite3 @@ -70,8 +69,7 @@ expect(test_run.output).to include(<<~OUTPUT) -----> Python app detected - -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version -----> No change in requirements detected, installing from cache -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} @@ -92,8 +90,7 @@ app.run_ci do |test_run| expect(test_run.output).to match(Regexp.new(<<~REGEX)) -----> Python app detected - -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} - To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} -----> Installing Pipenv #{PIPENV_VERSION} @@ -148,8 +145,7 @@ expect(test_run.output).to match(Regexp.new(<<~REGEX)) -----> Python app detected - -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} -----> Installing Pipenv #{PIPENV_VERSION} diff --git a/spec/hatchet/package_manager_spec.rb b/spec/hatchet/package_manager_spec.rb index 70473fc81..cde1f154b 100644 --- a/spec/hatchet/package_manager_spec.rb +++ b/spec/hatchet/package_manager_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'Package manager support' do context 'when there are no supported package manager files' do - let(:app) { Hatchet::Runner.new('spec/fixtures/runtime_txt_only', allow_failure: true) } + let(:app) { Hatchet::Runner.new('spec/fixtures/pyproject_toml_only', allow_failure: true) } it 'fails the build with an informative error message' do app.deploy do |app| @@ -19,7 +19,7 @@ remote: ! remote: ! Currently the root directory of your app contains: remote: ! - remote: ! runtime.txt + remote: ! pyproject.toml remote: ! subdir/ remote: ! remote: ! If your app already has a package manager file, check that it: diff --git a/spec/hatchet/pip_spec.rb b/spec/hatchet/pip_spec.rb index 178be593d..59b2ba0cc 100644 --- a/spec/hatchet/pip_spec.rb +++ b/spec/hatchet/pip_spec.rb @@ -22,8 +22,7 @@ app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 @@ -63,8 +62,7 @@ app.push! expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version remote: -----> No change in requirements detected, installing from cache remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} @@ -86,8 +84,7 @@ app.push! expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version remote: -----> Requirements file has been changed, clearing cached dependencies remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} @@ -107,19 +104,19 @@ end context 'when the package manager has changed from Pipenv to pip since the last build' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_unspecified') } + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_basic') } # TODO: Fix this case so the cache is actually cleared. it 'clears the cache before installing with pip' do app.deploy do |app| FileUtils.rm(['Pipfile', 'Pipfile.lock']) + FileUtils.cp(FIXTURE_DIR.join('requirements_basic/.python-version'), '.') FileUtils.cp(FIXTURE_DIR.join('requirements_basic/requirements.txt'), '.') app.commit! app.push! expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 @@ -142,7 +139,7 @@ it 'rewrites .pth, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Running post-compile hook remote: easy-install.pth:/app/.heroku/src/gunicorn remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py @@ -181,7 +178,7 @@ # Test that the cached .pth files work correctly. app.commit! app.push! - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Running post-compile hook remote: easy-install.pth:/app/.heroku/src/gunicorn remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py @@ -214,8 +211,7 @@ app.deploy do |app| expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 diff --git a/spec/hatchet/pipenv_spec.rb b/spec/hatchet/pipenv_spec.rb index 91d4fbb1a..8abe6fb6d 100644 --- a/spec/hatchet/pipenv_spec.rb +++ b/spec/hatchet/pipenv_spec.rb @@ -2,64 +2,20 @@ require_relative '../spec_helper' -RSpec.shared_examples 'builds using Pipenv with the requested Python version' do |requested_version, resolved_version| - it "builds with Python #{requested_version}" do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> Python app detected - remote: -----> Using Python #{requested_version} specified in Pipfile.lock - remote: -----> Installing Python #{resolved_version} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing Pipenv #{PIPENV_VERSION} - remote: -----> Installing SQLite3 - remote: -----> Installing dependencies with Pipenv - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - REGEX - end - end -end - RSpec.describe 'Pipenv support' do - context 'without a Pipfile.lock' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_no_lockfile') } - - it 'builds with the default Python version using just the Pipfile' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) - remote: -----> Python app detected - remote: ! No 'Pipfile.lock' found! We recommend you commit this into your repository. - remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing Pipenv #{PIPENV_VERSION} - remote: -----> Installing SQLite3 - remote: -----> Installing dependencies with Pipenv - remote: The flag --skip-lock has been reintroduced \\(but is not recommended\\). Without - remote: the lock resolver it is difficult to manage multiple package indexes, and hash - remote: checking is not provided. However it can help manage installs with current - remote: deficiencies in locking across platforms. - remote: Pipfile.lock not found, creating... - .+ - remote: Installing dependencies from Pipfile... - REGEX - end - end - end - - context 'with a Pipfile.lock but no Python version specified' do + context 'with a Pipfile.lock that is unchanged since the last build' do let(:buildpacks) { [:default, 'heroku-community/inline'] } - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_unspecified', buildpacks:) } + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_basic', buildpacks:) } # TODO: Run this on Heroku-22 too, once it has also migrated to the new build infrastructure. # (Currently the test fails on the old infrastructure due to subtle differences in system PATH elements.) - it 'builds with the default Python version', stacks: %w[heroku-20 heroku-24] do + it 'builds with the specified python_version and re-uses packages from the cache', + stacks: %w[heroku-20 heroku-24] do app.deploy do |app| # TODO: We should not be leaking the Pipenv installation into the app environment. expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in Pipfile.lock remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing Pipenv #{PIPENV_VERSION} @@ -98,127 +54,81 @@ remote: remote: \\ REGEX + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in Pipfile.lock + remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing SQLite3 + remote: -----> Installing dependencies with Pipenv + remote: Installing dependencies from Pipfile.lock \\(.+\\)... + remote: -----> Inline app detected + REGEX end end end - context 'with a Pipfile.lock containing an EOL python_version' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.7', allow_failure: true) } + # As well as testing the Pipfile.lock `python_full_version` field, this also tests: + # 1. That `python_full_version` takes precedence over the `python_version` field. + # 2. That Pipenv works on the oldest Python version supported by all stacks. + # 3. That the security update available message works for Pipenv too. + context 'with a Pipfile.lock containing python_full_version 3.9.0' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_full_version') } - it 'aborts the build with an EOL message' do + it 'builds with the outdated Python version specified and displays a deprecation warning' do app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~OUTPUT)) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> Using Python 3.7 specified in Pipfile.lock - remote: - remote: ! Error: The requested Python version has reached end-of-life. - remote: ! - remote: ! Python 3.7 has reached its upstream end-of-life, and is - remote: ! therefore no longer receiving security updates: - remote: ! https://devguide.python.org/versions/#supported-versions - remote: ! - remote: ! As such, it is no longer supported by this buildpack. + remote: -----> Using Python 3.9.0 specified in Pipfile.lock remote: ! - remote: ! Please upgrade to a newer Python version by updating the - remote: ! version configured via the 'Pipfile.lock' file. + remote: ! A Python security update is available! Upgrade as soon as possible to: Python #{LATEST_PYTHON_3_9} + remote: ! See: https://devcenter.heroku.com/articles/python-runtimes remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes - remote: - remote: ! Push rejected, failed to compile Python app. - OUTPUT + remote: -----> Installing Python 3.9.0 + remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing SQLite3 + remote: -----> Installing dependencies with Pipenv + remote: Installing dependencies from Pipfile.lock \\(.+\\)... + REGEX end end end - context 'with a Pipfile.lock containing python_version 3.8' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.8', allow_failure:) } + context 'when there is a both a Pipfile.lock python_version and a .python-version file' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_and_python_version_file') } - context 'when using Heroku-20', stacks: %w[heroku-20] do - it 'builds with the latest Python 3.8 but shows a deprecation warning' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> Python app detected - remote: -----> Using Python 3.8 specified in Pipfile.lock - remote: ! - remote: ! Python 3.8 will reach its upstream end-of-life in October 2024, at which - remote: ! point it will no longer receive security updates: - remote: ! https://devguide.python.org/versions/#supported-versions - remote: ! - remote: ! Support for Python 3.8 will be removed from this buildpack on December 4th, 2024. - remote: ! - remote: ! Upgrade to a newer Python version as soon as possible to keep your app secure. - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes - remote: ! - remote: -----> Installing Python #{LATEST_PYTHON_3_8} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing Pipenv #{PIPENV_VERSION} - remote: -----> Installing SQLite3 - remote: -----> Installing dependencies with Pipenv - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - REGEX - end - end - end - - context 'when using Heroku-22 or newer', stacks: %w[heroku-22 heroku-24] do - let(:allow_failure) { true } - - # We only support Python 3.8 on Heroku-20 and older. - it 'aborts the build with a version not available message' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python 3.8 specified in Pipfile.lock - remote: - remote: ! Error: Python #{LATEST_PYTHON_3_8} is not available for this stack (#{app.stack}). - remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes - remote: - remote: ! Push rejected, failed to compile Python app. - OUTPUT - end + it 'builds with the Python version from the .python-version file' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python 3.13 specified in .python-version + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies with Pipenv + remote: Installing dependencies from Pipfile.lock \\(.+\\)... + REGEX end end end - context 'with a Pipfile.lock containing python_version 3.9' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.9') } - - include_examples 'builds using Pipenv with the requested Python version', '3.9', LATEST_PYTHON_3_9 - end - - context 'with a Pipfile.lock containing python_version 3.10' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.10') } - - include_examples 'builds using Pipenv with the requested Python version', '3.10', LATEST_PYTHON_3_10 - end - - context 'with a Pipfile.lock containing python_version 3.11' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.11') } - - include_examples 'builds using Pipenv with the requested Python version', '3.11', LATEST_PYTHON_3_11 - end - - context 'with a Pipfile.lock containing python_version 3.12' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.12') } - - include_examples 'builds using Pipenv with the requested Python version', '3.12', LATEST_PYTHON_3_12 - end - - context 'with a Pipfile.lock containing python_version 3.13' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.13') } + context 'with a Pipfile.lock but no Python version specified' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_unspecified') } - it 'builds with latest Python 3.13' do + it 'builds with the default Python version' do app.deploy do |app| expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> Using Python 3.13 specified in Pipfile.lock - remote: -----> Installing Python #{LATEST_PYTHON_3_13} - remote: -----> Installing pip #{PIP_VERSION} + remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing SQLite3 remote: -----> Installing dependencies with Pipenv remote: Installing dependencies from Pipfile.lock \\(.+\\)... REGEX @@ -226,29 +136,28 @@ end end - # As well as testing `python_full_version`, this also tests: - # 1. That `python_full_version` takes precedence over `python_version`. - # 2. That Pipenv works on the oldest Python version supported by all stacks. - # 3. That the security update available message works for Pipenv too. - context 'with a Pipfile.lock containing python_full_version 3.9.0' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_full_version', allow_failure:) } + context 'without a Pipfile.lock' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_no_lockfile') } - it 'builds with the outdated Python version specified and displays a deprecation warning' do + it 'builds with the default Python version using just the Pipfile' do app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) remote: -----> Python app detected - remote: -----> Using Python 3.9.0 specified in Pipfile.lock - remote: ! - remote: ! A Python security update is available! Upgrade as soon as possible to: Python #{LATEST_PYTHON_3_9} - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes - remote: ! - remote: -----> Installing Python 3.9.0 + remote: ! No 'Pipfile.lock' found! We recommend you commit this into your repository. + remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing Pipenv #{PIPENV_VERSION} remote: -----> Installing SQLite3 remote: -----> Installing dependencies with Pipenv - remote: Installing dependencies from Pipfile.lock \\(.+\\)... + remote: The flag --skip-lock has been reintroduced \\(but is not recommended\\). Without + remote: the lock resolver it is difficult to manage multiple package indexes, and hash + remote: checking is not provided. However it can help manage installs with current + remote: deficiencies in locking across platforms. + remote: Pipfile.lock not found, creating... + .+ + remote: Installing dependencies from Pipfile... REGEX end end @@ -263,9 +172,9 @@ expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected remote: - remote: ! Error: Cannot parse Pipfile.lock. + remote: ! Error: Can't parse Pipfile.lock. remote: ! - remote: ! A Pipfile.lock file was found, however, it could not be parsed: + remote: ! A Pipfile.lock file was found, however, it couldn't be parsed: remote: ! (jq: )?parse error: Invalid numeric literal at line 1, column 8 remote: ! remote: ! This is likely due to it not being valid JSON. @@ -289,14 +198,14 @@ remote: ! Error: Invalid Python version in Pipfile / Pipfile.lock. remote: ! remote: ! The Python version specified in Pipfile / Pipfile.lock by the - remote: ! 'python_version' or 'python_full_version' field is not valid. + remote: ! 'python_version' or 'python_full_version' field isn't valid. remote: ! remote: ! The following version was found: remote: ! ^3.12 remote: ! remote: ! However, the version must be specified as either: - remote: ! 1. '.' (recommended, for automatic security updates) - remote: ! 2. '..' (to pin to an exact Python version) + remote: ! 1. '.' (recommended, for automatic patch updates) + remote: ! 2. '..' (to pin to an exact patch version) remote: ! remote: ! Please update your 'Pipfile' to use a valid Python version and remote: ! then run 'pipenv lock' to regenerate the lockfile. @@ -321,14 +230,14 @@ remote: ! Error: Invalid Python version in Pipfile / Pipfile.lock. remote: ! remote: ! The Python version specified in Pipfile / Pipfile.lock by the - remote: ! 'python_version' or 'python_full_version' field is not valid. + remote: ! 'python_version' or 'python_full_version' field isn't valid. remote: ! remote: ! The following version was found: remote: ! 3.9.* remote: ! remote: ! However, the version must be specified as either: - remote: ! 1. '.' (recommended, for automatic security updates) - remote: ! 2. '..' (to pin to an exact Python version) + remote: ! 1. '.' (recommended, for automatic patch updates) + remote: ! 2. '..' (to pin to an exact patch version) remote: ! remote: ! Please update your 'Pipfile' to use a valid Python version and remote: ! then run 'pipenv lock' to regenerate the lockfile. @@ -342,63 +251,50 @@ end end - context 'when there is a both a Pipfile.lock python_version and a runtime.txt' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_and_runtime_txt') } - - it 'builds with the Python version from runtime.txt' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> Python app detected - remote: -----> Using Python #{LATEST_PYTHON_3_12} specified in runtime.txt - remote: -----> Installing Python #{LATEST_PYTHON_3_12} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing Pipenv #{PIPENV_VERSION} - remote: -----> Installing SQLite3 - remote: -----> Installing dependencies with Pipenv - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - REGEX - end - end - end - - context 'when Pipfile.lock is unchanged since the last build' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_unspecified') } + context 'with a Pipfile.lock containing an EOL python_version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_eol', allow_failure: true) } - it 're-uses packages from the cache' do + it 'aborts the build with an EOL message' do app.deploy do |app| - app.commit! - app.push! - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + expect(clean_output(app.output)).to match(Regexp.new(<<~OUTPUT)) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing Pipenv #{PIPENV_VERSION} - remote: -----> Installing SQLite3 - remote: -----> Installing dependencies with Pipenv - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Discovering process types - REGEX + remote: -----> Using Python 3.7 specified in Pipfile.lock + remote: + remote: ! Error: The requested Python version has reached end-of-life. + remote: ! + remote: ! Python 3.7 has reached its upstream end-of-life, and is + remote: ! therefore no longer receiving security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, it is no longer supported by this buildpack. + remote: ! + remote: ! Please upgrade to a newer Python version by updating the + remote: ! version configured via the 'Pipfile.lock' file. + remote: ! + remote: ! For a list of the supported Python versions, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT end end end context 'when the package manager has changed from pip to Pipenv since the last build' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified') } + let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_basic') } # TODO: Fix this case so the cache is actually cleared. it 'clears the cache before installing with Pipenv' do app.deploy do |app| + FileUtils.rm('.python-version') FileUtils.rm('requirements.txt') - FileUtils.cp(FIXTURE_DIR.join('pipenv_python_version_unspecified/Pipfile'), '.') - FileUtils.cp(FIXTURE_DIR.join('pipenv_python_version_unspecified/Pipfile.lock'), '.') + FileUtils.cp(FIXTURE_DIR.join('pipenv_basic/Pipfile'), '.') + FileUtils.cp(FIXTURE_DIR.join('pipenv_basic/Pipfile.lock'), '.') app.commit! app.push! expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the same version as the last build: Python #{DEFAULT_PYTHON_FULL_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in Pipfile.lock remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing Pipenv #{PIPENV_VERSION} @@ -459,7 +355,7 @@ it 'rewrites .pth, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Running post-compile hook remote: easy-install.pth:/app/.heroku/src/gunicorn remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py @@ -498,7 +394,7 @@ # Test that the cached .pth files work correctly. app.commit! app.push! - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Running post-compile hook remote: easy-install.pth:/app/.heroku/src/gunicorn remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py diff --git a/spec/hatchet/python_update_warning_spec.rb b/spec/hatchet/python_update_warning_spec.rb index 787f24a18..664c15bc1 100644 --- a/spec/hatchet/python_update_warning_spec.rb +++ b/spec/hatchet/python_update_warning_spec.rb @@ -46,7 +46,7 @@ remote: -----> Python app detected remote: -----> Using Python 3.8.0 specified in runtime.txt remote: - remote: ! Error: Python 3.8.0 is not available for this stack (#{app.stack}). + remote: ! Error: Python 3.8.0 isn't available for this stack (#{app.stack}). remote: ! remote: ! For a list of the supported Python versions, see: remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes @@ -58,14 +58,14 @@ end end - context 'with a runtime.txt containing an outdated patch version' do + context 'with a .python-version file containing an outdated patch version' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_outdated') } it 'warns there is a Python update available' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python 3.9.0 specified in runtime.txt + remote: -----> Using Python 3.9.0 specified in .python-version remote: ! remote: ! A Python security update is available! Upgrade as soon as possible to: Python #{LATEST_PYTHON_3_9} remote: ! See: https://devcenter.heroku.com/articles/python-runtimes diff --git a/spec/hatchet/python_version_spec.rb b/spec/hatchet/python_version_spec.rb index e9751a920..ec6313d30 100644 --- a/spec/hatchet/python_version_spec.rb +++ b/spec/hatchet/python_version_spec.rb @@ -2,30 +2,30 @@ require_relative '../spec_helper' -RSpec.shared_examples 'builds with the requested Python version' do |requested_version| +RSpec.shared_examples 'builds with the requested Python version' do |requested_version, resolved_version| it "builds with Python #{requested_version}" do app.deploy do |app| - if requested_version.start_with?('3.13.') + if requested_version == '3.13' expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python #{requested_version} specified in runtime.txt - remote: -----> Installing Python #{requested_version} + remote: -----> Using Python #{requested_version} specified in .python-version + remote: -----> Installing Python #{resolved_version} remote: -----> Installing pip #{PIP_VERSION} remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) OUTPUT else expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python #{requested_version} specified in runtime.txt - remote: -----> Installing Python #{requested_version} + remote: -----> Using Python #{requested_version} specified in .python-version + remote: -----> Installing Python #{resolved_version} remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) OUTPUT end - expect(app.run('python -V')).to include("Python #{requested_version}") + expect(app.run('python -V')).to include("Python #{resolved_version}") end end end @@ -76,74 +76,16 @@ end end - context 'when runtime.txt contains python-2.7.18' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_2.7', allow_failure: true) } - - it 'aborts the build with an EOL message' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python 2.7.18 specified in runtime.txt - remote: - remote: ! Error: The requested Python version has reached end-of-life. - remote: ! - remote: ! Python 2.7 has reached its upstream end-of-life, and is - remote: ! therefore no longer receiving security updates: - remote: ! https://devguide.python.org/versions/#supported-versions - remote: ! - remote: ! As such, it is no longer supported by this buildpack. - remote: ! - remote: ! Please upgrade to a newer Python version by updating the - remote: ! version configured via the 'runtime.txt' file. - remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes - remote: - remote: ! Push rejected, failed to compile Python app. - OUTPUT - end - end - end - - context 'when runtime.txt contains python-3.7.17' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.7', allow_failure: true) } - - it 'aborts the build with an EOL message' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python 3.7.17 specified in runtime.txt - remote: - remote: ! Error: The requested Python version has reached end-of-life. - remote: ! - remote: ! Python 3.7 has reached its upstream end-of-life, and is - remote: ! therefore no longer receiving security updates: - remote: ! https://devguide.python.org/versions/#supported-versions - remote: ! - remote: ! As such, it is no longer supported by this buildpack. - remote: ! - remote: ! Please upgrade to a newer Python version by updating the - remote: ! version configured via the 'runtime.txt' file. - remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes - remote: - remote: ! Push rejected, failed to compile Python app. - OUTPUT - end - end - end - - context 'when runtime.txt contains python-3.8.20' do + context 'when .python-version contains Python 3.8' do let(:allow_failure) { false } let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.8', allow_failure:) } context 'when using Heroku-20', stacks: %w[heroku-20] do - it 'builds with Python 3.8.20 but shows a deprecation warning' do + it 'builds with latest Python 3.8 but shows a deprecation warning' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python #{LATEST_PYTHON_3_8} specified in runtime.txt + remote: -----> Using Python 3.8 specified in .python-version remote: ! remote: ! Python 3.8 will reach its upstream end-of-life in October 2024, at which remote: ! point it will no longer receive security updates: @@ -158,7 +100,7 @@ remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) OUTPUT expect(app.run('python -V')).to include("Python #{LATEST_PYTHON_3_8}") end @@ -173,9 +115,9 @@ app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python #{LATEST_PYTHON_3_8} specified in runtime.txt + remote: -----> Using Python 3.8 specified in .python-version remote: - remote: ! Error: Python #{LATEST_PYTHON_3_8} is not available for this stack (#{app.stack}). + remote: ! Error: Python #{LATEST_PYTHON_3_8} isn't available for this stack (#{app.stack}). remote: ! remote: ! For a list of the supported Python versions, see: remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes @@ -187,62 +129,143 @@ end end - context 'when runtime.txt contains python-3.9.20' do + context 'when .python-version contains Python 3.9' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.9') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_9 + include_examples 'builds with the requested Python version', '3.9', LATEST_PYTHON_3_9 end - context 'when runtime.txt contains python-3.10.15' do + context 'when .python-version contains Python 3.10' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.10') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_10 + include_examples 'builds with the requested Python version', '3.10', LATEST_PYTHON_3_10 end - context 'when runtime.txt contains python-3.11.10' do + context 'when .python-version contains Python 3.11' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.11') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_11 + include_examples 'builds with the requested Python version', '3.11', LATEST_PYTHON_3_11 end - context 'when runtime.txt contains python-3.12.7' do + context 'when .python-version contains Python 3.12' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.12') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_12 + include_examples 'builds with the requested Python version', '3.12', LATEST_PYTHON_3_12 end - context 'when runtime.txt contains python-3.13.0' do + context 'when .python-version contains Python 3.13' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.13') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_13 + include_examples 'builds with the requested Python version', '3.13', LATEST_PYTHON_3_13 end - context 'when runtime.txt contains an invalid Python version string' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_invalid', allow_failure: true) } + context 'when .python-version contains an invalid Python version string' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_invalid_version', allow_failure: true) } - it 'aborts the build with an invalid runtime.txt message' do + it 'aborts the build with an invalid .python-version message' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected remote: - remote: ! Error: Invalid Python version in runtime.txt. + remote: ! Error: Invalid Python version in .python-version. remote: ! - remote: ! The Python version specified in 'runtime.txt' is not in + remote: ! The Python version specified in '.python-version' isn't in remote: ! the correct format. remote: ! - remote: ! The following file contents were found: - remote: ! python-3.12.0invalid + remote: ! The following version was found: + remote: ! 3.12.0invalid + remote: ! + remote: ! However, the version must be specified as either: + remote: ! 1. '.' (recommended, for automatic patch updates) + remote: ! 2. '..' (to pin to an exact patch version) + remote: ! + remote: ! Don't include quotes or a 'python-' prefix. To include + remote: ! comments, add them on their own line, prefixed with '#'. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update the '.python-version' file so it contains: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when .python-version does not contain a Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_no_version', allow_failure: true) } + + it 'aborts the build with a no version string found message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Invalid Python version in .python-version. + remote: ! + remote: ! No Python version was found in the '.python-version' file. + remote: ! + remote: ! Update the file so that it contains a valid Python version + remote: ! such as '3.12'. + remote: ! + remote: ! If the file already contains a version, check the line doesn't + remote: ! begin with a '#', otherwise it will be treated as a comment. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when .python-version contains multiple Python versions' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_multiple_versions', allow_failure: true) } + + it 'aborts the build with a multiple versions not supported message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Invalid Python version in .python-version. remote: ! - remote: ! However, the version string must begin with a 'python-' prefix, - remote: ! followed by the version specified as '..'. - remote: ! Comments are not supported. + remote: ! Multiple Python versions were found in the '.python-version' + remote: ! file: remote: ! - remote: ! For example, to request Python 3.12.7, use: - remote: ! python-3.12.7 + remote: ! // invalid comment + remote: ! 3.12 + remote: ! 2.7 remote: ! - remote: ! Please update 'runtime.txt' to use a valid version string, or - remote: ! else remove the file to instead use the default version - remote: ! (currently Python 3.12.7). + remote: ! Update the file so it contains only one Python version. + remote: ! + remote: ! If the additional versions are actually comments, prefix + remote: ! those lines with '#'. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when .python-version contains an EOL Python 3.x version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_eol', allow_failure: true) } + + it 'aborts the build with an EOL message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.7 specified in .python-version + remote: + remote: ! Error: The requested Python version has reached end-of-life. + remote: ! + remote: ! Python 3.7 has reached its upstream end-of-life, and is + remote: ! therefore no longer receiving security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, it is no longer supported by this buildpack. + remote: ! + remote: ! Please upgrade to a newer Python version by updating the + remote: ! version configured via the '.python-version' file. + remote: ! + remote: ! For a list of the supported Python versions, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes remote: remote: ! Push rejected, failed to compile Python app. OUTPUT @@ -250,18 +273,18 @@ end end - context 'when runtime.txt contains an non-existent Python major version' do + context 'when .python-version contains an non-existent Python major version' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_non_existent_major', allow_failure: true) } - it 'aborts the build with an invalid runtime.txt message' do + it 'aborts the build with an invalid .python-version message' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python 3.999.0 specified in runtime.txt + remote: -----> Using Python 3.999 specified in .python-version remote: - remote: ! Error: The requested Python version is not recognised. + remote: ! Error: The requested Python version isn't recognised. remote: ! - remote: ! The requested Python version 3.999 is not recognised. + remote: ! The requested Python version 3.999 isn't recognised. remote: ! remote: ! Check that this Python version has been officially released, remote: ! and that the Python buildpack has added support for it: @@ -272,8 +295,8 @@ remote: ! of this buildpack: remote: ! https://devcenter.heroku.com/articles/python-support#checking-the-python-buildpack-version remote: ! - remote: ! Otherwise, switch to a supported version (such as Python 3.12) - remote: ! by updating the version configured via the 'runtime.txt' file. + remote: ! Otherwise, switch to a supported version (such as Python #{DEFAULT_PYTHON_MAJOR_VERSION}) + remote: ! by updating the version configured via the '.python-version' file. remote: remote: ! Push rejected, failed to compile Python app. OUTPUT @@ -281,16 +304,77 @@ end end - context 'when runtime.txt contains a non-existent Python patch version' do + context 'when .python-version contains a non-existent Python patch version' do let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_non_existent_patch', allow_failure: true) } it 'aborts the build with a version not available message' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python 3.12.999 specified in runtime.txt + remote: -----> Using Python 3.12.999 specified in .python-version + remote: + remote: ! Error: Python 3.12.999 isn't available for this stack (#{app.stack}). + remote: ! + remote: ! For a list of the supported Python versions, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when runtime.txt contains an invalid Python version string' do + let(:app) { Hatchet::Runner.new('spec/fixtures/runtime_txt_invalid_version', allow_failure: true) } + + it 'aborts the build with an invalid runtime.txt message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected remote: - remote: ! Error: Python 3.12.999 is not available for this stack (#{app.stack}). + remote: ! Error: Invalid Python version in runtime.txt. + remote: ! + remote: ! The Python version specified in 'runtime.txt' isn't in + remote: ! the correct format. + remote: ! + remote: ! The following file contents were found: + remote: ! python-3.12.0invalid + remote: ! + remote: ! However, the version must be specified as either: + remote: ! 1. 'python-.' (recommended, for automatic patch updates) + remote: ! 2. 'python-..' (to pin to an exact patch version) + remote: ! + remote: ! Remember to include the 'python-' prefix. Comments aren't supported. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update the 'runtime.txt' file so it contains: + remote: ! python-#{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when runtime.txt contains an EOL Python 2.x version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/runtime_txt_eol_version', allow_failure: true) } + + it 'aborts the build with an EOL message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 2.7.18 specified in runtime.txt + remote: + remote: ! Error: The requested Python version has reached end-of-life. + remote: ! + remote: ! Python 2.7 has reached its upstream end-of-life, and is + remote: ! therefore no longer receiving security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, it is no longer supported by this buildpack. + remote: ! + remote: ! Please upgrade to a newer Python version by updating the + remote: ! version configured via the 'runtime.txt' file. remote: ! remote: ! For a list of the supported Python versions, see: remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes @@ -301,10 +385,22 @@ end end - context 'when runtime.txt contains stray whitespace' do - let(:app) { Hatchet::Runner.new('spec/fixtures/runtime_txt_with_stray_whitespace') } + # This also tests runtime.txt support for the major version only syntax, as well as the handling + # of runtime.txt files that contain stray whitespace. + context 'when there is both a runtime.txt and .python-version file' do + let(:app) { Hatchet::Runner.new('spec/fixtures/runtime_txt_and_python_version_file') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_12 + it 'builds with the version from runtime.txt' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.13 specified in runtime.txt + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing requirements with pip + OUTPUT + end + end end context 'when the requested Python version has changed since the last build' do @@ -312,20 +408,19 @@ it 'builds with the new Python version after removing the old install' do app.deploy do |app| - File.write('runtime.txt', "python-#{LATEST_PYTHON_3_12}") + File.write('.python-version', '3.13') app.commit! app.push! # TODO: The output shouldn't say "installing from cache", since it's not. expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python #{LATEST_PYTHON_3_12} specified in runtime.txt - remote: -----> Python version has changed from #{LATEST_PYTHON_3_9} to #{LATEST_PYTHON_3_12}, clearing cache + remote: -----> Using Python 3.13 specified in .python-version + remote: -----> Python version has changed from #{LATEST_PYTHON_3_9} to #{LATEST_PYTHON_3_13}, clearing cache remote: -----> No change in requirements detected, installing from cache - remote: -----> Installing Python #{LATEST_PYTHON_3_12} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing SQLite3 + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing pip #{PIP_VERSION} remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) OUTPUT end end diff --git a/spec/hatchet/stack_spec.rb b/spec/hatchet/stack_spec.rb index 0135e0628..3f55f5337 100644 --- a/spec/hatchet/stack_spec.rb +++ b/spec/hatchet/stack_spec.rb @@ -34,7 +34,7 @@ remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) OUTPUT end end @@ -59,7 +59,7 @@ remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} remote: -----> Installing SQLite3 remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: Collecting typing-extensions==4.12.2 (from -r requirements.txt (line 2)) OUTPUT end end