diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..67289a444 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,37 @@ +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rb] +# Required since we use heredocs to assert against Hatchet output, and sometimes that output +# contains trailing newlines. Our RuboCop config ensures these are only allowed in heredocs. +trim_trailing_whitespace = false + +[*.sh] +binary_next_line = true +# We sadly have to use tabs in shell scripts otherwise we can't indent here documents: +# https://www.gnu.org/software/bash/manual/html_node/Redirections.html#Here-Documents +indent_style = tab +shell_variant = bash +switch_case_indent = true + +# Catches scripts that we can't give a .sh file extension, such as the Buildpack API scripts. +[**/bin/**] +binary_next_line = true +indent_style = tab +shell_variant = bash +switch_case_indent = true + +# The setup-ruby GitHub Action creates this directory when caching is enabled, and if +# it's not ignored will cause false positives when running shfmt in the CI lint job. +[vendor/bundle/**] +ignore = true + +[Makefile] +indent_style = tab diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 52082f8a2..3f4e003a1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,12 @@ # Default to requesting pull request reviews from the Heroku Languages team. +#ECCN:Open Source +#GUSINFO:Heroku - Languages,Heroku Python Platform * @heroku/languages # However, request review from the language owner instead for files that are updated # by Dependabot or release automation, to reduce team review request noise. CHANGELOG.md @edmorley Gemfile.lock @edmorley +/.github/workflows/ @edmorley +/builds/Dockerfile @edmorley /requirements/ @edmorley diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 366df6035..636065174 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,14 @@ updates: update-types: - "minor" - "patch" + - package-ecosystem: "docker" + directory: "/builds" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "docker" + - "skip changelog" - package-ecosystem: "github-actions" directory: "/" schedule: @@ -28,3 +36,9 @@ updates: labels: - "dependencies" - "python" + groups: + # We don't limit grouping for Python to just minor/patch versions, since pip doesn't follow + # semver, plus having to update CHANGELOG.md means it's easier to just have a single PR. + python-dependencies: + patterns: + - "*" diff --git a/.github/workflows/build_python_runtime.yml b/.github/workflows/build_python_runtime.yml index 76812cb75..6039a5710 100644 --- a/.github/workflows/build_python_runtime.yml +++ b/.github/workflows/build_python_runtime.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: python_version: - description: "Python version (eg: 3.12.0)" + description: "Python version (eg: 3.13.0)" type: string required: true stack: @@ -13,9 +13,9 @@ on: type: choice options: - auto - - heroku-20 - heroku-22 - heroku-24 + - heroku-26 default: auto required: false dry_run: @@ -36,14 +36,14 @@ env: # Unfortunately these jobs cannot be easily written as a matrix since `matrix.exclude` does not # support expression syntax, and the `matrix` context is not available inside the job `if` key. jobs: - heroku-20: - if: inputs.stack == 'heroku-20' || inputs.stack == 'auto' + heroku-22: + if: inputs.stack == 'heroku-22' || inputs.stack == 'auto' runs-on: pub-hk-ubuntu-24.04-xlarge env: - STACK_VERSION: "20" + STACK_VERSION: "22" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Build Docker image run: docker build --platform="linux/amd64" --pull --tag buildenv --build-arg=STACK_VERSION builds/ - name: Compile and package Python runtime @@ -57,41 +57,43 @@ jobs: if: (!inputs.dry_run) run: aws s3 sync ./upload "s3://${S3_BUCKET}" - heroku-22: - # On Heroku-22 we only support Python 3.9+. - if: inputs.stack == 'heroku-22' || (inputs.stack == 'auto' && !startsWith(inputs.python_version,'3.8.')) - runs-on: pub-hk-ubuntu-24.04-xlarge + heroku-24: + if: inputs.stack == 'heroku-24' || inputs.stack == 'auto' + strategy: + fail-fast: false + matrix: + arch: ["amd64", "arm64"] + runs-on: ${{ matrix.arch == 'arm64' && 'pub-hk-ubuntu-24.04-arm-xlarge' || 'pub-hk-ubuntu-24.04-xlarge' }} env: - STACK_VERSION: "22" + STACK_VERSION: "24" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Build Docker image - run: docker build --platform="linux/amd64" --pull --tag buildenv --build-arg=STACK_VERSION builds/ + run: docker build --platform="linux/${{ matrix.arch }}" --pull --tag buildenv --build-arg=STACK_VERSION builds/ - name: Compile and package Python runtime run: docker run --rm --volume="${PWD}/upload:/tmp/upload" buildenv ./build_python_runtime.sh "${{ inputs.python_version }}" - name: Test Python runtime run: | RUN_IMAGE='heroku/heroku:${{ env.STACK_VERSION }}' - ARCHIVE_FILENAME='python-${{ inputs.python_version }}-ubuntu-${{ env.STACK_VERSION }}.04-amd64.tar.zst' + ARCHIVE_FILENAME='python-${{ inputs.python_version }}-ubuntu-${{ env.STACK_VERSION }}.04-${{ matrix.arch }}.tar.zst' docker run --rm --volume="${PWD}/upload:/upload:ro" --volume="${PWD}/builds:/builds:ro" "${RUN_IMAGE}" /builds/test_python_runtime.sh "/upload/${ARCHIVE_FILENAME}" - name: Upload Python runtime archive to S3 if: (!inputs.dry_run) run: aws s3 sync ./upload "s3://${S3_BUCKET}" - heroku-24: - # On Heroku-24 we only support Python 3.10+. - if: inputs.stack == 'heroku-24' || (inputs.stack == 'auto' && !startsWith(inputs.python_version,'3.8.') && !startsWith(inputs.python_version,'3.9.')) + heroku-26: + if: inputs.stack == 'heroku-26' || inputs.stack == 'auto' strategy: fail-fast: false matrix: arch: ["amd64", "arm64"] runs-on: ${{ matrix.arch == 'arm64' && 'pub-hk-ubuntu-24.04-arm-xlarge' || 'pub-hk-ubuntu-24.04-xlarge' }} env: - STACK_VERSION: "24" + STACK_VERSION: "26" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Build Docker image run: docker build --platform="linux/${{ matrix.arch }}" --pull --tag buildenv --build-arg=STACK_VERSION builds/ - name: Compile and package Python runtime diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml index e528aae03..ee511b3d2 100644 --- a/.github/workflows/check_changelog.yml +++ b/.github/workflows/check_changelog.yml @@ -13,7 +13,7 @@ jobs: if: (!contains(github.event.pull_request.labels.*.name, 'skip changelog')) steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Check that CHANGELOG is touched run: | git fetch origin ${{ github.base_ref }} --depth 1 && \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed5588b7d..35de00fb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,19 +10,27 @@ on: permissions: contents: read +env: + # Used by shfmt and more. + FORCE_COLOR: 1 + jobs: lint: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Ruby and dependencies uses: ruby/setup-ruby@v1 with: bundler-cache: true - ruby-version: "3.3" + ruby-version: "4.0" - name: Run ShellCheck run: make lint-scripts + - name: Run shfmt + uses: docker://mvdan/shfmt:latest + with: + args: "--diff ." - name: Run Rubocop run: bundle exec rubocop @@ -31,24 +39,24 @@ jobs: strategy: fail-fast: false matrix: - stack: ["heroku-20", "heroku-22", "heroku-24"] + stack: ["heroku-22", "heroku-24", "heroku-26"] env: - HATCHET_APP_LIMIT: 200 + HATCHET_APP_LIMIT: 300 HATCHET_DEFAULT_STACK: ${{ matrix.stack }} HATCHET_EXPENSIVE_MODE: 1 HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} HEROKU_API_USER: ${{ secrets.HEROKU_API_USER }} HEROKU_DISABLE_AUTOUPDATE: 1 - PARALLEL_SPLIT_TEST_PROCESSES: 60 - RSPEC_RETRY_RETRY_COUNT: 3 + PARALLEL_SPLIT_TEST_PROCESSES: 90 + RSPEC_RETRY_RETRY_COUNT: 1 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Ruby and dependencies uses: ruby/setup-ruby@v1 with: bundler-cache: true - ruby-version: "3.3" + ruby-version: "4.0" - name: Hatchet setup run: bundle exec hatchet ci:setup - name: Run Hatchet integration tests @@ -57,17 +65,12 @@ jobs: container-test: runs-on: ubuntu-24.04 - defaults: - run: - # Work around lack of TTY causing errors when using `docker run -it`: - # https://github.com/actions/runner/issues/241 - shell: 'script -q -e -c "bash -eo pipefail {0}"' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 # These test both the local development `make run` workflow and that `bin/report` completes successfully # for both passing and failing builds (since `bin/report` can't easily be tested via Hatchet tests). - 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/ COMPILE_FAILURE_EXIT_CODE=0 diff --git a/.github/workflows/hatchet_app_cleaner.yml b/.github/workflows/hatchet_app_cleaner.yml index b9eb06cdf..05343fb8a 100644 --- a/.github/workflows/hatchet_app_cleaner.yml +++ b/.github/workflows/hatchet_app_cleaner.yml @@ -19,12 +19,12 @@ jobs: HEROKU_DISABLE_AUTOUPDATE: 1 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Ruby and dependencies uses: ruby/setup-ruby@v1 with: bundler-cache: true - ruby-version: "3.3" + ruby-version: "4.0" - name: Run Hatchet destroy # Only apps older than 10 minutes are destroyed, to ensure that any # in progress CI runs are not interrupted. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..16872d5c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release Buildpack + +on: + # Auto-trigger when the "Prepare release" PR (created by the + # _classic-buildpack-prepare-release.yml workflow) is merged. + pull_request: + branches: + - main + types: + - closed + workflow_dispatch: + +permissions: + id-token: write + contents: write + +jobs: + release: + name: Release + # On `pull_request`, only run for the merged auto-generated + # "Prepare release" PR (branch name set by + # _classic-buildpack-prepare-release.yml). Manual dispatches always run. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'prepare-release' && + github.event.pull_request.user.login == 'heroku-linguist[bot]') + uses: heroku/languages-github-actions/.github/workflows/_classic-buildpack-publish.yml@latest + with: + buildpack_id: "heroku/python" + app_id: ${{ vars.LINGUIST_GH_APP_ID }} + secrets: + app_private_key: ${{ secrets.LINGUIST_GH_PRIVATE_KEY }} + slack_webhook_url: ${{ secrets.BUILDPACK_RELEASE_FAILURE_WEBHOOK_URL }} + honeycomb_api_key: ${{ secrets.HONEYCOMB_API_KEY }} diff --git a/.gitignore b/.gitignore index e415b62c2..4cecc5fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ __pycache__/ -.hatchet/repos/ .venv/ # The setup-ruby GitHub Action creates this directory when caching is enabled, so we ignore # it here so it does not show up in the output of `git ls-files` for `make lint-scripts`. diff --git a/.rubocop.yml b/.rubocop.yml index 44a322aef..2edf1e039 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,4 @@ -require: +plugins: - rubocop-rspec AllCops: diff --git a/.shellcheckrc b/.shellcheckrc index 44e54caa5..da9f6a163 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -1,15 +1,2 @@ # Enable all checks, including the optional ones that are off by default. enable=all - -# TODO: Triage and potentially enable more of these optional checks. - -# SC2154 (warning): var is referenced but not assigned. -disable=SC2154 -# SC2250 (style): Prefer putting braces around variable references even when not strictly required. -disable=SC2250 -# SC2310 (info): This function is invoked in an 'if' condition so set -e will be disabled. -disable=SC2310 -# SC2311 (info): Bash implicitly disabled set -e for this function invocation because it's inside a command substitution. -disable=SC2311 -# SC2312 (info): Consider invoking this command separately to avoid masking its return value -disable=SC2312 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f823115..3648b20a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,533 @@ ## [Unreleased] +## [v347] - 2026-07-10 + +- Updated Pipenv from 2026.6.1 to 2026.6.2. ([#2111](https://github.com/heroku/heroku-buildpack-python/pull/2111)) +- Updated uv from 0.11.19 to 0.11.28. ([#2111](https://github.com/heroku/heroku-buildpack-python/pull/2111)) + +## [v346] - 2026-06-10 + +- The Python 3.14 version alias now resolves to Python 3.14.6. ([#2105](https://github.com/heroku/heroku-buildpack-python/pull/2105)) +- The Python 3.13 version alias now resolves to Python 3.13.14. ([#2105](https://github.com/heroku/heroku-buildpack-python/pull/2105)) + +## [v345] - 2026-06-04 + +- Updated pip from 26.1.1 to 26.1.2. ([#2098](https://github.com/heroku/heroku-buildpack-python/pull/2098)) +- Updated Poetry from 2.4.0 to 2.4.1. ([#2098](https://github.com/heroku/heroku-buildpack-python/pull/2098)) +- Updated uv from 0.11.11 to 0.11.19. ([#2098](https://github.com/heroku/heroku-buildpack-python/pull/2098)) +- The Python 3.14 version alias now resolves to Python 3.14.5. ([#2094](https://github.com/heroku/heroku-buildpack-python/pull/2094)) + +## [v344] - 2026-05-07 + +- Updated pip from 26.0.1 to 26.1.1. ([#2089](https://github.com/heroku/heroku-buildpack-python/pull/2089)) +- Updated Pipenv from 2026.5.2 to 2026.6.1. ([#2082](https://github.com/heroku/heroku-buildpack-python/pull/2082)) +- Updated Poetry from 2.3.4 to 2.4.0. ([#2088](https://github.com/heroku/heroku-buildpack-python/pull/2088)) +- Updated uv from 0.11.6 to 0.11.11. ([#2090](https://github.com/heroku/heroku-buildpack-python/pull/2090)) + +## [v343] - 2026-04-13 + +- Updated Pipenv from 2026.5.1 to 2026.5.2. ([#2080](https://github.com/heroku/heroku-buildpack-python/pull/2080)) +- Updated Poetry from 2.3.3 to 2.3.4. ([#2079](https://github.com/heroku/heroku-buildpack-python/pull/2079)) +- Updated uv from 0.11.3 to 0.11.6. ([#2078](https://github.com/heroku/heroku-buildpack-python/pull/2078)) + +## [v342] - 2026-04-09 + +- Added support for Heroku-26. ([#2073](https://github.com/heroku/heroku-buildpack-python/pull/2073)) + +## [v341] - 2026-04-07 + +- The Python 3.14 version alias now resolves to Python 3.14.4. ([#2071](https://github.com/heroku/heroku-buildpack-python/pull/2071)) +- The Python 3.13 version alias now resolves to Python 3.13.13. ([#2071](https://github.com/heroku/heroku-buildpack-python/pull/2071)) + +## [v340] - 2026-04-02 + +- Updated Pipenv from 2026.0.3 to 2026.5.1. ([#2065](https://github.com/heroku/heroku-buildpack-python/pull/2065)) +- Updated Poetry from 2.3.2 to 2.3.3. ([#2064](https://github.com/heroku/heroku-buildpack-python/pull/2064)) +- Updated uv from 0.10.9 to 0.11.3. ([#2067](https://github.com/heroku/heroku-buildpack-python/pull/2067)) + +## [v339] - 2026-03-13 + +- Updated uv from 0.10.7 to 0.10.9. ([#2049](https://github.com/heroku/heroku-buildpack-python/pull/2049)) +- Switched to downloading uv from `releases.astral.sh` instead of GitHub releases. ([#2050](https://github.com/heroku/heroku-buildpack-python/pull/2050)) +- Updated the check for misspelled `.python-version` files with additional misspellings seen in the wild. ([#2051](https://github.com/heroku/heroku-buildpack-python/pull/2051)) + +## [v338] - 2026-03-03 + +- The Python 3.12 version alias now resolves to Python 3.12.13. ([#2046](https://github.com/heroku/heroku-buildpack-python/pull/2046)) +- The Python 3.11 version alias now resolves to Python 3.11.15. ([#2046](https://github.com/heroku/heroku-buildpack-python/pull/2046)) +- The Python 3.10 version alias now resolves to Python 3.10.20. ([#2046](https://github.com/heroku/heroku-buildpack-python/pull/2046)) + +## [v337] - 2026-03-03 + +- Updated pip from 25.3 to 26.0.1. ([#2032](https://github.com/heroku/heroku-buildpack-python/pull/2032)) +- Updated uv from 0.10.1 to 0.10.7. ([#2041](https://github.com/heroku/heroku-buildpack-python/pull/2041)) +- Explicitly configured uv to use hard links to maintain the behaviour of previous versions. ([#2044](https://github.com/heroku/heroku-buildpack-python/pull/2044)) + +## [v336] - 2026-03-02 + +- Added a workaround for `nltk.txt` package downloader errors caused by an upstream regression in NLTK v3.9.3. ([#2041](https://github.com/heroku/heroku-buildpack-python/pull/2041)) +- Changed the S3 URL used to download Python to use AWS' dual-stack (IPv6 compatible) endpoint. ([#2035](https://github.com/heroku/heroku-buildpack-python/pull/2035)) + +## [v335] - 2026-02-10 + +- Updated uv from 0.9.29 to 0.10.1. ([#2031](https://github.com/heroku/heroku-buildpack-python/pull/2031)) + +## [v334] - 2026-02-04 + +- Updated Poetry from 2.3.1 to 2.3.2. ([#2024](https://github.com/heroku/heroku-buildpack-python/pull/2024)) +- Updated uv from 0.9.26 to 0.9.29. ([#2029](https://github.com/heroku/heroku-buildpack-python/pull/2029)) + +## [v333] - 2026-02-04 + +- The Python 3.14 version alias now resolves to Python 3.14.3. ([#2027](https://github.com/heroku/heroku-buildpack-python/pull/2027)) +- The Python 3.13 version alias now resolves to Python 3.13.12. ([#2027](https://github.com/heroku/heroku-buildpack-python/pull/2027)) + +## [v332] - 2026-01-26 + +- Updated Poetry from 2.2.1 to 2.3.1. ([#2019](https://github.com/heroku/heroku-buildpack-python/pull/2019)) +- Updated uv from 0.9.24 to 0.9.26. ([#2016](https://github.com/heroku/heroku-buildpack-python/pull/2016)) +- The web server concurrency calculation profile script now sets the env var `WEB_CONCURRENCY_SET_BY="heroku/python"` if `WEB_CONCURRENCY` was set automatically by the buildpack. ([#2015](https://github.com/heroku/heroku-buildpack-python/pull/2015)) + +## [v331] - 2026-01-11 + +- Updated uv from 0.9.21 to 0.9.24. ([#2013](https://github.com/heroku/heroku-buildpack-python/pull/2013)) + +## [v330] - 2026-01-08 + +- Re-apply a workaround for a Pipenv bug when using `--system`, that causes packages to not be installed correctly if they are also a dependency of Pipenv (such as `certifi` or `packaging`). ([#2011](https://github.com/heroku/heroku-buildpack-python/pull/2011)) + +## [v329] - 2026-01-08 + +- Updated Pipenv from 2025.0.4 to 2026.0.3. ([#2000](https://github.com/heroku/heroku-buildpack-python/pull/2000)) + +## [v328] - 2026-01-07 + +- Updated pip from 25.2 to 25.3. ([#2008](https://github.com/heroku/heroku-buildpack-python/pull/2008)) +- Stopped installing wheel when using pip with Python 3.12 and older. ([#2008](https://github.com/heroku/heroku-buildpack-python/pull/2008)) + +## [v327] - 2026-01-07 + +- Removed support for Python 3.9. ([#2005](https://github.com/heroku/heroku-buildpack-python/pull/2005)) +- Adjusted the error message shown if SQLite headers aren't found during package installation. ([#2006](https://github.com/heroku/heroku-buildpack-python/pull/2006)) + +## [v326] - 2026-01-05 + +- Updated uv from 0.9.17 to 0.9.21. ([#2003](https://github.com/heroku/heroku-buildpack-python/pull/2003)) + +## [v325] - 2025-12-10 + +- The build now errors if the files for multiple package managers are found. This replaces the warning displayed since November 2024. ([#1993](https://github.com/heroku/heroku-buildpack-python/pull/1993)) +- Sunset the previously deprecated support for falling back to installing dependencies from a `setup.py` file if no Python package manager files were found. ([#1992](https://github.com/heroku/heroku-buildpack-python/pull/1992)) +- Updated uv from 0.9.14 to 0.9.17. ([#1995](https://github.com/heroku/heroku-buildpack-python/pull/1995)) + +## [v324] - 2025-12-09 + +- The build now errors if an existing Python virtual environment is found in the build directory at `.venv/` or `venv/`. This replaces the warning displayed since September 2025. ([#1990](https://github.com/heroku/heroku-buildpack-python/pull/1990)) +- Stopped adding the current working directory to `PATH`. ([#1987](https://github.com/heroku/heroku-buildpack-python/pull/1987)) + +## [v323] - 2025-12-05 + +- Changed the default Python version for new apps from 3.13 to 3.14. ([#1984](https://github.com/heroku/heroku-buildpack-python/pull/1984)) +- The Python 3.14 version alias now resolves to Python 3.14.2. ([#1985](https://github.com/heroku/heroku-buildpack-python/pull/1985)) +- The Python 3.13 version alias now resolves to Python 3.13.11. ([#1985](https://github.com/heroku/heroku-buildpack-python/pull/1985)) + +## [v322] - 2025-12-02 + +- The Python 3.14 version alias now resolves to Python 3.14.1. ([#1982](https://github.com/heroku/heroku-buildpack-python/pull/1982)) +- The Python 3.13 version alias now resolves to Python 3.13.10. ([#1982](https://github.com/heroku/heroku-buildpack-python/pull/1982)) +- Updated uv from 0.9.11 to 0.9.14. ([#1979](https://github.com/heroku/heroku-buildpack-python/pull/1979)) + +## [v321] - 2025-11-21 + +- Deprecated support for Python 3.10. ([#1972](https://github.com/heroku/heroku-buildpack-python/pull/1972)) +- Updated uv from 0.9.9 to 0.9.11. ([#1973](https://github.com/heroku/heroku-buildpack-python/pull/1973)) +- Added more messaging to the build log recommending uv. ([#1975](https://github.com/heroku/heroku-buildpack-python/pull/1975)) +- Switched from using `--disable-pip-version-check` to the env var `PIP_DISABLE_PIP_VERSION_CHECK=1` when using pip. ([#1974](https://github.com/heroku/heroku-buildpack-python/pull/1974)) + +## [v320] - 2025-11-20 + +- Added a check for misspelled `.python-version` files. ([#1970](https://github.com/heroku/heroku-buildpack-python/pull/1970)) +- Fixed regex for null bytes character replacement to replace all occurrences, not just the first. ([#1966](https://github.com/heroku/heroku-buildpack-python/pull/1966)) +- Unpinned Poetry's `dulwich` version, since the upstream incompatibility with older Python has been fixed. ([#1967](https://github.com/heroku/heroku-buildpack-python/pull/1967)) + +## [v319] - 2025-11-14 + +- Updated uv from 0.9.7 to 0.9.9. ([#1961](https://github.com/heroku/heroku-buildpack-python/pull/1961)) +- Improved the error message shown for `.python-version` files that contain unexpected ASCII control code characters. ([#1962](https://github.com/heroku/heroku-buildpack-python/pull/1962)) +- Fixed Bash command substitution warnings from being shown if `runtime.txt` contains null byte characters. ([#1962](https://github.com/heroku/heroku-buildpack-python/pull/1962)) +- Improved the error message shown if the buildpack's build data file is deleted by a pre/post-compile hook. ([#1963](https://github.com/heroku/heroku-buildpack-python/pull/1963)) + +## [v318] - 2025-11-12 + +- Improved the error message shown when a `.python-version` file uses an unsupported file encoding. ([#1958](https://github.com/heroku/heroku-buildpack-python/pull/1958)) +- Improved the error message shown when a `.python-version` or `runtime.txt` file contains an invalid Python version. ([#1958](https://github.com/heroku/heroku-buildpack-python/pull/1958)) +- Added support for commented lines in `runtime.txt`, for parity with `.python-version`. ([#1958](https://github.com/heroku/heroku-buildpack-python/pull/1958)) + +## [v317] - 2025-11-03 + +- The Python 3.9 version alias now resolves to Python 3.9.25. ([#1955](https://github.com/heroku/heroku-buildpack-python/pull/1955)) +- Updated uv from 0.9.5 to 0.9.7. ([#1952](https://github.com/heroku/heroku-buildpack-python/pull/1952)) + +## [v316] - 2025-10-27 + +- Improved the error message when a `.python-version` or `runtime.txt` file contains invisible Unicode whitespace characters. ([#1947](https://github.com/heroku/heroku-buildpack-python/pull/1947)) +- Added metrics for the file encoding of `.python-version` and `runtime.txt` files. ([#1948](https://github.com/heroku/heroku-buildpack-python/pull/1948)) + +## [v315] - 2025-10-22 + +- Updated uv from 0.8.23 to 0.9.5. ([#1942](https://github.com/heroku/heroku-buildpack-python/pull/1942) and [#1945](https://github.com/heroku/heroku-buildpack-python/pull/1945)) +- Pinned `dulwich` version when using Poetry to work around an incompatibility with Python <3.9.2. ([#1943](https://github.com/heroku/heroku-buildpack-python/pull/1943)) +- Removed redundant internal error handling for venv creation. ([#1937](https://github.com/heroku/heroku-buildpack-python/pull/1937)) + +## [v314] - 2025-10-15 + +- The Python 3.13 version alias now resolves to Python 3.13.9. ([#1934](https://github.com/heroku/heroku-buildpack-python/pull/1934)) +- Improved error messages and metrics for unhandled buildpack errors. ([#1933](https://github.com/heroku/heroku-buildpack-python/pull/1933)) + +## [v313] - 2025-10-09 + +- Added support for Python 3.14. ([#1927](https://github.com/heroku/heroku-buildpack-python/pull/1927)) +- The Python 3.13 version alias now resolves to Python 3.13.8. ([#1928](https://github.com/heroku/heroku-buildpack-python/pull/1928)) +- The Python 3.12 version alias now resolves to Python 3.12.12. ([#1929](https://github.com/heroku/heroku-buildpack-python/pull/1929)) +- The Python 3.11 version alias now resolves to Python 3.11.14. ([#1929](https://github.com/heroku/heroku-buildpack-python/pull/1929)) +- The Python 3.10 version alias now resolves to Python 3.10.19. ([#1929](https://github.com/heroku/heroku-buildpack-python/pull/1929)) +- The Python 3.9 version alias now resolves to Python 3.9.24. ([#1929](https://github.com/heroku/heroku-buildpack-python/pull/1929)) +- Stopped using `--with-system-expat` when compiling new Python versions. ([#1925](https://github.com/heroku/heroku-buildpack-python/pull/1925)) + +## [v312] - 2025-10-05 + +- Updated uv from 0.8.20 to 0.8.23. ([#1916](https://github.com/heroku/heroku-buildpack-python/pull/1916) and [#1922](https://github.com/heroku/heroku-buildpack-python/pull/1922)) + +## [v311] - 2025-09-30 + +- Stopped rewriting Django collectstatic command log output. ([#1918](https://github.com/heroku/heroku-buildpack-python/pull/1918)) +- Changed the `pip install` command used to install the pip, Pipenv and Poetry package managers to now use `--isolated` mode. ([#1915](https://github.com/heroku/heroku-buildpack-python/pull/1915)) +- Added more Python project related file and directory names to the list recognised by buildpack detection. ([#1914](https://github.com/heroku/heroku-buildpack-python/pull/1914)) + +## [v310] - 2025-09-23 + +- Updated Poetry from 2.2.0 to 2.2.1. ([#1907](https://github.com/heroku/heroku-buildpack-python/pull/1907)) +- Updated uv from 0.8.18 to 0.8.20. ([#1910](https://github.com/heroku/heroku-buildpack-python/pull/1910)) +- Fixed errors saving the build cache when installed packages contain broken symlinks. ([#1909](https://github.com/heroku/heroku-buildpack-python/pull/1909)) +- Improved metrics for failed uv archive downloads. ([#1908](https://github.com/heroku/heroku-buildpack-python/pull/1908)) + +## [v309] - 2025-09-19 + +- Added metrics for misspelled `.python-version` files. ([#1904](https://github.com/heroku/heroku-buildpack-python/pull/1904)) + +## [v308] - 2025-09-19 + +- Updated Poetry from 2.1.4 to 2.2.0. ([#1900](https://github.com/heroku/heroku-buildpack-python/pull/1900)) +- Updated uv from 0.8.15 to 0.8.18. ([#1899](https://github.com/heroku/heroku-buildpack-python/pull/1899) and [#1901](https://github.com/heroku/heroku-buildpack-python/pull/1901)) +- Improved performance of Python build cache saving. ([#1902](https://github.com/heroku/heroku-buildpack-python/pull/1902)) + +## [v307] - 2025-09-10 + +- Deprecated support for falling back to installing dependencies from a `setup.py` file if no Python package manager files were found. ([#1897](https://github.com/heroku/heroku-buildpack-python/pull/1897)) + +## [v306] - 2025-09-09 + +- Updated uv from 0.8.13 to 0.8.15. ([#1894](https://github.com/heroku/heroku-buildpack-python/pull/1894)) + +## [v305] - 2025-09-02 + +- Added a warning if an existing Python virtual environment is found in the build directory at `.venv/` or `venv/`. In the future this warning will be made an error. ([#1890](https://github.com/heroku/heroku-buildpack-python/pull/1890)) + +## [v304] - 2025-09-01 + +- Fixed Django collectstatic and NLTK downloader support for apps that use config vars that shadow internal buildpack variable names (such as `CACHE_DIR`). ([#1888](https://github.com/heroku/heroku-buildpack-python/pull/1888)) + +## [v303] - 2025-08-26 + +- Updated uv from 0.8.9 to 0.8.13. ([#1880](https://github.com/heroku/heroku-buildpack-python/pull/1880)) +- Reduced default curl timeouts for faster retries of any transient connection issues on Heroku. ([#1884](https://github.com/heroku/heroku-buildpack-python/pull/1884)) +- Added support for overriding the default curl timeouts using `CURL_CONNECT_TIMEOUT` and `CURL_TIMEOUT`. These are intended for use in non-Heroku environments with slow connections, and so must be set via the build system rather than app config vars. ([#1884](https://github.com/heroku/heroku-buildpack-python/pull/1884)) +- Improved log output during curl retry attempts. ([#1884](https://github.com/heroku/heroku-buildpack-python/pull/1884)) +- Switched to Bash 5.0's `EPOCHREALTIME` for buildpack data store timing logic. ([#1881](https://github.com/heroku/heroku-buildpack-python/pull/1881)) + +## [v302] - 2025-08-21 + +- Stopped setting the `PYTHONHASHSEED` env var. ([#1876](https://github.com/heroku/heroku-buildpack-python/pull/1876)) +- Removed support for `BUILDPACK_S3_BASE_URL`. ([#1875](https://github.com/heroku/heroku-buildpack-python/pull/1875)) +- Refactored buildpack data store and `bin/report` to simplify the implementation and fix some string escaping bugs. ([#1878](https://github.com/heroku/heroku-buildpack-python/pull/1878)) + +## [v301] - 2025-08-18 + +- Simplified the handling of caches written by older buildpack versions. ([#1870](https://github.com/heroku/heroku-buildpack-python/pull/1870)) + +## [v300] - 2025-08-15 + +- The Python 3.13 version alias now resolves to Python 3.13.7. ([#1868](https://github.com/heroku/heroku-buildpack-python/pull/1868)) + +## [v299] - 2025-08-13 + +- Updated uv from 0.8.5 to 0.8.9. ([#1866](https://github.com/heroku/heroku-buildpack-python/pull/1866)) + +## [v298] - 2025-08-06 + +- The Python 3.13 version alias now resolves to Python 3.13.6. ([#1861](https://github.com/heroku/heroku-buildpack-python/pull/1861)) +- Fixed the stack version check to correctly show the "stack not supported" error message for the EOL Heroku-20, instead of "stack not recognised". ([#1860](https://github.com/heroku/heroku-buildpack-python/pull/1860)) + +## [v297] - 2025-08-06 + +- Updated pip from 25.1.1 to 25.2. ([#1848](https://github.com/heroku/heroku-buildpack-python/pull/1848)) +- Updated Poetry from 2.1.3 to 2.1.4. ([#1858](https://github.com/heroku/heroku-buildpack-python/pull/1858)) +- Updated uv from 0.8.4 to 0.8.5. ([#1857](https://github.com/heroku/heroku-buildpack-python/pull/1857)) + +## [v296] - 2025-08-06 + +- Stopped installing SQLite headers and CLI. ([#1854](https://github.com/heroku/heroku-buildpack-python/pull/1854)) + +## [v295] - 2025-08-01 + +- Updated uv from 0.7.20 to 0.8.4. ([#1847](https://github.com/heroku/heroku-buildpack-python/pull/1847)) +- Reduced the verbosity of pip's `Requirement already satisfied:` log lines when using Python 3.10 and older. ([#1851](https://github.com/heroku/heroku-buildpack-python/pull/1851)) + +## [v294] - 2025-07-28 + +- Improved performance of Python build cache restoration. ([#1845](https://github.com/heroku/heroku-buildpack-python/pull/1845)) +- Added a build log message for the build cache saving step. ([#1844](https://github.com/heroku/heroku-buildpack-python/pull/1844)) + +## [v293] - 2025-07-23 + +- Work around a Pipenv bug when using `--system`, that causes packages to not be installed correctly if they are also a dependency of Pipenv (such as `certifi`). ([#1842](https://github.com/heroku/heroku-buildpack-python/pull/1842)) + +## [v292] - 2025-07-23 + +- Updated Pipenv from 2024.0.1 to 2025.0.4. ([#1840](https://github.com/heroku/heroku-buildpack-python/pull/1840)) +- Fixed the way Pipenv is installed, so that it and its dependencies are installed into a separate virtual environment rather than the same environment as the app. If your app inadvertently depended on Pipenv's internal dependencies, you will need to add those dependencies explicitly to your `Pipfile`. ([#1840](https://github.com/heroku/heroku-buildpack-python/pull/1840)) +- Stopped installing pip when Pipenv is the chosen package manager. ([#1840](https://github.com/heroku/heroku-buildpack-python/pull/1840)) +- The build cache is now cleared when using Pipenv if the contents of `Pipfile.lock` has changed since the last build. This is required to work around Pipenv not uninstalling packages when they are removed from the lockfile. ([#1840](https://github.com/heroku/heroku-buildpack-python/pull/1840)) +- The build now errors when using Pipenv without its lockfile (`Pipfile.lock`). This replaces the warning displayed since November 2024. ([#1833](https://github.com/heroku/heroku-buildpack-python/pull/1833)) + +## [v291] - 2025-07-10 + +- Updated uv from 0.7.13 to 0.7.20. ([#1827](https://github.com/heroku/heroku-buildpack-python/pull/1827) and [#1829](https://github.com/heroku/heroku-buildpack-python/pull/1829)) +- The build now errors if the Python buildpack has been run multiple times in the same build. This replaces the warning displayed since December 2024. ([#1830](https://github.com/heroku/heroku-buildpack-python/pull/1830)) +- The build now errors if an existing `.heroku/python/` directory is found in the app source. This replaces the warning displayed since December 2024. ([#1830](https://github.com/heroku/heroku-buildpack-python/pull/1830)) + +## [v290] - 2025-06-17 + +- Updated uv from 0.7.10 to 0.7.13. ([#1819](https://github.com/heroku/heroku-buildpack-python/pull/1819)) + +## [v289] - 2025-06-12 + +- The Python 3.13 version alias now resolves to Python 3.13.5. ([#1814](https://github.com/heroku/heroku-buildpack-python/pull/1814)) + +## [v288] - 2025-06-03 + +- The Python 3.13 version alias now resolves to Python 3.13.4. ([#1810](https://github.com/heroku/heroku-buildpack-python/pull/1810)) +- The Python 3.12 version alias now resolves to Python 3.12.11. ([#1810](https://github.com/heroku/heroku-buildpack-python/pull/1810)) +- The Python 3.11 version alias now resolves to Python 3.11.13. ([#1810](https://github.com/heroku/heroku-buildpack-python/pull/1810)) +- The Python 3.10 version alias now resolves to Python 3.10.18. ([#1810](https://github.com/heroku/heroku-buildpack-python/pull/1810)) +- The Python 3.9 version alias now resolves to Python 3.9.23. ([#1810](https://github.com/heroku/heroku-buildpack-python/pull/1810)) +- Updated uv from 0.7.6 to 0.7.10. ([#1811](https://github.com/heroku/heroku-buildpack-python/pull/1811)) + +## [v287] - 2025-05-20 + +- Updated pip from 25.0.1 to 25.1.1. ([#1795](https://github.com/heroku/heroku-buildpack-python/pull/1795)) +- Updated Poetry from 2.1.2 to 2.1.3. ([#1797](https://github.com/heroku/heroku-buildpack-python/pull/1797)) +- Updated uv from 0.7.3 to 0.7.6. ([#1800](https://github.com/heroku/heroku-buildpack-python/pull/1800) and [#1803](https://github.com/heroku/heroku-buildpack-python/pull/1803)) + +## [v286] - 2025-05-13 + +- Added support for the package manager uv. ([#1791](https://github.com/heroku/heroku-buildpack-python/pull/1791)) + +## [v285] - 2025-05-08 + +- Improved internal buildpack metrics handling of attributes that contain newline characters. ([#1792](https://github.com/heroku/heroku-buildpack-python/pull/1792)) + +## [v284] - 2025-05-06 + +- Fixed parsing of `runtime.txt` and `.python-version` files that contain CRLF characters. ([#1789](https://github.com/heroku/heroku-buildpack-python/pull/1789)) + +## [v283] - 2025-05-06 + +- Added a warning when an app doesn't specify a Python version and instead relies upon the default/cached version. ([#1787](https://github.com/heroku/heroku-buildpack-python/pull/1787)) +- Improved the instructions for migrating from `runtime.txt` to `.python-version`. ([#1783](https://github.com/heroku/heroku-buildpack-python/pull/1783)) +- Improved the error messages shown when `.python-version`, `runtime.txt` or `Pipfile.lock` contain an invalid Python version. ([#1783](https://github.com/heroku/heroku-buildpack-python/pull/1783) and [#1786](https://github.com/heroku/heroku-buildpack-python/pull/1786)) +- Improved the rendering of the error message shown when `.python-version` or `runtime.txt` contain stray invisible characters (such as ASCII control codes). ([#1783](https://github.com/heroku/heroku-buildpack-python/pull/1783)) +- Improved the upgrade instructions shown for EOL and unsupported Python versions. ([#1783](https://github.com/heroku/heroku-buildpack-python/pull/1783) and [#1786](https://github.com/heroku/heroku-buildpack-python/pull/1786)) +- Improved the error messages shown when no Python package manager files are found. ([#1787](https://github.com/heroku/heroku-buildpack-python/pull/1787)) + +## [v282] - 2025-05-02 + +- Removed support for Heroku-20. ([#1778](https://github.com/heroku/heroku-buildpack-python/pull/1778)) + +## [v281] - 2025-04-08 + +- The Python 3.13 version alias now resolves to Python 3.13.3. ([#1775](https://github.com/heroku/heroku-buildpack-python/pull/1775)) +- The Python 3.12 version alias now resolves to Python 3.12.10. ([#1775](https://github.com/heroku/heroku-buildpack-python/pull/1775)) +- The Python 3.11 version alias now resolves to Python 3.11.12. ([#1775](https://github.com/heroku/heroku-buildpack-python/pull/1775)) +- The Python 3.10 version alias now resolves to Python 3.10.17. ([#1775](https://github.com/heroku/heroku-buildpack-python/pull/1775)) +- The Python 3.9 version alias now resolves to Python 3.9.22. ([#1775](https://github.com/heroku/heroku-buildpack-python/pull/1775)) + +## [v280] - 2025-04-08 + +- Updated pip from 24.3.1 to 25.0.1. ([#1759](https://github.com/heroku/heroku-buildpack-python/pull/1759)) +- Updated Poetry from 2.1.1 to 2.1.2. ([#1772](https://github.com/heroku/heroku-buildpack-python/pull/1772)) + +## [v279] - 2025-02-26 + +- Updated Poetry from 2.0.1 to 2.1.1. ([#1758](https://github.com/heroku/heroku-buildpack-python/pull/1758)) +- Stopped filtering out pip's `Requirement already satisfied:` log lines when installing dependencies. ([#1765](https://github.com/heroku/heroku-buildpack-python/pull/1765)) +- Improved the error messages shown if installing pip/Poetry/Pipenv fails. ([#1764](https://github.com/heroku/heroku-buildpack-python/pull/1764)) +- Stopped installing pip into Poetry's virtual environment. ([#1761](https://github.com/heroku/heroku-buildpack-python/pull/1761)) + +## [v278] - 2025-02-24 + +- Added build-time rewriting of editable VCS dependency paths (in addition to the existing run-time rewriting), to work around an upstream Pipenv bug with editable VCS dependencies not being reinstalled correctly for cached builds. ([#1756](https://github.com/heroku/heroku-buildpack-python/pull/1756)) +- Changed the location of repositories for editable VCS dependencies when using pip and Pipenv, to improve build performance and match the behaviour when using Poetry. ([#1753](https://github.com/heroku/heroku-buildpack-python/pull/1753)) + +## [v277] - 2025-02-17 + +- Improved the warning message shown when the requested Python version is not the latest patch version. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) +- Improved the error message shown when the requested Python patch version isn't available. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) +- Improved the error message shown if there was a networking or server related error downloading Python. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) +- Adjusted the curl options used when downloading Python to set a maximum download time of 120s to prevent hanging builds in the case of network issues. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) +- Refactored the Python download step to avoid an unnecessary version check `HEAD` request to S3 prior to downloading Python or reusing a cached install. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) +- Updated the `runtime.txt` deprecation warning to include a link to the deprecation changelog post. ([#1747](https://github.com/heroku/heroku-buildpack-python/pull/1747)) +- Improved buildpack metrics for Python version selection. ([#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) +- Improved buildpack metrics for builds that fail. ([#1746](https://github.com/heroku/heroku-buildpack-python/pull/1746) and [#1749](https://github.com/heroku/heroku-buildpack-python/pull/1749)) + +## [v276] - 2025-02-05 + +- The Python 3.13 version alias now resolves to Python 3.13.2. ([#1744](https://github.com/heroku/heroku-buildpack-python/pull/1744)) +- The Python 3.12 version alias now resolves to Python 3.12.9. ([#1744](https://github.com/heroku/heroku-buildpack-python/pull/1744)) +- Deprecated support for the `runtime.txt` file. ([#1743](https://github.com/heroku/heroku-buildpack-python/pull/1743)) +- Improved the error messages shown when `.python-version`, `runtime.txt` or `Pipfile.lock` contain an invalid Python version. ([#1743](https://github.com/heroku/heroku-buildpack-python/pull/1743)) + +## [v275] - 2025-01-13 + +- Updated Poetry from 1.8.5 to 2.0.1. ([#1734](https://github.com/heroku/heroku-buildpack-python/pull/1734)) + +## [v274] - 2025-01-08 + +- Added a deprecation warning for Python 3.9. ([#1732](https://github.com/heroku/heroku-buildpack-python/pull/1732)) +- Removed support for Python 3.8. ([#1732](https://github.com/heroku/heroku-buildpack-python/pull/1732)) +- Improved the error messages shown for EOL or unrecognised major Python versions. ([#1732](https://github.com/heroku/heroku-buildpack-python/pull/1732)) + +## [v273] - 2025-01-03 + +- Added more Python project related file and directory names to the list recognised by buildpack detection. ([#1729](https://github.com/heroku/heroku-buildpack-python/pull/1729)) +- Improved the file listing in the error messages shown when buildpack detection fails or when no Python package manager files are found. ([#1728](https://github.com/heroku/heroku-buildpack-python/pull/1728)) + +## [v272] - 2024-12-13 + +- Added a warning if the Python buildpack has been run multiple times in the same build. In the future this warning will be made an error. ([#1724](https://github.com/heroku/heroku-buildpack-python/pull/1724)) +- Added a warning if an existing `.heroku/python/` directory is found in the app source. In the future this warning will be made an error. ([#1724](https://github.com/heroku/heroku-buildpack-python/pull/1724)) +- Improved the error message shown if the buildpack is used on an unsupported stack. ([#1724](https://github.com/heroku/heroku-buildpack-python/pull/1724)) +- Fixed Dev Center links to reflect recent article URL changes. ([#1723](https://github.com/heroku/heroku-buildpack-python/pull/1723)) +- Added metrics for the existence of a uv lockfile. ([#1725](https://github.com/heroku/heroku-buildpack-python/pull/1725)) + +## [v271] - 2024-12-12 + +- Updated the Python 3.8 EOL warning message with the new sunset date. ([#1721](https://github.com/heroku/heroku-buildpack-python/pull/1721)) +- Improved the error message shown when pip install fails due to pip rejecting a package with invalid version metadata. ([#1718](https://github.com/heroku/heroku-buildpack-python/pull/1718)) +- Improved the error message shown when the copy of pip bundled in the `ensurepip` module cannot be found. ([#1720](https://github.com/heroku/heroku-buildpack-python/pull/1720)) + +## [v270] - 2024-12-10 + +- Changed the default Python version for new apps from 3.12 to 3.13. ([#1715](https://github.com/heroku/heroku-buildpack-python/pull/1715)) +- Changed Python version pinning behaviour for apps that do not specify a Python version. Repeat builds are now pinned to the major Python version only (`3.X`) instead of the full Python version (`3.X.Y`), so that they always use the latest patch version. ([#1714](https://github.com/heroku/heroku-buildpack-python/pull/1714)) +- Updated Poetry from 1.8.4 to 1.8.5. ([#1716](https://github.com/heroku/heroku-buildpack-python/pull/1716)) + +## [v269] - 2024-12-04 + +- The Python 3.13 version alias now resolves to Python 3.13.1. ([#1712](https://github.com/heroku/heroku-buildpack-python/pull/1712)) +- The Python 3.12 version alias now resolves to Python 3.12.8. ([#1712](https://github.com/heroku/heroku-buildpack-python/pull/1712)) +- The Python 3.11 version alias now resolves to Python 3.11.11. ([#1712](https://github.com/heroku/heroku-buildpack-python/pull/1712)) +- The Python 3.10 version alias now resolves to Python 3.10.16. ([#1712](https://github.com/heroku/heroku-buildpack-python/pull/1712)) +- The Python 3.9 version alias now resolves to Python 3.9.21. ([#1712](https://github.com/heroku/heroku-buildpack-python/pull/1712)) + +## [v268] - 2024-12-04 + +- Updated pip from 24.0 to 24.3.1. ([#1685](https://github.com/heroku/heroku-buildpack-python/pull/1685)) +- Updated wheel from 0.44.0 to 0.45.1. ([#1707](https://github.com/heroku/heroku-buildpack-python/pull/1707)) + +## [v267] - 2024-11-12 + +- Deprecated using Pipenv without a lockfile (`Pipfile.lock`). ([#1695](https://github.com/heroku/heroku-buildpack-python/pull/1695)) +- Fixed Poetry venv creation when using an outdated Python version whose `ensurepip` module doesn't enable isolated mode, and the app's build directory contents shadows a package imported by pip (such as `brotli`). ([#1698](https://github.com/heroku/heroku-buildpack-python/pull/1698)) + +## [v266] - 2024-11-08 + +- Added a warning when the files for multiple package managers are found. In the future this warning will become an error. ([#1692](https://github.com/heroku/heroku-buildpack-python/pull/1692)) +- Updated the build log message shown when installing dependencies to include the package manager command being run. ([#1689](https://github.com/heroku/heroku-buildpack-python/pull/1689)) +- Changed test dependency installation on Heroku CI to now install `requirements.txt` and `requirements-test.txt` in a single `pip install` invocation rather than separately. This allows pip's resolver to resolve any version conflicts between the two files. ([#1689](https://github.com/heroku/heroku-buildpack-python/pull/1689)) +- Improved the error messages and buildpack metrics for package manager related failures. ([#1689](https://github.com/heroku/heroku-buildpack-python/pull/1689)) +- Improved the build log output, error messages and buildpack failure metrics for the NLTK downloader feature. ([#1690](https://github.com/heroku/heroku-buildpack-python/pull/1690)) + +## [v265] - 2024-11-06 + +- Fixed Poetry installation when using outdated patch versions of Python 3.8, 3.9 and 3.10, whose bundled pip doesn't support the `--python` option. ([#1687](https://github.com/heroku/heroku-buildpack-python/pull/1687)) + +## [v264] - 2024-11-06 + +- Added support for the package manager Poetry. Apps must have a `pyproject.toml` + `poetry.lock` and no other package manager files (otherwise pip/Pipenv will take precedence for backwards compatibility). ([#1682](https://github.com/heroku/heroku-buildpack-python/pull/1682)) + +## [v263] - 2024-10-31 + +- Fixed cache handling so that it now also discards the cache when the package manager (or its version) changes. ([#1679](https://github.com/heroku/heroku-buildpack-python/pull/1679)) +- Improved the build log output shown when restoring or discarding the cache. For example, if the cache was invalidated all reasons are now shown. ([#1679](https://github.com/heroku/heroku-buildpack-python/pull/1679)) +- Stopped performing unnecessary cache file copies when the cache is due to be invalidated. This required moving the cache restoration step to after the `bin/pre_compile` hook runs. ([#1679](https://github.com/heroku/heroku-buildpack-python/pull/1679)) +- Fixed cache restoration in the case where an app's `requirements.txt` was formerly a symlink. ([#1679](https://github.com/heroku/heroku-buildpack-python/pull/1679)) +- Added buildpack metrics for the status of the cache and duration of cache restoration/saving. ([#1679](https://github.com/heroku/heroku-buildpack-python/pull/1679)) + +## [v262] - 2024-10-25 + +- Updated buildpack-generated warning messages to use colour and be more consistently formatted. ([#1666](https://github.com/heroku/heroku-buildpack-python/pull/1666)) +- Improved build log output and error messages for the `bin/pre_compile` and `bin/post_compile` customisation hooks. ([#1667](https://github.com/heroku/heroku-buildpack-python/pull/1667)) + +## [v261] - 2024-10-14 + +- 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 + +- Added support for Python 3.13. ([#1661](https://github.com/heroku/heroku-buildpack-python/pull/1661)) +- Removed the `idle3` and `pydoc3` scripts since they do not work with relocated Python and so have been broken for some time. Invoke them via their modules instead (e.g. `python -m pydoc`). ([#1661](https://github.com/heroku/heroku-buildpack-python/pull/1661)) + +## [v259] - 2024-10-09 + +- Improved build log output about the detected Python version. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658)) +- Improved error messages shown when the requested Python version is not a valid version string or is for an unknown/non-existent major Python version. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658)) +- Improved error messages shown when `Pipfile.lock` is not valid JSON. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658)) +- Fixed invalid Python versions being silently ignored when they were specified via the `python_version` field in `Pipfile.lock`. ([#1658](https://github.com/heroku/heroku-buildpack-python/pull/1658)) +- Added support for Python 3.9 on Heroku-24. ([#1656](https://github.com/heroku/heroku-buildpack-python/pull/1656)) +- Added buildpack metrics for use of outdated Python patch versions and occurrences of internal errors. ([#1657](https://github.com/heroku/heroku-buildpack-python/pull/1657)) +- Improved the robustness of buildpack error handling by enabling `inherit_errexit`. ([#1655](https://github.com/heroku/heroku-buildpack-python/pull/1655)) + +## [v258] - 2024-10-01 + +- Added support for Python 3.12.7. ([#1650](https://github.com/heroku/heroku-buildpack-python/pull/1650)) +- Changed the default Python version for new apps from 3.12.6 to 3.12.7. ([#1650](https://github.com/heroku/heroku-buildpack-python/pull/1650)) +- Fixed Django collectstatic debug output being shown if `DEBUG_COLLECTSTATIC` was set to `0` or the empty string. ([#1646](https://github.com/heroku/heroku-buildpack-python/pull/1646)) +- Stopped adding a trailing `:` to `C_INCLUDE_PATH`, `CPLUS_INCLUDE_PATH`, `LIBRARY_PATH`, `LD_LIBRARY_PATH` and `PKG_CONFIG_PATH`. ([#1645](https://github.com/heroku/heroku-buildpack-python/pull/1645)) +- Removed remnants of the unused `.heroku/vendor/` directory. ([#1644](https://github.com/heroku/heroku-buildpack-python/pull/1644)) + +## [v257] - 2024-09-24 + +- Moved the SQLite3 install step prior to installing dependencies when using Pipenv. This now matches the behaviour when using pip and allows dependencies to actually use the headers. ([#1640](https://github.com/heroku/heroku-buildpack-python/pull/1640)) +- Stopped exposing the `SKIP_PIP_INSTALL` env var to `bin/post_compile` and other subprocesses when using Pipenv. ([#1640](https://github.com/heroku/heroku-buildpack-python/pull/1640)) +- Stopped creating `.heroku/python/requirements-{declared,installed}.txt` files when using pip. ([#1640](https://github.com/heroku/heroku-buildpack-python/pull/1640)) +- Stopped creating a placeholder `requirements.txt` file when an app only has a `setup.py` file and no other package manager files. Instead pip is now invoked directly using `--editable .`. ([#1640](https://github.com/heroku/heroku-buildpack-python/pull/1640)) +- Improved buildpack metrics for package manager detection and duration of install steps. ([#1640](https://github.com/heroku/heroku-buildpack-python/pull/1640)) +- Updated buildpack-generated error messages to use colour and be more consistently formatted. ([#1639](https://github.com/heroku/heroku-buildpack-python/pull/1639)) + +## [v256] - 2024-09-07 + +- Added support for Python 3.8.20, 3.9.20, 3.10.15, 3.11.10 and 3.12.6. ([#1632](https://github.com/heroku/heroku-buildpack-python/pull/1632)) +- Changed the default Python version for new apps from 3.12.5 to 3.12.6. ([#1632](https://github.com/heroku/heroku-buildpack-python/pull/1632)) +- Updated wheel from 0.43.0 to 0.44.0. ([#1629](https://github.com/heroku/heroku-buildpack-python/pull/1629)) + +## [v255] - 2024-08-07 + +- Added support for Python 3.12.5. ([#1622](https://github.com/heroku/heroku-buildpack-python/pull/1622)) +- Changed the default Python version for new apps from 3.12.4 to 3.12.5. ([#1622](https://github.com/heroku/heroku-buildpack-python/pull/1622)) + ## [v254] - 2024-07-16 - Updated setuptools from 69.2.0 to 70.3.0. ([#1614](https://github.com/heroku/heroku-buildpack-python/pull/1614)) @@ -67,7 +594,7 @@ ## [v243] - 2024-02-07 -- Added support for Python 3.11.8 and 3.12.2. ([#1538](https://github.com/heroku/heroku-buildpack-python/pull/1538)). +- Added support for Python 3.11.8 and 3.12.2. ([#1538](https://github.com/heroku/heroku-buildpack-python/pull/1538)) - Changed the default Python version for new apps from 3.12.1 to 3.12.2. ([#1538](https://github.com/heroku/heroku-buildpack-python/pull/1538)) ## [v242] - 2024-01-11 @@ -78,8 +605,8 @@ ## [v241] - 2023-12-08 -- Changed the default Python version for new apps from Python 3.11 to Python 3.12. ([#1516](https://github.com/heroku/heroku-buildpack-python/pull/1516)). -- Added support for Python 3.11.7 and 3.12.1. ([#1517](https://github.com/heroku/heroku-buildpack-python/pull/1517) and [#1518](https://github.com/heroku/heroku-buildpack-python/pull/1518)). +- Changed the default Python version for new apps from Python 3.11 to Python 3.12. ([#1516](https://github.com/heroku/heroku-buildpack-python/pull/1516)) +- Added support for Python 3.11.7 and 3.12.1. ([#1517](https://github.com/heroku/heroku-buildpack-python/pull/1517) and [#1518](https://github.com/heroku/heroku-buildpack-python/pull/1518)) - Added a deprecation warning for Python 3.8. ([#1515](https://github.com/heroku/heroku-buildpack-python/pull/1515)) ## [v240] - 2023-11-30 @@ -518,7 +1045,7 @@ ## v164 - 2020-02-20 -- Update requirements.txt builds to use Pip 20.0.2 +- Update requirements.txt builds to use pip 20.0.2 - Download get-pip.py to tmpdir instead of root dir ## v163 - 2019-12-23 @@ -647,7 +1174,7 @@ Default to 3.6.5, bugfixes. ## v133 -Fixes for Pip 10 release. +Fixes for pip 10 release. ## v132 @@ -955,7 +1482,7 @@ Updated pip and Setuptools. Added warnings for lack of Procfile. -## v72 - 2015-12-07 +## v73 - 2015-12-07 Updated default Python to 2.7.11. @@ -993,81 +1520,174 @@ Improved compatibility with multi and node.js buildpacks. ## v63 - 2015-10-08 -Updated Pip and Setuptools. +Updated pip and Setuptools. - Setuptools updated to v18.3.2 -- Pip updated to v7.1.2 +- pip updated to v7.1.2 ## v62 - 2015-08-07 -Updated Pip and Setuptools. +Updated pip and Setuptools. - Setuptools updated to v18.1 -- Pip updated to v7.1.0 +- pip updated to v7.1.0 ## v61 - 2015-06-30 -Updated Pip and Setuptools. +Updated pip and Setuptools. - Setuptools updated to v18.0.1 -- Pip updated to v7.0.3 +- pip updated to v7.0.3 ## v60 - 2015-05-27 -Default Python is now latest 2.7.10. Updated Pip and Distribute. +Default Python is now latest 2.7.10. Updated pip and Distribute. - Default Python version is v2.7.10 - Setuptools updated to v16.0 -- Pip updated to v7.0.1 - -[unreleased]: https://github.com/heroku/heroku-buildpack-python/compare/v254...main -[v254]: https://github.com/heroku/heroku-buildpack-python/compare/v253...v254 -[v253]: https://github.com/heroku/heroku-buildpack-python/compare/v252...v253 -[v252]: https://github.com/heroku/heroku-buildpack-python/compare/v251...v252 -[v251]: https://github.com/heroku/heroku-buildpack-python/compare/v250...v251 -[v250]: https://github.com/heroku/heroku-buildpack-python/compare/v249...v250 -[v249]: https://github.com/heroku/heroku-buildpack-python/compare/v248...v249 -[v248]: https://github.com/heroku/heroku-buildpack-python/compare/v247...v248 -[v247]: https://github.com/heroku/heroku-buildpack-python/compare/v246...v247 -[v246]: https://github.com/heroku/heroku-buildpack-python/compare/v245...v246 -[v245]: https://github.com/heroku/heroku-buildpack-python/compare/v244...v245 -[v244]: https://github.com/heroku/heroku-buildpack-python/compare/v243...v244 -[v243]: https://github.com/heroku/heroku-buildpack-python/compare/v242...v243 -[v242]: https://github.com/heroku/heroku-buildpack-python/compare/v241...v242 -[v241]: https://github.com/heroku/heroku-buildpack-python/compare/v240...v241 -[v240]: https://github.com/heroku/heroku-buildpack-python/compare/v239...v240 -[v239]: https://github.com/heroku/heroku-buildpack-python/compare/v238...v239 -[v238]: https://github.com/heroku/heroku-buildpack-python/compare/v237...v238 -[v237]: https://github.com/heroku/heroku-buildpack-python/compare/v236...v237 -[v236]: https://github.com/heroku/heroku-buildpack-python/compare/v235...v236 -[v235]: https://github.com/heroku/heroku-buildpack-python/compare/v234...v235 -[v234]: https://github.com/heroku/heroku-buildpack-python/compare/v233...v234 -[v233]: https://github.com/heroku/heroku-buildpack-python/compare/v232...v233 -[v232]: https://github.com/heroku/heroku-buildpack-python/compare/v231...v232 -[v231]: https://github.com/heroku/heroku-buildpack-python/compare/v230...v231 -[v230]: https://github.com/heroku/heroku-buildpack-python/compare/v229...v230 -[v229]: https://github.com/heroku/heroku-buildpack-python/compare/v228...v229 -[v228]: https://github.com/heroku/heroku-buildpack-python/compare/v227...v228 -[v227]: https://github.com/heroku/heroku-buildpack-python/compare/v226...v227 -[v226]: https://github.com/heroku/heroku-buildpack-python/compare/v225...v226 -[v225]: https://github.com/heroku/heroku-buildpack-python/compare/v224...v225 -[v224]: https://github.com/heroku/heroku-buildpack-python/compare/v223...v224 -[v223]: https://github.com/heroku/heroku-buildpack-python/compare/v222...v223 -[v222]: https://github.com/heroku/heroku-buildpack-python/compare/v221...v222 -[v221]: https://github.com/heroku/heroku-buildpack-python/compare/v220...v221 -[v220]: https://github.com/heroku/heroku-buildpack-python/compare/v219...v220 -[v219]: https://github.com/heroku/heroku-buildpack-python/compare/v218...v219 -[v218]: https://github.com/heroku/heroku-buildpack-python/compare/v217...v218 -[v217]: https://github.com/heroku/heroku-buildpack-python/compare/v216...v217 -[v216]: https://github.com/heroku/heroku-buildpack-python/compare/v215...v216 -[v215]: https://github.com/heroku/heroku-buildpack-python/compare/v214...v215 -[v214]: https://github.com/heroku/heroku-buildpack-python/compare/v213...v214 -[v213]: https://github.com/heroku/heroku-buildpack-python/compare/v212...v213 -[v212]: https://github.com/heroku/heroku-buildpack-python/compare/v211...v212 -[v211]: https://github.com/heroku/heroku-buildpack-python/compare/v210...v211 -[v210]: https://github.com/heroku/heroku-buildpack-python/compare/v209...v210 -[v209]: https://github.com/heroku/heroku-buildpack-python/compare/v208...v209 -[v208]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v207...v208 +- pip updated to v7.0.1 + +[unreleased]: https://github.com/heroku/heroku-buildpack-python/compare/v347...main +[v347]: https://github.com/heroku/heroku-buildpack-python/compare/v346...v347 +[v346]: https://github.com/heroku/heroku-buildpack-python/compare/v345...v346 +[v345]: https://github.com/heroku/heroku-buildpack-python/compare/v344...v345 +[v344]: https://github.com/heroku/heroku-buildpack-python/compare/v343...v344 +[v343]: https://github.com/heroku/heroku-buildpack-python/compare/v342...v343 +[v342]: https://github.com/heroku/heroku-buildpack-python/compare/v341...v342 +[v341]: https://github.com/heroku/heroku-buildpack-python/compare/v340...v341 +[v340]: https://github.com/heroku/heroku-buildpack-python/compare/v339...v340 +[v339]: https://github.com/heroku/heroku-buildpack-python/compare/v338...v339 +[v338]: https://github.com/heroku/heroku-buildpack-python/compare/v337...v338 +[v337]: https://github.com/heroku/heroku-buildpack-python/compare/v336...v337 +[v336]: https://github.com/heroku/heroku-buildpack-python/compare/v335...v336 +[v335]: https://github.com/heroku/heroku-buildpack-python/compare/v334...v335 +[v334]: https://github.com/heroku/heroku-buildpack-python/compare/v333...v334 +[v333]: https://github.com/heroku/heroku-buildpack-python/compare/v332...v333 +[v332]: https://github.com/heroku/heroku-buildpack-python/compare/v331...v332 +[v331]: https://github.com/heroku/heroku-buildpack-python/compare/v330...v331 +[v330]: https://github.com/heroku/heroku-buildpack-python/compare/v329...v330 +[v329]: https://github.com/heroku/heroku-buildpack-python/compare/v328...v329 +[v328]: https://github.com/heroku/heroku-buildpack-python/compare/v327...v328 +[v327]: https://github.com/heroku/heroku-buildpack-python/compare/v326...v327 +[v326]: https://github.com/heroku/heroku-buildpack-python/compare/v325...v326 +[v325]: https://github.com/heroku/heroku-buildpack-python/compare/v324...v325 +[v324]: https://github.com/heroku/heroku-buildpack-python/compare/v323...v324 +[v323]: https://github.com/heroku/heroku-buildpack-python/compare/v322...v323 +[v322]: https://github.com/heroku/heroku-buildpack-python/compare/v321...v322 +[v321]: https://github.com/heroku/heroku-buildpack-python/compare/v320...v321 +[v320]: https://github.com/heroku/heroku-buildpack-python/compare/v319...v320 +[v319]: https://github.com/heroku/heroku-buildpack-python/compare/v318...v319 +[v318]: https://github.com/heroku/heroku-buildpack-python/compare/v317...v318 +[v317]: https://github.com/heroku/heroku-buildpack-python/compare/v316...v317 +[v316]: https://github.com/heroku/heroku-buildpack-python/compare/v315...v316 +[v315]: https://github.com/heroku/heroku-buildpack-python/compare/v314...v315 +[v314]: https://github.com/heroku/heroku-buildpack-python/compare/v313...v314 +[v313]: https://github.com/heroku/heroku-buildpack-python/compare/v312...v313 +[v312]: https://github.com/heroku/heroku-buildpack-python/compare/v311...v312 +[v311]: https://github.com/heroku/heroku-buildpack-python/compare/v310...v311 +[v310]: https://github.com/heroku/heroku-buildpack-python/compare/v309...v310 +[v309]: https://github.com/heroku/heroku-buildpack-python/compare/v308...v309 +[v308]: https://github.com/heroku/heroku-buildpack-python/compare/v307...v308 +[v307]: https://github.com/heroku/heroku-buildpack-python/compare/v306...v307 +[v306]: https://github.com/heroku/heroku-buildpack-python/compare/v305...v306 +[v305]: https://github.com/heroku/heroku-buildpack-python/compare/v304...v305 +[v304]: https://github.com/heroku/heroku-buildpack-python/compare/v303...v304 +[v303]: https://github.com/heroku/heroku-buildpack-python/compare/v302...v303 +[v302]: https://github.com/heroku/heroku-buildpack-python/compare/v301...v302 +[v301]: https://github.com/heroku/heroku-buildpack-python/compare/v300...v301 +[v300]: https://github.com/heroku/heroku-buildpack-python/compare/v299...v300 +[v299]: https://github.com/heroku/heroku-buildpack-python/compare/v298...v299 +[v298]: https://github.com/heroku/heroku-buildpack-python/compare/v297...v298 +[v297]: https://github.com/heroku/heroku-buildpack-python/compare/v296...v297 +[v296]: https://github.com/heroku/heroku-buildpack-python/compare/v295...v296 +[v295]: https://github.com/heroku/heroku-buildpack-python/compare/v294...v295 +[v294]: https://github.com/heroku/heroku-buildpack-python/compare/v293...v294 +[v293]: https://github.com/heroku/heroku-buildpack-python/compare/v292...v293 +[v292]: https://github.com/heroku/heroku-buildpack-python/compare/v291...v292 +[v291]: https://github.com/heroku/heroku-buildpack-python/compare/v290...v291 +[v290]: https://github.com/heroku/heroku-buildpack-python/compare/v289...v290 +[v289]: https://github.com/heroku/heroku-buildpack-python/compare/v288...v289 +[v288]: https://github.com/heroku/heroku-buildpack-python/compare/v287...v288 +[v287]: https://github.com/heroku/heroku-buildpack-python/compare/v286...v287 +[v286]: https://github.com/heroku/heroku-buildpack-python/compare/v285...v286 +[v285]: https://github.com/heroku/heroku-buildpack-python/compare/v284...v285 +[v284]: https://github.com/heroku/heroku-buildpack-python/compare/v283...v284 +[v283]: https://github.com/heroku/heroku-buildpack-python/compare/v282...v283 +[v282]: https://github.com/heroku/heroku-buildpack-python/compare/v281...v282 +[v281]: https://github.com/heroku/heroku-buildpack-python/compare/v280...v281 +[v280]: https://github.com/heroku/heroku-buildpack-python/compare/v279...v280 +[v279]: https://github.com/heroku/heroku-buildpack-python/compare/v278...v279 +[v278]: https://github.com/heroku/heroku-buildpack-python/compare/v277...v278 +[v277]: https://github.com/heroku/heroku-buildpack-python/compare/v276...v277 +[v276]: https://github.com/heroku/heroku-buildpack-python/compare/v275...v276 +[v275]: https://github.com/heroku/heroku-buildpack-python/compare/v274...v275 +[v274]: https://github.com/heroku/heroku-buildpack-python/compare/v273...v274 +[v273]: https://github.com/heroku/heroku-buildpack-python/compare/v272...v273 +[v272]: https://github.com/heroku/heroku-buildpack-python/compare/v271...v272 +[v271]: https://github.com/heroku/heroku-buildpack-python/compare/v270...v271 +[v270]: https://github.com/heroku/heroku-buildpack-python/compare/v269...v270 +[v269]: https://github.com/heroku/heroku-buildpack-python/compare/v268...v269 +[v268]: https://github.com/heroku/heroku-buildpack-python/compare/v267...v268 +[v267]: https://github.com/heroku/heroku-buildpack-python/compare/v266...v267 +[v266]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v265...v266 +[v265]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v264...archive/v265 +[v264]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v263...archive/v264 +[v263]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v262...archive/v263 +[v262]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v261...archive/v262 +[v261]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v260...archive/v261 +[v260]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v259...archive/v260 +[v259]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v258...archive/v259 +[v258]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v257...archive/v258 +[v257]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v256...archive/v257 +[v256]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v255...archive/v256 +[v255]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v254...archive/v255 +[v254]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v253...archive/v254 +[v253]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v252...archive/v253 +[v252]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v251...archive/v252 +[v251]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v250...archive/v251 +[v250]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v249...archive/v250 +[v249]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v248...archive/v249 +[v248]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v247...archive/v248 +[v247]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v246...archive/v247 +[v246]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v245...archive/v246 +[v245]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v244...archive/v245 +[v244]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v243...archive/v244 +[v243]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v242...archive/v243 +[v242]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v241...archive/v242 +[v241]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v240...archive/v241 +[v240]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v239...archive/v240 +[v239]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v238...archive/v239 +[v238]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v237...archive/v238 +[v237]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v236...archive/v237 +[v236]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v235...archive/v236 +[v235]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v234...archive/v235 +[v234]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v233...archive/v234 +[v233]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v232...archive/v233 +[v232]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v231...archive/v232 +[v231]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v230...archive/v231 +[v230]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v229...archive/v230 +[v229]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v228...archive/v229 +[v228]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v227...archive/v228 +[v227]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v226...archive/v227 +[v226]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v225...archive/v226 +[v225]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v224...archive/v225 +[v224]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v223...archive/v224 +[v223]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v222...archive/v223 +[v222]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v221...archive/v222 +[v221]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v220...archive/v221 +[v220]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v219...archive/v220 +[v219]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v218...archive/v219 +[v218]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v217...archive/v218 +[v217]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v216...archive/v217 +[v216]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v215...archive/v216 +[v215]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v214...archive/v215 +[v214]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v213...archive/v214 +[v213]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v212...archive/v213 +[v212]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v211...archive/v212 +[v211]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v210...archive/v211 +[v210]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v209...archive/v210 +[v209]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v208...archive/v209 +[v208]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v207...archive/v208 [v207]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v206...archive/v207 [v206]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v205...archive/v206 [v205]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v204...archive/v205 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..9e229777d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,105 @@ +# Salesforce Open Source Community Code of Conduct + +## About the Code of Conduct + +Equality is a core value at Salesforce. We believe a diverse and inclusive +community fosters innovation and creativity, and are committed to building a +culture where everyone feels included. + +Salesforce open-source projects are committed to providing a friendly, safe, and +welcoming environment for all, regardless of gender identity and expression, +sexual orientation, disability, physical appearance, body size, ethnicity, nationality, +race, age, religion, level of experience, education, socioeconomic status, or +other similar personal characteristics. + +The goal of this code of conduct is to specify a baseline standard of behavior so +that people with different social values and communication styles can work +together effectively, productively, and respectfully in our open source community. +It also establishes a mechanism for reporting issues and resolving conflicts. + +All questions and reports of abusive, harassing, or otherwise unacceptable behavior +in a Salesforce open-source project may be reported by contacting the Salesforce +Open Source Conduct Committee at ossconduct@salesforce.com. + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of gender +identity and expression, sexual orientation, disability, physical appearance, +body size, ethnicity, nationality, race, age, religion, level of experience, education, +socioeconomic status, or other similar personal characteristics. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy toward other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Personal attacks, insulting/derogatory comments, or trolling +* Public or private harassment +* Publishing, or threatening to publish, others' private information—such as +a physical or electronic address—without explicit permission +* Other conduct which could reasonably be considered inappropriate in a +professional setting +* Advocating for or encouraging any of the above behaviors + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned with this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project email +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the Salesforce Open Source Conduct Committee +at ossconduct@salesforce.com. All complaints will be reviewed and investigated +and will result in a response that is deemed necessary and appropriate to the +circumstances. The committee is obligated to maintain confidentiality with +regard to the reporter of an incident. Further details of specific enforcement +policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership and the Salesforce Open Source Conduct +Committee. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home], +version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. +It includes adaptations and additions from [Go Community Code of Conduct][golang-coc], +[CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc]. + +This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us]. + +[contributor-covenant-home]: https://www.contributor-covenant.org +[golang-coc]: https://golang.org/conduct +[cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md +[microsoft-coc]: https://opensource.microsoft.com/codeofconduct/ +[cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/ diff --git a/Gemfile b/Gemfile index 6bd480a38..7ed84f7d1 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -ruby '>= 3.1', '< 3.4' +ruby '>= 3.2', '< 4.1' group :test, :development do gem 'heroku_hatchet' diff --git a/Gemfile.lock b/Gemfile.lock index 90b394edb..36e3c353f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,12 @@ GEM remote: https://rubygems.org/ specs: - ast (2.4.2) - base64 (0.2.0) - diff-lcs (1.5.1) + ast (2.4.3) + base64 (0.3.0) + diff-lcs (1.6.2) erubis (2.7.0) - excon (0.110.0) + excon (1.5.0) + logger heroics (0.1.3) base64 erubis (~> 2.0) @@ -13,66 +14,73 @@ GEM moneta multi_json (>= 1.9.2) webrick - heroku_hatchet (8.0.4) - excon (~> 0) + heroku_hatchet (8.0.6) + excon (< 2) platform-api (~> 3) rrrretry (~> 1) thor (~> 1) threaded (~> 0) - json (2.7.2) - language_server-protocol (3.17.0.3) + json (2.20.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) moneta (1.0.0) - multi_json (1.15.0) - parallel (1.25.1) + multi_json (1.21.1) + parallel (2.1.0) parallel_split_test (0.10.0) parallel (>= 0.5.13) rspec-core (>= 3.9.0) - parser (3.3.3.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - platform-api (3.7.0) + platform-api (3.9.0) heroics (~> 0.1.1) moneta (~> 1.0.0) rate_throttle_client (~> 0.1.0) - racc (1.8.0) + prism (1.9.0) + racc (1.8.1) rainbow (3.1.1) rate_throttle_client (0.1.2) - regexp_parser (2.9.2) - rexml (3.3.0) - strscan + regexp_parser (2.12.0) rrrretry (1.0.0) - rspec-core (3.13.0) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.1) - rubocop (1.64.1) + rspec-support (3.13.7) + rubocop (1.88.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) - parser (>= 3.3.1.0) - rubocop-rspec (3.0.1) - rubocop (~> 1.61) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) - strscan (3.1.0) - thor (1.3.1) + thor (1.5.0) threaded (0.0.4) - unicode-display_width (2.5.0) - webrick (1.8.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webrick (1.9.2) PLATFORMS + arm64-darwin-24 ruby + x86_64-linux DEPENDENCIES heroku_hatchet @@ -83,8 +91,47 @@ DEPENDENCIES rubocop rubocop-rspec +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bundler (4.0.13) sha256=19f08be7f27022cf0b89f27da0b044ae075e8270a9ef44ad248a932614e1ca3b + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + erubis (2.7.0) sha256=63653f5174a7997f6f1d6f465fbe1494dcc4bdab1fb8e635f6216989fb1148ba + excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 + heroics (0.1.3) sha256=31d792e8706ecc6f78299f52d0b0f8ab55489a13b5f4f76c3050b07912563562 + heroku_hatchet (8.0.6) sha256=886a7d7d686859db9fac19df1194a35c5349285956ca4d134054c5c376accea3 + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + moneta (1.0.0) sha256=2224e5a68156e8eceb525fb0582c8c4e0f29f67cae86507cdcfb406abbb1fc5d + multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parallel_split_test (0.10.0) sha256=4abd6bedc6a1b169ecb93a1bf982fa7fbd437b66c360594d56d107c25f53b05f + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + platform-api (3.9.0) sha256=be3b919955c52649fd931a9f62d571bd6f06613de630970dc7255fc95eb7d962 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rate_throttle_client (0.1.2) sha256=f9de968b892fea9272154f6182b4f5cfb74292585e66763fb8a8510181ec83ee + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + rrrretry (1.0.0) sha256=3c60784501701a49d8ad499af7e76dbddf9a8be916beffe885bd0f443ad1c749 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-retry (0.6.2) sha256=6101ba23a38809811ae3484acde4ab481c54d846ac66d5037ccb40131a60d858 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.88.1) sha256=726af773d6bc169ed3ff852f3ca020b7c58d39c34e7a8d879a8e0147cc994f26 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + threaded (0.0.4) sha256=8f32c11d29f7c7237aabeb36d286c15ac495137ce06ace3357de743aafd4f0e3 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131 + RUBY VERSION - ruby 3.3.2p78 + ruby 4.0.5 BUNDLED WITH - 2.5.11 + 4.0.13 diff --git a/Makefile b/Makefile index 3e36a4eb4..f1afaf93d 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ # These targets are not files -.PHONY: lint lint-scripts lint-ruby run publish +.PHONY: lint lint-scripts lint-ruby check-format format run publish -STACK ?= heroku-24 +STACK ?= heroku-26 FIXTURE ?= spec/fixtures/python_version_unspecified +# Allow overriding the exit code in CI, so we can test bin/report works for failing builds. +COMPILE_FAILURE_EXIT_CODE ?= 1 # Converts a stack name of `heroku-NN` to its build Docker image tag of `heroku/heroku:NN-build`. STACK_IMAGE_TAG := heroku/$(subst -,:,$(STACK))-build -lint: lint-scripts lint-ruby +lint: lint-scripts check-format lint-ruby lint-scripts: @git ls-files -z --cached --others --exclude-standard 'bin/*' '*/bin/*' '*.sh' | xargs -0 shellcheck --check-sourced --color=always @@ -15,17 +17,31 @@ lint-scripts: lint-ruby: @bundle exec rubocop +check-format: + @shfmt --diff . + +format: + @shfmt --write --list . + run: @echo "Running buildpack using: STACK=$(STACK) FIXTURE=$(FIXTURE)" - @docker run --rm -it -v $(PWD):/src:ro --tmpfs /app -e "HOME=/app" -e "STACK=$(STACK)" "$(STACK_IMAGE_TAG)" \ - bash -eo pipefail -c '\ - mkdir /tmp/buildpack /tmp/build /tmp/cache /tmp/env \ - && cp -r /src/{bin,lib,requirements,vendor} /tmp/buildpack \ - && cp -rT /src/$(FIXTURE) /tmp/build \ - && cd /tmp/buildpack \ - && echo -e "\n~ Detect:" && bash ./bin/detect /tmp/build /tmp/cache /tmp/env \ - && echo -e "\n~ Compile:" && { ./bin/compile /tmp/build /tmp/cache /tmp/env || true; } \ - && echo -e "\n~ Report:" && ./bin/report /tmp/build /tmp/cache /tmp/env \ + @docker run --rm -v $(PWD):/src:ro --tmpfs /app:mode=1777 -e "HOME=/app" -e "STACK=$(STACK)" "$(STACK_IMAGE_TAG)" \ + bash -euo pipefail -O dotglob -c '\ + mkdir /tmp/buildpack /tmp/cache /tmp/env; \ + cp -r /src/{bin,lib,requirements,vendor} /tmp/buildpack; \ + cp -r /src/$(FIXTURE) /tmp/build_1; \ + cd /tmp/buildpack; \ + unset $$(printenv | cut -d '=' -f 1 | grep -vE "^(HOME|LANG|PATH|STACK)$$"); \ + echo -en "\n~ Detect: " && ./bin/detect /tmp/build_1; \ + echo -e "\n~ Compile:" && { ./bin/compile /tmp/build_1 /tmp/cache /tmp/env || COMPILE_FAILED=1; }; \ + echo -e "\n~ Report:" && ./bin/report /tmp/build_1 /tmp/cache /tmp/env; \ + [[ "$${COMPILE_FAILED:-}" == "1" ]] && exit $(COMPILE_FAILURE_EXIT_CODE); \ + [[ -f /tmp/build_1/bin/compile ]] && { echo -e "\n~ Compile (Inline Buildpack):" && (source ./export && /tmp/build_1/bin/compile /tmp/build_1 /tmp/cache /tmp/env); }; \ + echo -e "\n~ Release:" && ./bin/release /tmp/build_1; \ + rm -rf /app/* /tmp/buildpack/export /tmp/build_1; \ + cp -r /src/$(FIXTURE) /tmp/build_2; \ + echo -e "\n~ Recompile:" && ./bin/compile /tmp/build_2 /tmp/cache /tmp/env; \ + echo -e "\nBuild successful!"; \ ' @echo diff --git a/README.md b/README.md index 32dc35a58..4f8703ea4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![python](https://cloud.githubusercontent.com/assets/51578/13712821/b68a42ce-e793-11e5-96b0-d8eb978137ba.png) +![python](https://raw.githubusercontent.com/heroku/buildpacks/refs/heads/main/assets/images/buildpack-banner-python.png) # Heroku Buildpack: Python @@ -8,60 +8,57 @@ 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.4 - -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.12.4` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.11.9` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.10.14` on all [supported stacks](https://devcenter.heroku.com/articles/stack#stack-support-details) -- `python-3.9.19` on Heroku-20 and Heroku-22 only -- `python-3.8.19` 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`, `Pipfile.lock`, `poetry.lock`, or `uv.lock` file must be present in the root (top-level) +directory of your app's source code. + +When using the package manager [uv](https://docs.astral.sh/uv/) a `.python-version` file is also required. + +## 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.14, create a `.python-version` file in +the root directory of your app containing: +`3.14` + +We strongly recommend that you use the major version form instead of pinning to an exact version, +since it will allow your app to receive Python security updates. + +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.14 + +The supported Python versions are: + +- Python 3.14 +- Python 3.13 +- Python 3.12 +- Python 3.11 + +These Python versions are deprecated on Heroku: + +- Python 3.10 + +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/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..72541d6bd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security + +Please report any security issue to [https://www.sfdc.co/SubmitVuln](https://www.sfdc.co/SubmitVuln) +as soon as it is discovered. This project limits its runtime dependencies in +order to reduce the total cost of ownership as much as can be, but all consumers +should remain vigilant and have their security stakeholders review all third-party +products (3PP) like this one and their dependencies. diff --git a/bin/compile b/bin/compile index f9c219c0c..7017cabf3 100755 --- a/bin/compile +++ b/bin/compile @@ -2,9 +2,15 @@ # Usage: bin/compile # See: https://devcenter.heroku.com/articles/buildpack-api -set -eo pipefail - -[[ -n "$BUILDPACK_XTRACE" ]] && set -o xtrace +# We use `errtrace` and `inherit_errexit` to ensure the ERR trap and exit on error behaviour +# propagates to buildpack functions run in subshells, such as when using command substitutions. +set -eu -o pipefail -o errtrace +shopt -s inherit_errexit + +# Note: This can't be enabled via app config vars, since at this point they haven't been loaded from ENV_DIR. +if [[ "${BUILDPACK_XTRACE:-0}" == "1" ]]; then + set -o xtrace +fi BUILD_DIR="${1}" CACHE_DIR="${2}" @@ -14,37 +20,35 @@ ENV_DIR="${3}" BUILDPACK_DIR=$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd) source "${BUILDPACK_DIR}/bin/utils" -source "${BUILDPACK_DIR}/lib/metadata.sh" - -compile_start_time=$(nowms) - -# Initialise metadata store. -meta_init "${CACHE_DIR}" "python" -meta_setup - -# Prepend proper path for old-school virtualenv hackery. -# This may not be necessary. -export PATH=:/usr/local/bin:$PATH +source "${BUILDPACK_DIR}/lib/utils.sh" +source "${BUILDPACK_DIR}/lib/build_data.sh" +source "${BUILDPACK_DIR}/lib/cache.sh" +source "${BUILDPACK_DIR}/lib/checks.sh" +source "${BUILDPACK_DIR}/lib/hooks.sh" +source "${BUILDPACK_DIR}/lib/output.sh" +source "${BUILDPACK_DIR}/lib/package_manager.sh" +source "${BUILDPACK_DIR}/lib/pip.sh" +source "${BUILDPACK_DIR}/lib/pipenv.sh" +source "${BUILDPACK_DIR}/lib/poetry.sh" +source "${BUILDPACK_DIR}/lib/python_version.sh" +source "${BUILDPACK_DIR}/lib/python.sh" +source "${BUILDPACK_DIR}/lib/uv.sh" + +compile_start_time=$(build_data::current_unix_realtime) + +# Initialise the build data store used to track state across builds (for cache invalidation +# and messaging when build configuration changes) and also so that `bin/report` can generate +# a build report that will be consumed by the build system for observability. +build_data::setup + +trap 'utils::err_trap' ERR + +checks::ensure_supported_stack "${STACK:?"Required env var STACK isn't set"}" +checks::duplicate_python_buildpack "${BUILD_DIR}" # Exported for use in subshells, such as the steps run via sub_env. export BUILD_DIR CACHE_DIR ENV_DIR -# Set the base URL for downloading buildpack assets like Python runtimes. -# The user can provide BUILDPACK_S3_BASE_URL to specify a custom target. -# Note: this is designed for non-Heroku use, as it does not use the user-provided -# environment variable mechanism (the ENV_DIR). -S3_BASE_URL="${BUILDPACK_S3_BASE_URL:-"https://heroku-buildpack-python.s3.us-east-1.amazonaws.com"}" -# This has to be exported since it's used by the geo-libs step which is run in a subshell. - -# Default Python Versions -source "${BUILDPACK_DIR}/bin/default_pythons" - -# Common Problem Warnings: -# This section creates a temporary file in which to stick the output of `pip install`. -# The `warnings` subscript then greps through this for common problems and guides -# the user towards resolution of known issues. -WARNINGS_LOG=$(mktemp) - # Sanitize externally-provided environment variables: # The following environment variables are either problematic or simply unnecessary # for the buildpack to have knowledge of, so we unset them, to keep the environment @@ -52,7 +56,6 @@ WARNINGS_LOG=$(mktemp) unset PYTHONHOME PYTHONPATH # Import the warnings script, which contains the `pip install` user warning mechanisms -# (mentioned and explained above) source "${BUILDPACK_DIR}/bin/warnings" # Make the directory in which we will create symlinks from the temporary build directory @@ -61,268 +64,244 @@ source "${BUILDPACK_DIR}/bin/warnings" # More on this topic later. mkdir -p /app/.heroku -PROFILE_PATH="$BUILD_DIR/.profile.d/python.sh" +PROFILE_PATH="${BUILD_DIR}/.profile.d/python.sh" EXPORT_PATH="${BUILDPACK_DIR}/export" -GUNICORN_PROFILE_PATH="$BUILD_DIR/.profile.d/python.gunicorn.sh" -WEB_CONCURRENCY_PROFILE_PATH="$BUILD_DIR/.profile.d/WEB_CONCURRENCY.sh" - -export PATH=/app/.heroku/python/bin:/app/.heroku/vendor/bin:$PATH -# Tell Python to not buffer it's stdin/stdout. +GUNICORN_PROFILE_PATH="${BUILD_DIR}/.profile.d/python.gunicorn.sh" +WEB_CONCURRENCY_PROFILE_PATH="${BUILD_DIR}/.profile.d/WEB_CONCURRENCY.sh" +python_home='/app/.heroku/python' + +# NB: Python must be added to PATH using the symlinked `/app` location and not its actual location +# in BUILD_DIR, so that Python reports its location (via `sys.prefix`, `sys.executable` and others) +# using `/app` paths which will still work at run-time after relocation. Amongst other things, this +# ensures that the shebang lines in the entrypoint scripts of installed packages are correct. +export PATH="/app/.heroku/python/bin:${PATH}" +# Disable Python's output buffering to ensure logs aren't dropped if an app crashes. export PYTHONUNBUFFERED=1 -# Set the locale to a well-known and expected standard. -export LANG=en_US.UTF-8 -# `~/.heroku/vendor` is an place where the buildpack may stick pre-build binaries for known -# C dependencies. This section configures Python (GCC, more specifically) -# and pip to automatically include these paths when building binaries. -# TODO: Stop adding .heroku/vendor here now that the buildpack no longer vendors anything. -export C_INCLUDE_PATH=/app/.heroku/vendor/include:/app/.heroku/python/include:$C_INCLUDE_PATH -export CPLUS_INCLUDE_PATH=/app/.heroku/vendor/include:/app/.heroku/python/include:$CPLUS_INCLUDE_PATH -export LIBRARY_PATH=/app/.heroku/vendor/lib:/app/.heroku/python/lib:$LIBRARY_PATH -export LD_LIBRARY_PATH=/app/.heroku/vendor/lib:/app/.heroku/python/lib:$LD_LIBRARY_PATH -export PKG_CONFIG_PATH=/app/.heroku/vendor/lib/pkg-config:/app/.heroku/python/lib/pkg-config:$PKG_CONFIG_PATH - -# Global pip options (https://pip.pypa.io/en/stable/user_guide/#environment-variables). -# Disable pip's warnings about EOL Python since we show our own. -export PIP_NO_PYTHON_VERSION_WARNING=1 - -cd "$BUILD_DIR" - -# The Cache -# --------- - -# The workflow for the Python Buildpack's cache is as follows: -# -# - `~/.heroku/{known-paths}` are copied from the cache into the slug. -# - The build is executed, modifying `~/.heroku/{known-paths}`. -# - Once the build is complete, `~/.heroku/{known-paths}` is copied back into the cache. - -# Create the cache directory, if it doesn't exist. -mkdir -p "$CACHE_DIR/.heroku" - -# Restore old artifacts from the cache. -mkdir -p .heroku - -# The Python installation. -cp -R "$CACHE_DIR/.heroku/python" .heroku/ &> /dev/null || true -# A plain text file which contains the current stack being used (used for cache busting). -cp -R "$CACHE_DIR/.heroku/python-stack" .heroku/ &> /dev/null || true -# A plain text file which contains the current python version being used (used for cache busting). -cp -R "$CACHE_DIR/.heroku/python-version" .heroku/ &> /dev/null || true -# A plain text file which contains the current sqlite3 version being used (used for cache busting). -cp -R "$CACHE_DIR/.heroku/python-sqlite3-version" .heroku/ &> /dev/null || true -# "editable" installations of code repositories, via pip or pipenv. -if [[ -d "$CACHE_DIR/.heroku/src" ]]; then - cp -R "$CACHE_DIR/.heroku/src" .heroku/ &> /dev/null || true -fi - -# The pre_compile hook. Customers rely on this. Don't remove it. -# This part of the code is used to allow users to customize their build experience -# without forking the buildpack by providing a `bin/pre_compile` script, which gets -# run inline with the buildpack automatically. -source "${BUILDPACK_DIR}/bin/steps/hooks/pre_compile" - -# Sticky runtimes. If there was a previous build, and it used a given version of Python, -# continue to use that version of Python in perpetuity. -if [[ -f "$CACHE_DIR/.heroku/python-version" ]]; then - CACHED_PYTHON_VERSION=$(cat "$CACHE_DIR/.heroku/python-version") -fi - -# We didn't always record the stack version. This code is in place because of that. -if [[ -f "$CACHE_DIR/.heroku/python-stack" ]]; then - CACHED_PYTHON_STACK=$(cat "$CACHE_DIR/.heroku/python-stack") +# Ensure Python uses a Unicode locale, to prevent the issues described in: +# https://github.com/docker-library/python/pull/570 +export LANG="en_US.UTF-8" +export C_INCLUDE_PATH="/app/.heroku/python/include${C_INCLUDE_PATH:+:${C_INCLUDE_PATH}}" +export CPLUS_INCLUDE_PATH="/app/.heroku/python/include${CPLUS_INCLUDE_PATH:+:${CPLUS_INCLUDE_PATH}}" +export LIBRARY_PATH="/app/.heroku/python/lib${LIBRARY_PATH:+:${LIBRARY_PATH}}" +export LD_LIBRARY_PATH="/app/.heroku/python/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" +export PKG_CONFIG_PATH="/app/.heroku/python/lib/pkg-config${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" + +cd "${BUILD_DIR}" + +# Runs a `bin/pre_compile` script if found in the app source, allowing build customisation. +hooks::run_hook "pre_compile" + +# These later checks are after the pre_compile hook so that we can check not only the +# original app source, but also that the hook hasn't written to these locations either. +checks::existing_python_dir_present "${BUILD_DIR}" + +package_manager="$(package_manager::determine_package_manager "${BUILD_DIR}")" +build_data::set_string "package_manager" "${package_manager}" + +# This check is after the package manager step to improve the UX when the package manager file +# is missing and the venv directory was the only reason the buildpack passed detection. +# (Since once they remove the venv detection will fail, which would prevent us from being able +# to display the more helpful "missing package manager file" error message on the next build). +checks::existing_venv_dir_present "${BUILD_DIR}" + +cached_python_full_version="$(cache::cached_python_full_version "${CACHE_DIR}")" + +# We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function +# without having to hardcode globals. See: https://stackoverflow.com/a/38997681 +python_version::read_requested_python_version "${BUILD_DIR}" "${package_manager}" "${cached_python_full_version}" requested_python_version python_version_origin +build_data::set_string "python_version_requested" "${requested_python_version}" +build_data::set_string "python_version_origin" "${python_version_origin}" + +case "${python_version_origin}" in + default) + output::step "No Python version was specified. Using the buildpack default: Python ${requested_python_version}" + ;; + cached) + output::step "No Python version was specified. Using the same major version as the last build: Python ${requested_python_version}" + ;; + *) + output::step "Using Python ${requested_python_version} specified in ${python_version_origin}" + ;; +esac + +python_full_version="$(python_version::resolve_python_version "${requested_python_version}" "${python_version_origin}")" +python_major_version="${python_full_version%.*}" +build_data::set_string "python_version" "${python_full_version}" +build_data::set_string "python_version_major" "${python_major_version}" + +if [[ "${requested_python_version}" == "${python_full_version}" ]]; then + build_data::set_raw "python_version_pinned" "true" else - CACHED_PYTHON_STACK=$STACK + build_data::set_raw "python_version_pinned" "false" fi -# TODO: Move this into a new package manager handling implementation when adding Poetry support. -# We intentionally don't mention `setup.py` here since it's being removed soon. -if [[ ! -f requirements.txt && ! -f Pipfile && ! -f setup.py ]]; then - puts-warn - puts-warn "Error: Couldn't find any supported Python package manager files." - puts-warn - puts-warn "A Python app on Heroku must have either a 'requirements.txt' or" - puts-warn "'Pipfile' package manager file in the root directory of its" - puts-warn "source code." - puts-warn - puts-warn "Currently the root directory of your app contains:" - puts-warn - # TODO: Overhaul logging helpers so they can handle prefixing multi-line strings, and switch to them. - # shellcheck disable=SC2012 # Using `ls` instead of `find` is absolutely fine for this use case. - ls -1 --indicator-style=slash "${BUILD_DIR}" | sed 's/^/ ! /' - puts-warn - puts-warn "If your app already has a package manager file, check that it:" - puts-warn - puts-warn "1. Is in the top level directory (not a subdirectory)." - puts-warn "2. Has the correct spelling (the filenames are case-sensitive)." - puts-warn "3. Isn't listed in '.gitignore' or '.slugignore'." - puts-warn - puts-warn "Otherwise, add a package manager file to your app. If your app has" - puts-warn "no dependencies, then create an empty 'requirements.txt' file." - puts-warn - puts-warn "For help with using Python on Heroku, see:" - puts-warn "https://devcenter.heroku.com/articles/getting-started-with-python" - puts-warn "https://devcenter.heroku.com/articles/python-support" - puts-warn - meta_set "failure_reason" "package-manager-not-found" - exit 1 -fi +# We wait until after Python version resolution to show these warnings, to avoid causing confusion +# as to what was a warning vs an error. In addition, several of the error messages contain similar +# content to the warnings (such as encouraging use of .python-version and major version syntax), +# which would mean duplicate content if we showed both. +python_version::warn_or_error_if_python_version_file_missing "${python_version_origin}" "${python_major_version}" "${package_manager}" +python_version::warn_if_deprecated_major_version "${python_major_version}" "${python_version_origin}" +python_version::warn_if_patch_update_available "${python_full_version}" "${python_major_version}" "${python_version_origin}" -# Pipenv Python version support. -# Detect the version of Python requested from a Pipfile (e.g. python_version or python_full_version). -# Convert it to a runtime.txt file. -source "${BUILDPACK_DIR}/bin/steps/pipenv-python-version" - -if [[ -f runtime.txt ]]; then - # PYTHON_VERSION_SOURCE may have already been set by the pipenv-python-version step. - # TODO: Refactor this and stop pipenv-python-version using runtime.txt as an API. - PYTHON_VERSION_SOURCE=${PYTHON_VERSION_SOURCE:-"runtime.txt"} - puts-step "Using Python version specified in ${PYTHON_VERSION_SOURCE}" - meta_set "python_version_reason" "specified" -elif [[ -n "${CACHED_PYTHON_VERSION:-}" ]]; then - puts-step "No Python version was specified. Using the same version as the last build: ${CACHED_PYTHON_VERSION}" - echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes" - meta_set "python_version_reason" "cached" - echo "${CACHED_PYTHON_VERSION}" > runtime.txt -else - puts-step "No Python version was specified. Using the buildpack default: ${DEFAULT_PYTHON_VERSION}" - echo " To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes" - meta_set "python_version_reason" "default" - echo "${DEFAULT_PYTHON_VERSION}" > runtime.txt -fi +cache::restore "${BUILD_DIR}" "${CACHE_DIR}" "${STACK}" "${cached_python_full_version}" "${python_full_version}" "${package_manager}" -# Create the directory for .profile.d, if it doesn't exist. -mkdir -p "$(dirname "$PROFILE_PATH")" -# Create the directory for editable source code installation, if it doesn't exist. -mkdir -p /app/.heroku/src +# The directory for the .profile.d scripts. +mkdir -p "$(dirname "${PROFILE_PATH}")" # On Heroku CI, builds happen in `/app`. Otherwise, on the Heroku platform, # they occur in a temp directory. Because Python is not portable, we must create # symlinks to emulate that we are operating in `/app` during the build process. # This is (hopefully obviously) because apps end up running from `/app` in production. # Realpath is used to support use-cases where one of the locations is a symlink to the other. +# shellcheck disable=SC2312 # TODO: Invoke this command separately to avoid masking its return value. if [[ "$(realpath "${BUILD_DIR}")" != "$(realpath /app)" ]]; then - # python expects to reside in /app, so set up symlinks - # we will not remove these later so subsequent buildpacks can still invoke it - ln -nsf "$BUILD_DIR/.heroku/python" /app/.heroku/python - ln -nsf "$BUILD_DIR/.heroku/vendor" /app/.heroku/vendor - # Note: .heroku/src is copied in later. -fi - -# Download / Install Python, from pre-build binaries available on Amazon S3. -# This step also bootstraps pip / setuptools. -install_python_start_time=$(nowms) -source "${BUILDPACK_DIR}/bin/steps/python" -meta_time "python_install_duration" "${install_python_start_time}" - -# Install Pipenv dependencies, if a Pipfile was provided. -source "${BUILDPACK_DIR}/bin/steps/pipenv" - -# If no requirements.txt file given, assume `setup.py develop` is intended. -# This allows for people to ship a setup.py application to Heroku - -if [[ ! -f requirements.txt ]] && [[ ! -f Pipfile ]]; then - meta_set "setup_py_only" "true" - echo "-e ." > requirements.txt -else - meta_set "setup_py_only" "false" + # python expects to reside in /app, so set up symlinks + # we will not remove these later so subsequent buildpacks can still invoke it + ln -nsf "${BUILD_DIR}/.heroku/python" /app/.heroku/python fi -# SQLite3 support. -# This sets up and installs sqlite3 dev headers and the sqlite3 binary but not the -# libsqlite3-0 library since that exists on the stack image. -install_sqlite_start_time=$(nowms) -source "${BUILDPACK_DIR}/bin/steps/sqlite3" -buildpack_sqlite3_install -meta_time "sqlite_install_duration" "${install_sqlite_start_time}" - -# pip install -# ----------- - -# Install dependencies with pip (where the magic happens). -source "${BUILDPACK_DIR}/bin/steps/pip-install" +python::install "${BUILD_DIR}" "${STACK}" "${python_full_version}" "${python_major_version}" "${python_version_origin}" + +# Install the package manager and related tools. +package_manager_install_start_time=$(build_data::current_unix_realtime) +case "${package_manager}" in + pip) + pip::install_pip "${python_home}" "${python_major_version}" "${EXPORT_PATH}" "${PROFILE_PATH}" + ;; + pipenv) + pipenv::install_pipenv "${python_home}" "${python_major_version}" "${EXPORT_PATH}" "${PROFILE_PATH}" + ;; + poetry) + poetry::install_poetry "${python_home}" "${python_major_version}" "${CACHE_DIR}" "${EXPORT_PATH}" + ;; + uv) + uv::install_uv "${CACHE_DIR}" "${EXPORT_PATH}" "${python_home}" + ;; + *) + utils::abort_internal_error "Unhandled package manager: ${package_manager}" + ;; +esac +build_data::set_duration "package_manager_install_duration" "${package_manager_install_start_time}" + +# Install app dependencies. +dependencies_install_start_time=$(build_data::current_unix_realtime) +case "${package_manager}" in + pip) + pip::install_dependencies + ;; + pipenv) + pipenv::install_dependencies + ;; + poetry) + poetry::install_dependencies + ;; + uv) + uv::install_dependencies + ;; + *) + utils::abort_internal_error "Unhandled package manager: ${package_manager}" + ;; +esac +build_data::set_duration "dependencies_install_duration" "${dependencies_install_start_time}" # Support for NLTK corpora. -nltk_downloader_start_time=$(nowms) -sub_env "${BUILDPACK_DIR}/bin/steps/nltk" -meta_time "nltk_downloader_duration" "${nltk_downloader_start_time}" - -# Support for editable installations. Here, we are copying pip–created src directory, -# and copying it into the proper place (the logical place to do this was early, but it must be done here). -# In CI, $BUILD_DIR is /app. -# Realpath is used to support use-cases where one of the locations is a symlink to the other. -if [[ "$(realpath "${BUILD_DIR}")" != "$(realpath /app)" ]]; then - rm -rf "$BUILD_DIR/.heroku/src" - deep-cp /app/.heroku/src "$BUILD_DIR/.heroku/src" -fi - +nltk_downloader_start_time=$(build_data::current_unix_realtime) +# TODO: Migrate this script to functions under `lib/` and stop running it in a subshell. +"${BUILDPACK_DIR}/bin/steps/nltk" +build_data::set_duration "nltk_downloader_duration" "${nltk_downloader_start_time}" # Django collectstatic support. # The buildpack automatically runs collectstatic for Django applications. -# This is the cause for the majority of build failures on the Python platform. -# These failures are intentional — if collectstatic (which can be tricky, at times) fails, -# your build fails. -collectstatic_start_time=$(nowms) -sub_env "${BUILDPACK_DIR}/bin/steps/collectstatic" -meta_time "django_collectstatic_duration" "${collectstatic_start_time}" - +collectstatic_start_time=$(build_data::current_unix_realtime) +# TODO: Migrate this script to functions under `lib/` and stop running it in a subshell. +"${BUILDPACK_DIR}/bin/steps/collectstatic" +build_data::set_duration "django_collectstatic_duration" "${collectstatic_start_time}" # Programmatically create .profile.d script for application runtime environment variables. # Set the PATH to include Python / pip / pipenv / etc. -set_env PATH "\$HOME/.heroku/python/bin:\$PATH" +set_env PATH "\${HOME}/.heroku/python/bin:\${PATH}" # Tell Python to run in unbuffered mode. set_env PYTHONUNBUFFERED true # Tell Python where it lives. -set_env PYTHONHOME "\$HOME/.heroku/python" +set_env PYTHONHOME "\${HOME}/.heroku/python" # Set variables for C libraries. -set_env LIBRARY_PATH "\$HOME/.heroku/vendor/lib:\$HOME/.heroku/python/lib:\$LIBRARY_PATH" -set_env LD_LIBRARY_PATH "\$HOME/.heroku/vendor/lib:\$HOME/.heroku/python/lib:\$LD_LIBRARY_PATH" +set_env LIBRARY_PATH "\${HOME}/.heroku/python/lib\${LIBRARY_PATH:+:\${LIBRARY_PATH}}" +set_env LD_LIBRARY_PATH "\${HOME}/.heroku/python/lib\${LD_LIBRARY_PATH:+:\${LD_LIBRARY_PATH}}" # Locale. set_default_env LANG en_US.UTF-8 -# The Python hash seed is set to random. -set_default_env PYTHONHASHSEED random # Tell Python to look for Python modules in the /app dir. Don't change this. -set_default_env PYTHONPATH "\$HOME" +set_default_env PYTHONPATH "\${HOME}" # Python expects to be in /app, if at runtime, it is not, set # up symlinks… this can occur when the subdir buildpack is used. -cat <> "$PROFILE_PATH" +cat <>"${PROFILE_PATH}" if [[ \$HOME != "/app" ]]; then mkdir -p /app/.heroku ln -nsf "\$HOME/.heroku/python" /app/.heroku/python - ln -nsf "\$HOME/.heroku/vendor" /app/.heroku/vendor fi EOT -# At runtime, rewrite paths in editable package .egg-link, .pth and finder files from the build time paths -# (such as `/tmp/build_`) back to `/app`. This is not done during the build itself, since later -# buildpacks still need the build time paths. +# When dependencies are installed in editable mode, the package manager/build backend creates `.pth` +# (and related) files in site-packages, which contain absolute path references to the actual location +# of the packages. By default the Heroku build runs from a directory like `/tmp/build_`, which +# changes every build and also differs from the app location at runtime (`/app`). This means any build +# directory paths referenced in .pth and related files will no longer exist at runtime or during cached +# rebuilds, unless we rewrite the paths. +# +# Ideally, we would be able to rewrite all paths to use the `/app/.heroku/python/` symlink trick we use +# when invoking Python, since then the same path would work across the current build, runtime and cached +# rebuilds. However, this trick only works for paths under that directory (since it's not possible to +# symlink `/app` or other directories we don't own), and when apps use path-based editable dependencies +# the paths will be outside of that (such as a subdirectory of the app source, or even the root of the +# build directory). We also can't just rewrite all paths now ready for runtime, since other buildpacks +# might run after this one that make use of the editable dependencies. As such, we have to perform path +# rewriting for path-based editable dependencies at app boot instead. +# +# For VCS editable dependencies, we can use the symlink trick and so configure the repo checkout location +# as `/app/.heroku/python/src/`, which in theory should mean the `.pth` files use that path. However, +# some build backends (such as setuptools' PEP660 implementation) call realpath on it causing the +# `/tmp/build_*` location to be written instead, meaning VCS src paths need to be rewritten regardless. +# +# In addition to ensuring dependencies work for subsequent buildpacks and at runtime, they must also +# work for cached rebuilds. Most package managers will reinstall editable dependencies regardless on +# the next install, which means we can avoid having to rewrite paths on cache restore from the old build +# directory to the new location (`/tmp/build_`). However, Pipenv has a bug when using +# PEP660 style editable VCS dependencies where it won't reinstall if it's missing (or in our case, the +# path has changed), which means we must make sure that VCS src paths stored in the cache do use the +# symlink path. See: https://github.com/pypa/pipenv/issues/6348 +# +# As such, we have to perform two rewrites: +# 1. At build time, of just the VCS editable paths (which we can safely change to /app paths early). +# 2. At runtime, to rewrite the remaining path-based editable dependency paths. if [[ "${BUILD_DIR}" != "/app" ]]; then - cat <> "$PROFILE_PATH" -find .heroku/python/lib/python*/site-packages/ -type f -and \( -name '*.egg-link' -or -name '*.pth' -or -name '__editable___*_finder.py' \) -exec sed -i -e 's#${BUILD_DIR}#/app#' {} \+ -EOT + find .heroku/python/lib/python*/site-packages/ -type f -and \( -name '*.egg-link' -or -name '*.pth' -or -name '__editable___*_finder.py' \) -exec sed -i -e "s#${BUILD_DIR}/.heroku/python#/app/.heroku/python#" {} \+ + cat <<-EOT >>"${PROFILE_PATH}" + find .heroku/python/lib/python*/site-packages/ -type f -and \( -name '*.egg-link' -or -name '*.pth' -or -name '__editable___*_finder.py' \) -exec sed -i -e 's#${BUILD_DIR}#/app#' {} \+ + EOT fi # Install sane-default script for $WEB_CONCURRENCY and $FORWARDED_ALLOW_IPS. -cp "${BUILDPACK_DIR}/vendor/WEB_CONCURRENCY.sh" "$WEB_CONCURRENCY_PROFILE_PATH" -cp "${BUILDPACK_DIR}/vendor/python.gunicorn.sh" "$GUNICORN_PROFILE_PATH" - -# Experimental post_compile hook. Don't remove this. -source "${BUILDPACK_DIR}/bin/steps/hooks/post_compile" - -# Store new artifacts in the cache. -rm -rf "$CACHE_DIR/.heroku/python" -rm -rf "$CACHE_DIR/.heroku/python-version" -rm -rf "$CACHE_DIR/.heroku/python-stack" -rm -rf "$CACHE_DIR/.heroku/vendor" -rm -rf "$CACHE_DIR/.heroku/src" - -mkdir -p "$CACHE_DIR/.heroku" -cp -R .heroku/python "$CACHE_DIR/.heroku/" -cp -R .heroku/python-version "$CACHE_DIR/.heroku/" -cp -R .heroku/python-stack "$CACHE_DIR/.heroku/" &> /dev/null || true -if [[ -d .heroku/src ]]; then - cp -R .heroku/src "$CACHE_DIR/.heroku/" &> /dev/null || true +cp "${BUILDPACK_DIR}/vendor/WEB_CONCURRENCY.sh" "${WEB_CONCURRENCY_PROFILE_PATH}" +cp "${BUILDPACK_DIR}/vendor/python.gunicorn.sh" "${GUNICORN_PROFILE_PATH}" + +# Runs a `bin/post_compile` script if found in the app source, allowing build customisation. +hooks::run_hook "post_compile" + +cache::save "${BUILD_DIR}" "${CACHE_DIR}" "${STACK}" "${python_full_version}" "${package_manager}" + +if [[ "${package_manager}" != "uv" ]]; then + output::notice <<-EOF + Note: We recently added support for the package manager uv: + https://devcenter.heroku.com/changelog-items/3238 + + It's now our recommended Python package manager, since it + supports lockfiles, is faster, gives more helpful error + messages, and is actively maintained by a full-time team. + + If you haven't tried it yet, we suggest you take a look! + https://docs.astral.sh/uv/ + EOF fi -meta_time "total_duration" "${compile_start_time}" +build_data::set_duration "total_duration" "${compile_start_time}" diff --git a/bin/default_pythons b/bin/default_pythons deleted file mode 100755 index 04caa85d7..000000000 --- a/bin/default_pythons +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -# Disable unused env var warning, since shellcheck doesn't take into account -# that this file is sourced. We don't want to use export since it exposes -# the env vars to subprocesses. -# shellcheck disable=2034 - -LATEST_312="python-3.12.4" -LATEST_311="python-3.11.9" -LATEST_310="python-3.10.14" -LATEST_39="python-3.9.19" -LATEST_38="python-3.8.19" -# TODO: Remove these EOL versions once pipenv-python-version is refactored. -LATEST_37="python-3.7.17" -LATEST_36="python-3.6.15" -LATEST_35="python-3.5.10" -LATEST_34="python-3.4.10" -LATEST_27="python-2.7.18" -DEFAULT_PYTHON_VERSION="${LATEST_312}" diff --git a/bin/detect b/bin/detect index d7b27069f..86da2ddc0 100755 --- a/bin/detect +++ b/bin/detect @@ -1,11 +1,17 @@ #!/usr/bin/env bash -# Usage: bin/compile +# Usage: bin/detect # See: https://devcenter.heroku.com/articles/buildpack-api set -euo pipefail +shopt -s inherit_errexit BUILD_DIR="${1}" +# The absolute path to the root of the buildpack. +BUILDPACK_DIR=$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd) + +source "${BUILDPACK_DIR}/lib/output.sh" + # Filenames that if found in a project mean it should be treated as a Python project, # and so pass this buildpack's detection phase. # @@ -13,57 +19,78 @@ BUILD_DIR="${1}" # so that Python projects that are missing some of the required files still pass detection, # allowing us to show a helpful error message during the build phase. KNOWN_PYTHON_PROJECT_FILES=( - .python-version - app.py - main.py - manage.py - pdm.lock - Pipfile - Pipfile.lock - poetry.lock - pyproject.toml - requirements.txt - runtime.txt - setup.cfg - setup.py - uv.lock + .python-version + __init__.py + app.py + main.py + manage.py + pdm.lock + Pipfile + Pipfile.lock + poetry.lock + pyproject.toml + requirements.txt + runtime.txt + server.py + setup.cfg + setup.py + uv.lock + # Commonly seen misspellings of requirements.txt. (Which occur since pip doesn't + # create/manage requirements files itself, so the filenames are manually typed.) + requeriments.txt + requirement.txt + requirements + requirements.text + Requirements.txt + requirements.txt.txt + requirments.txt + # Whilst the pyc cache and virtual environments shouldn't be committed to Git (and so + # shouldn't normally be present during the build), they are often present for beginner + # Python apps that are missing all of the other Python related files above. + __pycache__/ + .venv/ + venv/ ) -for filename in "${KNOWN_PYTHON_PROJECT_FILES[@]}"; do - if [[ -f "${BUILD_DIR}/${filename}" ]]; then - echo "Python" - exit 0 - fi +for filepath in "${KNOWN_PYTHON_PROJECT_FILES[@]}"; do + # Using -e since we need to check for both files and directories. + if [[ -e "${BUILD_DIR}/${filepath}" ]]; then + echo "Python" + exit 0 + fi done -# Cytokine incorrectly indents the first line, so we have to leave it empty. -echo 1>&2 - # Note: This error message intentionally doesn't list all of the filetypes above, # since during compile the build will still require a package manager file, so it -# makes sense to describe the stricter requirements up front. -# TODO: Overhaul logging helpers so they can handle prefixing multi-line strings, and switch to them. -sed 's/^/ ! /' 1>&2 << EOF +# makes sense to describe the stricter requirements upfront. +output::error < -# Produces a build report containing metadata about the build, that's consumed by the build system. -# This script is run for both successful and failing builds, so it should not assume the build ran -# to completion (e.g. Python or other tools may not even have been installed). +# Produces a build report emitted to stdout, containing metadata about the build, that's +# consumed by the build system. See the pip_spec.rb for an example build report payload. # -# Metadata must be emitted to stdout as valid YAML key-value pairs. Any fields that should always -# be typed as a string must be explicitly quoted. -# -# Example valid stdout: -# python_version: 'X.Y.Z' -# python_install_duration: 1.234 +# This script is run for both successful and failing builds, so it should not assume the +# build ran to completion (e.g. Python or other tools may not even have been installed). # # Failures in this script don't cause the overall build to fail (and won't appear in user # facing build logs) to avoid breaking builds unnecessarily / causing confusion. To debug # issues check the internal build system logs for `buildpack.report.failed` events, or -# when developing run `make compile` in this repo locally, which runs `bin/report` too. +# when developing run `make run` in this repo locally, which runs `bin/report` too. +# +# Note: The build system doesn't source the `export` script before running this script, +# so Python/the package manager won't be on PATH by default. set -euo pipefail +shopt -s inherit_errexit CACHE_DIR="${2}" # The absolute path to the root of the buildpack. BUILDPACK_DIR=$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd) -# The build system doesn't source the `export` script before running this script, so we have to do -# so manually (if it exists) so that buildpack Python/Pip can be found (if the build succeeded). -# We have to disable Bash error on undefined variables for now, since not all env vars used in the -# export script will be set by default (eg `LIBRARY_PATH`). -EXPORT_FILE="${BUILDPACK_DIR}/export" -if [[ -f "${EXPORT_FILE}" ]]; then - set +u - # shellcheck source=/dev/null - source "${EXPORT_FILE}" - set -u -fi - -source "${BUILDPACK_DIR}/lib/metadata.sh" -meta_init "${CACHE_DIR}" "python" - -# Emit the key / value pair unquoted to stdout. Skips if the value is empty. -# Based on: https://github.com/heroku/heroku-buildpack-nodejs/blob/main/bin/report -kv_pair() { - local key="${1}" - local value="${2}" - if [[ -n "${value}" ]]; then - echo "${key}: ${value}" - fi -} - -# Emit the key / value pair to stdout, safely quoting the string. Skips if the value is empty. -# Based on: https://github.com/heroku/heroku-buildpack-nodejs/blob/main/bin/report -# (Though instead uses single quotes instead of double to avoid escaping issues.) -kv_pair_string() { - local key="${1}" - local value="${2}" - if [[ -n "${value}" ]]; then - # Escape any existing single quotes, which for YAML means replacing `'` with `''`. - value="${value//\'/\'\'}" - echo "${key}: '${value}'" - fi -} - -STRING_FIELDS=( - django_collectstatic - failure_reason - nltk_downloader - package_manager - pip_version - pipenv_version - python_version_major - python_version_reason - python_version - setuptools_version - wheel_version -) - -# We don't want to quote numeric or boolean fields. -ALL_OTHER_FIELDS=( - dependencies_install_duration - django_collectstatic_duration - nltk_downloader_duration - pipenv_has_lockfile - post_compile_hook - post_compile_hook_duration - pre_compile_hook - pre_compile_hook_duration - python_install_duration - setup_py_only - sqlite_install_duration - total_duration -) - -for field in "${STRING_FIELDS[@]}"; do - kv_pair_string "${field}" "$(meta_get "${field}")" -done - -for field in "${ALL_OTHER_FIELDS[@]}"; do - kv_pair "${field}" "$(meta_get "${field}")" -done - -# If the build failed, pip might not have been installed yet. -if command -v pip >/dev/null; then - # Determine pysqlite3 usage since it's the only package that requires the sqlite3 headers. - if pip show pysqlite3 &>/dev/null; then - kv_pair pysqlite3_installed true - else - kv_pair pysqlite3_installed false - fi +source "${BUILDPACK_DIR}/lib/build_data.sh" - if pip show pysqlite3-binary &>/dev/null; then - kv_pair pysqlite3_binary_installed true - else - kv_pair pysqlite3_binary_installed false - fi -fi +build_data::print_bin_report_json diff --git a/bin/steps/collectstatic b/bin/steps/collectstatic index 5731408a4..0abed5a56 100755 --- a/bin/steps/collectstatic +++ b/bin/steps/collectstatic @@ -4,39 +4,48 @@ # automatically be executed as part of the build process. If collectstatic # fails, your build fails. -# This functionality will only activate if Django is in requirements.txt. +# This functionality will only activate if Django is installed. # Runtime arguments: # - $DISABLE_COLLECTSTATIC: disables this functionality. # - $DEBUG_COLLECTSTATIC: upon failure, print out environment variables. -# This script is run in a subshell via sub_env so doesn't inherit the options/vars/utils from `bin/compile`. -# TODO: Stop running this script in a subshell. -set -eo pipefail +# This script is run in a subshell so doesn't inherit the options/vars/utils from `bin/compile`. +# TODO: Migrate this script to functions under `lib/` and stop running the entire script in a subshell, +# and instead only the parts that need the user-provided config vars loaded. +set -euo pipefail BUILDPACK_DIR=$(cd "$(dirname "$(dirname "$(dirname "${BASH_SOURCE[0]}")")")" && pwd) source "${BUILDPACK_DIR}/bin/utils" +source "${BUILDPACK_DIR}/lib/build_data.sh" +source "${BUILDPACK_DIR}/lib/output.sh" -# Required for `meta_set`. -source "${BUILDPACK_DIR}/lib/metadata.sh" -# shellcheck disable=SC2153 -meta_init "${CACHE_DIR}" "python" +# This must be after the imports above, to prevent user-provided config vars from overwriting +# env vars used by the buildpack such as `CACHE_DIR`: +# https://github.com/heroku/heroku-buildpack-python/issues/972 +# TODO: Reduce the scope of this even further, as part of refactoring this file. +export_env "${ENV_DIR}" if [[ -f .heroku/collectstatic_disabled ]]; then - puts-step "Skipping Django collectstatic since the file '.heroku/collectstatic_disabled' exists." - puts-warn "This approach is deprecated, please set the env var DISABLE_COLLECTSTATIC=1 instead." - meta_set "django_collectstatic" "disabled-file" - exit 0 + output::step "Skipping Django collectstatic since the file '.heroku/collectstatic_disabled' exists." + output::warning <<-'EOF' + Warning: The .heroku/collectstatic_disabled file is deprecated. + + Please remove the file and set the env var DISABLE_COLLECTSTATIC=1 instead. + EOF + build_data::set_string "django_collectstatic" "disabled-file" + exit 0 fi if [[ "${DISABLE_COLLECTSTATIC:-0}" != "0" ]]; then - puts-step "Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set." - meta_set "django_collectstatic" "disabled-env-var" - exit 0 + output::step "Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set." + build_data::set_string "django_collectstatic" "disabled-env-var" + exit 0 fi # Ensure that Django is actually installed. +# shellcheck disable=SC2310 # TODO: This function is invoked in an 'if' condition so set -e will be disabled. if ! is_module_available 'django'; then - exit 0 + exit 0 fi # Location of 'manage.py', if it exists. @@ -44,59 +53,66 @@ MANAGE_FILE=$(find . -maxdepth 3 -type f -name 'manage.py' -printf '%d\t%P\n' | MANAGE_FILE=${MANAGE_FILE:-fakepath} if [[ ! -f "${MANAGE_FILE}" ]]; then - puts-step "Skipping Django collectstatic since no manage.py file found." - meta_set "django_collectstatic" "skipped-no-manage-py" - exit 0 + output::step "Skipping Django collectstatic since no manage.py file found." + build_data::set_string "django_collectstatic" "skipped-no-manage-py" + exit 0 fi -meta_set "django_collectstatic" "enabled" +build_data::set_string "django_collectstatic" "enabled" -puts-step "$ python $MANAGE_FILE collectstatic --noinput" +output::step "$ python ${MANAGE_FILE} collectstatic --noinput" -PYTHONPATH=${PYTHONPATH:-.} +PYTHONPATH="${PYTHONPATH:-.}" export PYTHONPATH COLLECTSTATIC_LOG=$(mktemp) set +e -python "$MANAGE_FILE" collectstatic --noinput --traceback 2>&1 | tee "$COLLECTSTATIC_LOG" | sed '/^Post-processed/d;/^Copying/d;/^$/d' | indent +python "${MANAGE_FILE}" collectstatic --noinput --traceback |& tee "${COLLECTSTATIC_LOG}" |& output::indent COLLECTSTATIC_STATUS="${PIPESTATUS[0]}" set -e echo if [[ "${COLLECTSTATIC_STATUS}" == 0 ]]; then - exit 0 + exit 0 fi # Display a warning if collectstatic failed. -if grep -q 'SyntaxError' "$COLLECTSTATIC_LOG"; then - meta_set "failure_reason" "collectstatic-syntax-error" -elif grep -q 'ImproperlyConfigured' "$COLLECTSTATIC_LOG"; then - meta_set "failure_reason" "collectstatic-improper-configuration" -elif grep -q 'The CSS file' "$COLLECTSTATIC_LOG"; then - meta_set "failure_reason" "collectstatic-fancy-references" -elif grep -q 'OSError' "$COLLECTSTATIC_LOG"; then - meta_set "failure_reason" "collectstatic-missing-file" +if grep -q 'SyntaxError' "${COLLECTSTATIC_LOG}"; then + build_data::set_string "failure_reason" "collectstatic-syntax-error" +elif grep -q 'ImproperlyConfigured' "${COLLECTSTATIC_LOG}"; then + build_data::set_string "failure_reason" "collectstatic-improper-configuration" +elif grep -q 'The CSS file' "${COLLECTSTATIC_LOG}"; then + build_data::set_string "failure_reason" "collectstatic-fancy-references" +elif grep -q 'OSError' "${COLLECTSTATIC_LOG}"; then + build_data::set_string "failure_reason" "collectstatic-missing-file" else - meta_set "failure_reason" "collectstatic-other" + build_data::set_string "failure_reason" "collectstatic-other" fi -echo " ! Error while running '$ python $MANAGE_FILE collectstatic --noinput'." -echo " See traceback above for details." -echo -echo " You may need to update application code to resolve this error." -echo " Or, you can disable collectstatic for this application:" -echo -echo " $ heroku config:set DISABLE_COLLECTSTATIC=1" -echo -echo " https://devcenter.heroku.com/articles/django-assets" +output::error <<-EOF + Error: Unable to generate Django static files. + + The 'python ${MANAGE_FILE} collectstatic --noinput' Django + management command to generate static files failed. + + See the traceback above for details. + + You may need to update application code to resolve this error. + Or, you can disable collectstatic for this application: + + $ heroku config:set DISABLE_COLLECTSTATIC=1 + + https://devcenter.heroku.com/articles/django-assets +EOF # Additionally, dump out the environment, if debug mode is on. -if [[ -n "$DEBUG_COLLECTSTATIC" ]]; then - echo - echo "****** Collectstatic environment variables:" - echo - env | indent +if [[ "${DEBUG_COLLECTSTATIC:-0}" == "1" ]]; then + echo + echo "****** Collectstatic environment variables:" + echo + # TODO: Sort the displayed env vars to make the order deterministic. + env | output::indent fi exit 1 diff --git a/bin/steps/hooks/post_compile b/bin/steps/hooks/post_compile deleted file mode 100755 index 111491ee7..000000000 --- a/bin/steps/hooks/post_compile +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -if [[ -f bin/post_compile ]]; then - post_compile_hook_start_time=$(nowms) - meta_set "post_compile_hook" "true" - echo "-----> Running post-compile hook" - chmod +x bin/post_compile - sub_env bin/post_compile - meta_time "post_compile_hook_duration" "${post_compile_hook_start_time}" -else - meta_set "post_compile_hook" "false" -fi diff --git a/bin/steps/hooks/pre_compile b/bin/steps/hooks/pre_compile deleted file mode 100755 index c22eb1102..000000000 --- a/bin/steps/hooks/pre_compile +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -if [[ -f bin/pre_compile ]]; then - pre_compile_hook_start_time=$(nowms) - meta_set "pre_compile_hook" "true" - echo "-----> Running pre-compile hook" - chmod +x bin/pre_compile - sub_env bin/pre_compile - meta_time "pre_compile_hook_duration" "${pre_compile_hook_start_time}" -else - meta_set "pre_compile_hook" "false" -fi diff --git a/bin/steps/nltk b/bin/steps/nltk index f04e60cde..67040d103 100755 --- a/bin/steps/nltk +++ b/bin/steps/nltk @@ -1,38 +1,52 @@ #!/usr/bin/env bash -# This script is run in a subshell via sub_env so doesn't inherit the options/vars/utils from `bin/compile`. -# TODO: Stop running this script in a subshell. -set -eo pipefail +# This script is run in a subshell so doesn't inherit the options/vars/utils from `bin/compile`. +# TODO: Migrate this script to functions under `lib/` and stop running the entire script in a subshell. +set -euo pipefail BUILDPACK_DIR=$(cd "$(dirname "$(dirname "$(dirname "${BASH_SOURCE[0]}")")")" && pwd) source "${BUILDPACK_DIR}/bin/utils" - -# Required for `meta_set`. -source "${BUILDPACK_DIR}/lib/metadata.sh" -# shellcheck disable=SC2153 -meta_init "${CACHE_DIR}" "python" +source "${BUILDPACK_DIR}/lib/build_data.sh" +source "${BUILDPACK_DIR}/lib/output.sh" # These are required by `set_env`. -PROFILE_PATH="${BUILD_DIR}/.profile.d/python.sh" +PROFILE_PATH="${BUILD_DIR:?}/.profile.d/python.sh" EXPORT_PATH="${BUILDPACK_DIR}/export" # Check that nltk was installed by pip, otherwise obviously not needed +# shellcheck disable=SC2310 # TODO: This function is invoked in an 'if' condition so set -e will be disabled. if is_module_available 'nltk'; then - puts-step "Downloading NLTK corpora..." - - nltk_packages_definition="$BUILD_DIR/nltk.txt" - - if [[ -f "$nltk_packages_definition" ]]; then - meta_set "nltk_downloader" "enabled" - - readarray -t nltk_packages < "$nltk_packages_definition" - puts-step "Downloading NLTK packages: ${nltk_packages[*]}" - - python -m nltk.downloader -d "$BUILD_DIR/.heroku/python/nltk_data" "${nltk_packages[@]}" | indent - set_env NLTK_DATA "/app/.heroku/python/nltk_data" - - else - meta_set "nltk_downloader" "skipped-no-nltk-file" - puts-warn "'nltk.txt' not found, not downloading any corpora" - puts-warn "Learn more: https://devcenter.heroku.com/articles/python-nltk" - fi + output::step "Downloading NLTK corpora..." + + nltk_packages_definition="${BUILD_DIR}/nltk.txt" + + if [[ -f "${nltk_packages_definition}" ]]; then + build_data::set_string "nltk_downloader" "enabled" + + readarray -t nltk_packages <"${nltk_packages_definition}" + output::step "Downloading NLTK packages: ${nltk_packages[*]}" + + # Note: We have to use the raw build directory path here and not the symlinked `/app` path, + # otherwise it will cause a false positive in NLTK v3.9.3's new Zip Slip security check, + # which doesn't handle symlinked paths correctly: https://github.com/nltk/nltk/issues/3509 + # TODO: Does this even need user-provided env vars, or can we remove the sub_env usage here? + if ! sub_env python -m nltk.downloader -d "${BUILD_DIR}/.heroku/python/nltk_data" "${nltk_packages[@]}" |& output::indent; then + output::error <<-EOF + Error: Unable to download NLTK data. + + The 'python -m nltk.downloader' command to download NLTK + data didn't exit successfully. + + See the log output above for more information. + EOF + build_data::set_string "failure_reason" "nltk-downloader" + exit 1 + fi + + # Since this will be used at runtime, we must use the symlinked `/app` path and not + # the raw build directory path. + set_env NLTK_DATA "/app/.heroku/python/nltk_data" + else + build_data::set_string "nltk_downloader" "skipped-no-nltk-file" + echo " 'nltk.txt' not found, not downloading any corpora" + fi fi diff --git a/bin/steps/pip-install b/bin/steps/pip-install deleted file mode 100755 index e180e649d..000000000 --- a/bin/steps/pip-install +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash - -if [[ -z "$SKIP_PIP_INSTALL" ]]; then - pip_install_start_time=$(nowms) - meta_set "package_manager" "pip" - - puts-step "Installing requirements with pip" - - # Set Pip env vars - # This reads certain environment variables set on the Heroku app config - # and makes them accessible to the pip install process. - # - # PIP_EXTRA_INDEX_URL allows for an alternate pypi URL to be used. - if [[ -r "$ENV_DIR/PIP_EXTRA_INDEX_URL" ]]; then - PIP_EXTRA_INDEX_URL="$(cat "$ENV_DIR/PIP_EXTRA_INDEX_URL")" - export PIP_EXTRA_INDEX_URL - fi - - set +e - - /app/.heroku/python/bin/pip install -r requirements.txt --exists-action=w --src='/app/.heroku/src' --disable-pip-version-check --no-cache-dir --progress-bar off 2>&1 | tee "$WARNINGS_LOG" | cleanup | indent - PIP_STATUS="${PIPESTATUS[0]}" - set -e - - show-warnings - - if [[ ! $PIP_STATUS -eq 0 ]]; then - meta_set "failure_reason" "pip-install" - exit 1 - fi - - cp requirements.txt .heroku/python/requirements-declared.txt - /app/.heroku/python/bin/pip freeze --disable-pip-version-check > .heroku/python/requirements-installed.txt - - # Install test dependencies, for CI. - if [[ -n "$INSTALL_TEST" ]]; then - if [[ -f requirements-test.txt ]]; then - puts-step "Installing test dependencies..." - /app/.heroku/python/bin/pip install -r requirements-test.txt --exists-action=w --src='/app/.heroku/src' --disable-pip-version-check --no-cache-dir 2>&1 | cleanup | indent - fi - fi - - meta_time "dependencies_install_duration" "${pip_install_start_time}" -fi diff --git a/bin/steps/pipenv b/bin/steps/pipenv deleted file mode 100755 index d7a190b4b..000000000 --- a/bin/steps/pipenv +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -# export CLINT_FORCE_COLOR=1 -# export PIPENV_FORCE_COLOR=1 - -# Previous versions of the buildpack used to cache the checksum of the lockfile to allow -# for skipping pipenv install if the lockfile was unchanged. However, this is not always safe -# to do (the lockfile can refer to dependencies that can change independently of the lockfile, -# for example, when using a local non-editable file dependency), so we no longer ever skip -# install, and instead defer to pipenv to determine whether install is actually a no-op. -rm -f .heroku/python/Pipfile.lock.sha256 - -if [[ -f Pipfile ]]; then - pipenv_install_start_time=$(nowms) - meta_set "package_manager" "pipenv" - - # Skip installing dependencies using pip later. - export SKIP_PIP_INSTALL=1 - - # Set Pip env vars - # This reads certain environment variables set on the Heroku app config - # and makes them accessible to the pip install process. - # - # PIP_EXTRA_INDEX_URL allows for an alternate pypi URL to be used. - if [[ -r "$ENV_DIR/PIP_EXTRA_INDEX_URL" ]]; then - PIP_EXTRA_INDEX_URL="$(cat "$ENV_DIR/PIP_EXTRA_INDEX_URL")" - export PIP_EXTRA_INDEX_URL - fi - - PIPENV_VERSION=$(get_requirement_version 'pipenv') - meta_set "pipenv_version" "${PIPENV_VERSION}" - - /app/.heroku/python/bin/pip install --quiet --disable-pip-version-check --no-cache-dir "pipenv==${PIPENV_VERSION}" - - # Install the test dependencies, for CI. - # TODO: This is currently inconsistent with the non-test path, since it assumes (but doesn't check for) a lockfile. - if [[ -n "$INSTALL_TEST" ]]; then - meta_set "pipenv_has_lockfile" "true" - puts-step "Installing test dependencies" - /app/.heroku/python/bin/pipenv install --dev --system --deploy --extra-pip-args='--src=/app/.heroku/src' 2>&1 | cleanup | indent - - # Install the dependencies. - elif [[ ! -f Pipfile.lock ]]; then - meta_set "pipenv_has_lockfile" "false" - puts-step "Installing dependencies with Pipenv ${PIPENV_VERSION}" - /app/.heroku/python/bin/pipenv install --system --skip-lock --extra-pip-args='--src=/app/.heroku/src' 2>&1 | indent - - else - meta_set "pipenv_has_lockfile" "true" - puts-step "Installing dependencies with Pipenv ${PIPENV_VERSION}" - /app/.heroku/python/bin/pipenv install --system --deploy --extra-pip-args='--src=/app/.heroku/src' 2>&1 | indent - fi - - meta_time "dependencies_install_duration" "${pipenv_install_start_time}" -fi diff --git a/bin/steps/pipenv-python-version b/bin/steps/pipenv-python-version deleted file mode 100755 index 9adba9002..000000000 --- a/bin/steps/pipenv-python-version +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash - -# Detect Python-version with Pipenv. - -if [[ -f $BUILD_DIR/Pipfile ]]; then - - if [[ ! -f $BUILD_DIR/runtime.txt ]]; then - if [[ ! -f $BUILD_DIR/Pipfile.lock ]]; then - puts-warn "No 'Pipfile.lock' found! We recommend you commit this into your repository." - fi - if [[ -f $BUILD_DIR/Pipfile.lock ]]; then - # Ignore unused env var warning since this is used by bin/compile. - # shellcheck disable=2034 - PYTHON_VERSION_SOURCE='Pipfile.lock' - set +e - PYTHON=$(jq -r '._meta.requires.python_full_version' "$BUILD_DIR/Pipfile.lock") - if [[ "$PYTHON" != "null" ]]; then - echo "python-$PYTHON" > "$BUILD_DIR/runtime.txt" - fi - set -e - - if [[ "$PYTHON" == "null" ]]; then - PYTHON=$(jq -r '._meta.requires.python_version' "$BUILD_DIR/Pipfile.lock") - case "${PYTHON}" in - 2.7) - echo "${LATEST_27}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.4) - echo "${LATEST_34}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.5) - echo "${LATEST_35}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.6) - echo "${LATEST_36}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.7) - echo "${LATEST_37}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.8) - echo "${LATEST_38}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.9) - echo "${LATEST_39}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.10) - echo "${LATEST_310}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.11) - echo "${LATEST_311}" > "${BUILD_DIR}/runtime.txt" - ;; - 3.12) - echo "${LATEST_312}" > "${BUILD_DIR}/runtime.txt" - ;; - # TODO: Make this case an error - *) ;; - esac - fi - - - fi - fi -fi diff --git a/bin/steps/python b/bin/steps/python deleted file mode 100755 index 333a9302a..000000000 --- a/bin/steps/python +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env bash - -PYTHON_VERSION=$(cat runtime.txt) -# Remove leading and trailing whitespace. Note: This implementation relies upon -# `extglob` being set, which is the case thanks to `bin/utils` being run earlier. -PYTHON_VERSION="${PYTHON_VERSION##+([[:space:]])}" -PYTHON_VERSION="${PYTHON_VERSION%%+([[:space:]])}" - -function eol_python_version_error() { - local major_version="${1}" - local eol_date="${2}" - puts-warn - puts-warn "Python ${major_version} reached upstream end-of-life on ${eol_date}, and is" - puts-warn "therefore no longer receiving security updates:" - puts-warn "https://devguide.python.org/versions/#supported-versions" - puts-warn - puts-warn "As such, it is no longer supported by this buildpack." - puts-warn - puts-warn "Please upgrade to a newer Python version." - puts-warn - puts-warn "For a list of the supported Python versions, see:" - puts-warn "https://devcenter.heroku.com/articles/python-support#supported-runtimes" - puts-warn - meta_set "failure_reason" "python-version-eol" - exit 1 -} - -# We check for EOL prior to checking if the archive exists on S3, to ensure the more specific EOL error -# message is still shown for newer stacks where the EOL Python versions might not have been built. -case "${PYTHON_VERSION}" in - python-3.7.+([0-9])) - eol_python_version_error "3.7" "June 27th, 2023" - ;; - python-3.6.+([0-9])) - eol_python_version_error "3.6" "December 23rd, 2021" - ;; - *) ;; -esac - -# The Python runtime archive filename is of form: 'python-X.Y.Z-ubuntu-22.04-amd64.tar.zst' -# The Ubuntu version is calculated from `STACK` since it's faster than calling `lsb_release`. -UBUNTU_VERSION="${STACK/heroku-}.04" -ARCH=$(dpkg --print-architecture) -PYTHON_URL="${S3_BASE_URL}/${PYTHON_VERSION}-ubuntu-${UBUNTU_VERSION}-${ARCH}.tar.zst" - -if ! curl --output /dev/null --silent --head --fail --retry 3 --retry-connrefused --connect-timeout 10 "${PYTHON_URL}"; then - puts-warn - puts-warn "Requested runtime '${PYTHON_VERSION}' is not available for this stack (${STACK})." - puts-warn - puts-warn "For a list of the supported Python versions, see:" - puts-warn "https://devcenter.heroku.com/articles/python-support#supported-runtimes" - puts-warn - meta_set "failure_reason" "python-version-not-found" - exit 1 -fi - -# TODO: Refactor Python version usage to use the non-prefixed form everywhere. -python_version_without_prefix="${PYTHON_VERSION#python-}" -meta_set "python_version" "${python_version_without_prefix}" -meta_set "python_version_major" "${python_version_without_prefix%.*}" - -function warn_if_patch_update_available() { - local requested_version="${1}" - local latest_patch_version="${2}" - # Extract the patch version component of the version strings (ie: the '5' in '3.10.5'). - local requested_patch_number="${requested_version##*.}" - local latest_patch_number="${latest_patch_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: ${latest_patch_version}" - puts-warn "See: https://devcenter.heroku.com/articles/python-runtimes" - puts-warn - fi -} - -# We wait until now to display outdated Python version warnings, since we only want to show them -# if there weren't any errors with the version to avoid adding noise to the error messages. -case "${PYTHON_VERSION}" in - python-3.12.*) - warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_312}" - ;; - python-3.11.*) - warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_311}" - ;; - python-3.10.*) - warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_310}" - ;; - python-3.9.*) - warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_39}" - ;; - python-3.8.*) - puts-warn - puts-warn "Python 3.8 will reach its upstream end-of-life in October 2024, at which" - puts-warn "point it will no longer receive security updates:" - puts-warn "https://devguide.python.org/versions/#supported-versions" - puts-warn - puts-warn "Support for Python 3.8 will be removed from this buildpack on December 4th, 2024." - puts-warn - puts-warn "Upgrade to a newer Python version as soon as possible to keep your app secure." - puts-warn "See: https://devcenter.heroku.com/articles/python-runtimes" - puts-warn - warn_if_patch_update_available "${PYTHON_VERSION}" "${LATEST_38}" - ;; - # TODO: Make this case an error, since it should be unreachable. - *) ;; -esac - -if [[ "$STACK" != "$CACHED_PYTHON_STACK" ]]; then - puts-step "Stack has changed from $CACHED_PYTHON_STACK to $STACK, clearing cache" - rm -rf .heroku/python-stack .heroku/python-version .heroku/python .heroku/vendor .heroku/python .heroku/python-sqlite3-version -fi - -if [[ -f .heroku/python-version ]]; then - if [[ ! "$(cat .heroku/python-version)" == "$PYTHON_VERSION" ]]; then - puts-step "Python version has changed from $(cat .heroku/python-version) to ${PYTHON_VERSION}, clearing cache" - rm -rf .heroku/python - else - SKIP_INSTALL=1 - fi -fi - -# If using Pip, check if we should reinstall python dependencies given that requirements.txt -# is non-deterministic (not all packages pinned, doesn't handle uninstalls etc). We don't need -# to do this when using Pipenv, since it has a lockfile and syncs the packages for us. -if [[ -f "${BUILD_DIR}/requirements.txt" ]]; then - if [[ ! -f "$CACHE_DIR/.heroku/requirements.txt" ]]; then - # This is a the first build of an app (or the build cache was cleared). Since there - # are no cached packages, we only need to store the requirements file for next time. - cp -R "$BUILD_DIR/requirements.txt" "$CACHE_DIR/.heroku/requirements.txt" - else - # IF there IS a cached directory, check for differences with the new one - if ! diff "$BUILD_DIR/requirements.txt" "$CACHE_DIR/.heroku/requirements.txt" &> /dev/null; then - puts-step "Requirements file has been changed, clearing cached dependencies" - # if there are any differences, clear the Python cache - # Installing Python over again does not take noticably more time - cp -R "$BUILD_DIR/requirements.txt" "$CACHE_DIR/.heroku/requirements.txt" - rm -rf .heroku/python - unset SKIP_INSTALL - else - puts-step "No change in requirements detected, installing from cache" - fi - fi -fi - -if [[ -n "${SKIP_INSTALL}" ]]; then - puts-step "Using cached install of ${PYTHON_VERSION}" -else - puts-step "Installing ${PYTHON_VERSION}" - - # Prepare destination directory. - mkdir -p .heroku/python - - if ! curl --silent --show-error --fail --retry 3 --retry-connrefused --connect-timeout 10 "${PYTHON_URL}" | tar --zstd --extract --directory .heroku/python; then - # The Python version was confirmed to exist previously, so any failure here is due to - # a networking issue or archive/buildpack bug rather than the runtime not existing. - puts-warn "Failed to download/install ${PYTHON_VERSION}" - meta_set "failure_reason" "python-download" - exit 1 - fi - - # Record for future reference. - echo "$PYTHON_VERSION" > .heroku/python-version - echo "$STACK" > .heroku/python-stack - - hash -r -fi - -PIP_VERSION=$(get_requirement_version 'pip') -SETUPTOOLS_VERSION=$(get_requirement_version 'setuptools') -WHEEL_VERSION=$(get_requirement_version 'wheel') - -puts-step "Installing pip ${PIP_VERSION}, setuptools ${SETUPTOOLS_VERSION} and wheel ${WHEEL_VERSION}" - -meta_set "pip_version" "${PIP_VERSION}" -meta_set "setuptools_version" "${SETUPTOOLS_VERSION}" -meta_set "wheel_version" "${WHEEL_VERSION}" - -# Python bundles Pip within its standard library, which we can use to install our chosen -# pip version from PyPI, saving us from having to download the usual pip bootstrap script. -# We have to use a glob since the bundled wheel filename contains the pip version, which -# differs between Python versions. We also have to handle the case where there are multiple -# matching pip wheels, since in some versions of Python (eg 3.9.0) multiple versions of pip -# were accidentally bundled upstream. Note: This implementation relies upon `nullglob` being -# set, which is the case thanks to the `bin/utils` that was run earlier. -BUNDLED_PIP_WHEEL_LIST=(.heroku/python/lib/python*/ensurepip/_bundled/pip-*.whl) -BUNDLED_PIP_WHEEL="${BUNDLED_PIP_WHEEL_LIST[0]}" - -if [[ -z "${BUNDLED_PIP_WHEEL}" ]]; then - puts-warn "Failed to locate the bundled pip wheel" - meta_set "failure_reason" "bundled-pip-not-found" - exit 1 -fi - -/app/.heroku/python/bin/python "${BUNDLED_PIP_WHEEL}/pip" install --quiet --disable-pip-version-check --no-cache-dir \ - "pip==${PIP_VERSION}" "setuptools==${SETUPTOOLS_VERSION}" "wheel==${WHEEL_VERSION}" - -hash -r diff --git a/bin/steps/sqlite3 b/bin/steps/sqlite3 deleted file mode 100755 index f21d606f4..000000000 --- a/bin/steps/sqlite3 +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash - -# TODO: Remove this entirely since the Python stdlib now includes modern sqlite support, -# and the APT buildpack should be used if an app needs the sqlite CLI/headers. - -sqlite3_install() { - HEROKU_PYTHON_DIR="$1" - HEADERS_ONLY="$3" - - mkdir -p "$HEROKU_PYTHON_DIR" - - APT_CACHE_DIR="$HEROKU_PYTHON_DIR/apt/cache" - APT_STATE_DIR="$HEROKU_PYTHON_DIR/apt/state" - - mkdir -p "$APT_CACHE_DIR/archives/partial" - mkdir -p "$APT_STATE_DIR/lists/partial" - - APT_OPTIONS=( - "--option=debug::nolocking=true" - "--option=dir::cache=${APT_CACHE_DIR}" - "--option=dir::state=${APT_STATE_DIR}" - "--option=dir::etc::sourcelist=/etc/apt/sources.list" - ) - - apt-get "${APT_OPTIONS[@]}" update > /dev/null 2>&1 - if [[ -z "$HEADERS_ONLY" ]]; then - apt-get "${APT_OPTIONS[@]}" -y -d --reinstall install libsqlite3-dev sqlite3 > /dev/null 2>&1 - else - apt-get "${APT_OPTIONS[@]}" -y -d --reinstall install libsqlite3-dev - fi - - find "$APT_CACHE_DIR/archives/" -name "*.deb" -exec dpkg -x {} "$HEROKU_PYTHON_DIR/sqlite3/" \; - - mkdir -p "$HEROKU_PYTHON_DIR/include" - mkdir -p "$HEROKU_PYTHON_DIR/lib" - - # remove old sqlite3 libraries/binaries - find "$HEROKU_PYTHON_DIR/include/" -name "sqlite3*.h" -exec rm -f {} \; - find "$HEROKU_PYTHON_DIR/lib/" -name "libsqlite3.*" -exec rm -f {} \; - rm -f "$HEROKU_PYTHON_DIR/lib/pkgconfig/sqlite3.pc" - rm -f "$HEROKU_PYTHON_DIR/bin/sqlite3" - - # eg: `x86_64` or `aarch64` - GNU_ARCH=$(arch) - - # copy over sqlite3 headers & bins and setup linking against the stack image library - mv "$HEROKU_PYTHON_DIR/sqlite3/usr/include/"* "$HEROKU_PYTHON_DIR/include/" - mv "$HEROKU_PYTHON_DIR/sqlite3/usr/lib/${GNU_ARCH}-linux-gnu"/libsqlite3.*a "$HEROKU_PYTHON_DIR/lib/" - mkdir -p "$HEROKU_PYTHON_DIR/lib/pkgconfig" - # set the right prefix/lib directories - sed -e 's/prefix=\/usr/prefix=\/app\/.heroku\/python/' -e "s/\/${GNU_ARCH}-linux-gnu//" "$HEROKU_PYTHON_DIR/sqlite3/usr/lib/${GNU_ARCH}-linux-gnu/pkgconfig/sqlite3.pc" > "$HEROKU_PYTHON_DIR/lib/pkgconfig/sqlite3.pc" - # need to point the libsqlite3.so to the stack image library for /usr/bin/ld -lsqlite3 - SQLITE3_LIBFILE="/usr/lib/${GNU_ARCH}-linux-gnu/$(readlink -n "$HEROKU_PYTHON_DIR/sqlite3/usr/lib/${GNU_ARCH}-linux-gnu/libsqlite3.so")" - ln -s "$SQLITE3_LIBFILE" "$HEROKU_PYTHON_DIR/lib/libsqlite3.so" - if [[ -z "$HEADERS_ONLY" ]]; then - mv "$HEROKU_PYTHON_DIR/sqlite3/usr/bin"/* "$HEROKU_PYTHON_DIR/bin/" - fi - - # cleanup - rm -rf "$HEROKU_PYTHON_DIR/sqlite3/" - rm -rf "$HEROKU_PYTHON_DIR/apt/" -} - -buildpack_sqlite3_install() { - puts-step "Installing SQLite3" - - # TODO: This never actual prints failure or even aborts the build, since - # the conditional disables `set -e` inside the called function: - # https://stackoverflow.com/q/19789102 - # ...plus whoever wrote this forgot the `exit 1` in the `else` anyway. - if sqlite3_install "$BUILD_DIR/.heroku/python" ; then - # mcount "success.python.sqlite3" - : - else - echo "Sqlite3 failed to install." - # mcount "failure.python.sqlite3" - fi - - mkdir -p "$CACHE_DIR/.heroku/" -} diff --git a/bin/test-compile b/bin/test-compile index 03fd6fadb..1fef46275 100755 --- a/bin/test-compile +++ b/bin/test-compile @@ -3,6 +3,7 @@ # See: https://devcenter.heroku.com/articles/testpack-api set -euo pipefail +shopt -s inherit_errexit # The absolute path to the root of the buildpack. BUILDPACK_DIR=$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd) diff --git a/bin/utils b/bin/utils index 5ce22d866..3eedc1f81 100755 --- a/bin/utils +++ b/bin/utils @@ -5,70 +5,15 @@ shopt -s extglob shopt -s nullglob -source "${BUILDPACK_DIR}/vendor/buildpack-stdlib_v8.sh" - -if [[ "$(uname)" == "Darwin" ]]; then - sed() { command sed -l "$@"; } -else - sed() { command sed -u "$@"; } -fi - -# Syntax sugar. -indent() { - sed "s/^/ /" -} - - -# Clean up pip output -cleanup() { - sed -e 's/\.\.\.\+/.../g' | sed -e '/already satisfied/Id' | sed -e '/No files were found to uninstall/Id' | sed -e '/Overwriting/Id' | sed -e '/python executable/Id' | sed -e '/no previously-included files/Id' -} - -# Buildpack Steps. -puts-step() { - echo "-----> $*" -} - -# Buildpack Warnings. -puts-warn() { - echo " ! $*" -} - -# Does some serious copying. -deep-cp() { - declare source="$1" target="$2" - - mkdir -p "$target" - - # cp doesn't like being called without source params, - # so make sure they expand to something first. - # subshell to avoid surprising caller with shopts. - ( - shopt -s nullglob dotglob - set -- "$source"/!(tmp|.|..) - [[ $# == 0 ]] || cp -a "$@" "$target" - ) -} +source "${BUILDPACK_DIR:?}/vendor/buildpack-stdlib_v8.sh" # Measure the size of the Python installation. measure-size() { - { du -s .heroku/python 2>/dev/null || echo 0; } | awk '{print $1}' + { du -s .heroku/python 2>/dev/null || echo 0; } | awk '{print $1}' } # Returns 0 if the specified module exists, otherwise returns 1. is_module_available() { - local module_name="${1}" - python -c "import sys, importlib.util; sys.exit(0 if importlib.util.find_spec('${module_name}') else 1)" -} - -# The requirement versions are effectively buildpack constants, however, we want -# Dependabot to be able to update them, which requires that they be in requirements -# files. The requirements files contain contents like `package==1.2.3` (and not just -# the package version) so we have to extract the version substring from it. -get_requirement_version() { - local package_name="${1}" - local requirement - requirement=$(cat "${BUILDPACK_DIR}/requirements/${package_name}.txt") - local requirement_version="${requirement#"${package_name}=="}" - echo "${requirement_version}" + local module_name="${1}" + python -c "import sys, importlib.util; sys.exit(0 if importlib.util.find_spec('${module_name}') else 1)" } diff --git a/bin/warnings b/bin/warnings index 0036f5ccc..04b453a2d 100755 --- a/bin/warnings +++ b/bin/warnings @@ -1,14 +1,51 @@ #!/usr/bin/env bash -gdal-missing() { - if grep -qi 'Could not find gdal-config' "$WARNINGS_LOG"; then - echo - puts-warn "Hello! Package installation failed since the GDAL library was not found." - puts-warn "For GDAL, GEOS and PROJ support, use the Geo buildpack alongside the Python buildpack:" - puts-warn "https://github.com/heroku/heroku-geo-buildpack" - fi -} - show-warnings() { - gdal-missing + local install_log="${1}" + + # shellcheck disable=SC2154 # TODO: Env var is referenced but not assigned. + if grep -qi 'Could not find gdal-config' "${install_log}"; then + output::error <<-'EOF' + Error: Package installation failed since the GDAL library wasn't found. + + For GDAL, GEOS and PROJ support, use the Geo buildpack alongside the Python buildpack: + https://github.com/heroku/heroku-geo-buildpack + EOF + build_data::set_string "failure_detail" "Could not find gdal-config" + elif grep -qi 'sqlite3.h: No such file or directory' "${install_log}"; then + output::error <<-'EOF' + Error: Package installation failed since SQLite headers weren't found. + + The Python buildpack no longer installs the SQLite headers + package since most apps don't require it. + + If you're trying to install the `pysqlite3` package, we + recommend using the virtually identical `sqlite3` module in + Python's standard library instead: + https://docs.python.org/3/library/sqlite3.html + + To do this: + 1. Remove the `pysqlite3` package from your dependencies. + 2. Replace any `pysqlite3` imports in your app with `sqlite3`. + + Alternatively, if you can't use the `sqlite3` stdlib module, + update your `pysqlite3` package to 0.6.0 or newer, since the + newer versions are published with pre-compiled wheels and so + don't need the SQLite headers to be installed. + EOF + build_data::set_string "failure_detail" "sqlite3.h: No such file or directory" + elif grep -qi 'Please use pip<24.1 if you need to use this version' "${install_log}"; then + output::error <<-'EOF' + Error: One of your dependencies contains broken metadata. + + Newer versions of pip reject packages that use invalid versions + in their metadata (such as Celery older than v5.2.1). + + Try upgrading to a newer version of the affected package. + + For more help, see: + https://devcenter.heroku.com/changelog-items/3073 + EOF + build_data::set_string "failure_detail" "Please use pip<24.1 if you need to use this version" + fi } diff --git a/buildpack.toml b/buildpack.toml index 0464f13c9..4ccd6f242 100644 --- a/buildpack.toml +++ b/buildpack.toml @@ -5,9 +5,12 @@ name = "Python" files = [ ".github/", "builds/", + "etc/publish.sh", "spec/", + ".editorconfig", ".gitignore", ".rubocop.yml", + ".shellcheckrc", "Gemfile", "Gemfile.lock", "hatchet.json", diff --git a/builds/Dockerfile b/builds/Dockerfile index f3fcfffb7..e2a56b2db 100644 --- a/builds/Dockerfile +++ b/builds/Dockerfile @@ -1,4 +1,5 @@ ARG STACK_VERSION="24" +FROM ghcr.io/sigstore/cosign/cosign:v3.1.1@sha256:6bbe0d281d955c79f85b325f0f7e651c1bcab5a4fa4ad4903d74955178a3b2eb AS cosign FROM heroku/heroku:${STACK_VERSION}-build ARG STACK_VERSION @@ -7,12 +8,17 @@ ENV STACK="heroku-${STACK_VERSION}" # For Heroku-24 and newer, the build image sets a non-root default `USER`. USER root +# `libcrypt-dev` can be removed once we drop support for Python 3.12, +# since the `crypt` module was removed in Python 3.13: +# https://docs.python.org/3.13/whatsnew/3.13.html#whatsnew313-pep594 RUN apt-get update --error-on=any \ && apt-get install -y --no-install-recommends \ + libcrypt-dev \ libdb-dev \ libreadline-dev \ libsqlite3-dev \ && rm -rf /var/lib/apt/lists/* +COPY --from=cosign /ko-app/cosign /usr/local/bin/cosign WORKDIR /tmp COPY build_python_runtime.sh . diff --git a/builds/build_python_runtime.sh b/builds/build_python_runtime.sh index b1e81c7b8..c19f155bb 100755 --- a/builds/build_python_runtime.sh +++ b/builds/build_python_runtime.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash +# This script is used by buildpack maintainers to compile new Python versions as they are released, +# ready for upload to S3. (It isn't run as part of normal buildpack execution during app builds.) +# See `builds/README.md` for how to invoke this via GitHub Actions. + set -euo pipefail +shopt -s inherit_errexit PYTHON_VERSION="${1:?"Error: The Python version to build must be specified as the first argument."}" PYTHON_MAJOR_VERSION="${PYTHON_VERSION%.*}" @@ -14,78 +19,66 @@ INSTALL_DIR="/tmp/python" SRC_DIR="/tmp/src" UPLOAD_DIR="/tmp/upload" -function error() { - echo "Error: ${1}" >&2 - exit 1 +function abort() { + echo "Error: ${1}" >&2 + exit 1 } -case "${STACK}" in - heroku-24) - SUPPORTED_PYTHON_VERSIONS=( - "3.10" - "3.11" - "3.12" - ) - ;; - heroku-22) - SUPPORTED_PYTHON_VERSIONS=( - "3.9" - "3.10" - "3.11" - "3.12" - ) - ;; - heroku-20) - SUPPORTED_PYTHON_VERSIONS=( - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" - ) - ;; - *) - error "Error: Unsupported stack '${STACK}'!" - ;; +case "${STACK:?}" in + heroku-22 | heroku-24 | heroku-26) + SUPPORTED_PYTHON_VERSIONS=( + "3.10" + "3.11" + "3.12" + "3.13" + "3.14" + ) + ;; + *) + abort "Unsupported stack '${STACK}'!" + ;; esac -if [[ ! " ${SUPPORTED_PYTHON_VERSIONS[*]} " == *" ${PYTHON_MAJOR_VERSION} "* ]]; then - error "Error: Python ${PYTHON_MAJOR_VERSION} is not supported on ${STACK}!" +if [[ " ${SUPPORTED_PYTHON_VERSIONS[*]} " != *" ${PYTHON_MAJOR_VERSION} "* ]]; then + abort "Python ${PYTHON_MAJOR_VERSION} isn't supported on ${STACK}!" fi -# The release keys can be found on https://www.python.org/downloads/ -> "OpenPGP Public Keys". +# Sigstore identities taken from: https://www.python.org/downloads/metadata/sigstore/ case "${PYTHON_MAJOR_VERSION}" in - 3.12) - # https://github.com/Yhg1s.gpg - GPG_KEY_FINGERPRINT='7169605F62C751356D054A26A821E680E5FA6305' - ;; - 3.10|3.11) - # https://keybase.io/pablogsal/ - GPG_KEY_FINGERPRINT='A035C8C19219BA821ECEA86B64E628F8D684696D' - ;; - 3.8|3.9) - # https://keybase.io/ambv/ - GPG_KEY_FINGERPRINT='E3FF2839C048B25C084DEBE9B26995E310250568' - ;; - *) - error "Error: Unsupported Python version '${PYTHON_MAJOR_VERSION}'!" - ;; + 3.14) + SIGSTORE_IDENTITY='hugo@python.org' + SIGSTORE_ISSUER='https://github.com/login/oauth' + ;; + 3.12 | 3.13) + SIGSTORE_IDENTITY='thomas@python.org' + SIGSTORE_ISSUER='https://accounts.google.com' + ;; + 3.10 | 3.11) + SIGSTORE_IDENTITY='pablogsal@python.org' + SIGSTORE_ISSUER='https://accounts.google.com' + ;; + *) + abort "Unsupported Python version '${PYTHON_MAJOR_VERSION}'!" + ;; esac echo "Building Python ${PYTHON_VERSION} for ${STACK} (${ARCH})..." SOURCE_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" -SIGNATURE_URL="${SOURCE_URL}.asc" +SIGSTORE_BUNDLE_URL="${SOURCE_URL}.sigstore" set -o xtrace mkdir -p "${SRC_DIR}" "${INSTALL_DIR}" "${UPLOAD_DIR}" -curl --fail --retry 3 --retry-connrefused --connect-timeout 10 --max-time 60 -o python.tgz "${SOURCE_URL}" -curl --fail --retry 3 --retry-connrefused --connect-timeout 10 --max-time 60 -o python.tgz.asc "${SIGNATURE_URL}" +curl --fail --retry 5 --retry-connrefused --connect-timeout 3 --max-time 30 -o python.tgz "${SOURCE_URL}" +curl --fail --retry 5 --retry-connrefused --connect-timeout 3 --max-time 30 -o python.tgz.sigstore "${SIGSTORE_BUNDLE_URL}" -gpg --batch --verbose --recv-keys "${GPG_KEY_FINGERPRINT}" -gpg --batch --verify python.tgz.asc python.tgz +cosign verify-blob \ + --bundle python.tgz.sigstore \ + --certificate-identity "${SIGSTORE_IDENTITY}" \ + --certificate-oidc-issuer "${SIGSTORE_ISSUER}" \ + python.tgz tar --extract --file python.tgz --strip-components=1 --directory "${SRC_DIR}" cd "${SRC_DIR}" @@ -94,59 +87,48 @@ cd "${SRC_DIR}" # for maximum compatibility / most battle-tested build configuration: # https://github.com/docker-library/python CONFIGURE_OPTS=( - # Explicitly set the target architecture rather than auto-detecting based on the host CPU. - # This only affects targets like i386 (for which we don't build), but we pass it anyway for - # completeness and parity with the Python Docker image builds. - "--build=$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" - # Support loadable extensions in the `_sqlite` extension module. - "--enable-loadable-sqlite-extensions" - # Enable recommended release build performance optimisations such as PGO. - "--enable-optimizations" - # Make autoconf's configure option validation more strict. - "--enable-option-checking=fatal" - # Install Python into `/tmp/python` rather than the default of `/usr/local`. - "--prefix=${INSTALL_DIR}" - # Skip running `ensurepip` as part of install, since the buildpack installs a curated - # version of pip itself (which ensures it's consistent across Python patch releases). - "--with-ensurepip=no" - # Build the `pyexpat` module using the `expat` library in the base image (which will - # automatically receive security updates), rather than CPython's vendored version. - "--with-system-expat" + # Explicitly set the target architecture rather than auto-detecting based on the host CPU. + # This only affects targets like i386 (for which we don't build), but we pass it anyway for + # completeness and parity with the Python Docker image builds. + "--build=$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" + # Support loadable extensions in the `_sqlite` extension module. + "--enable-loadable-sqlite-extensions" + # Enable recommended release build performance optimisations such as PGO. + "--enable-optimizations" + # Make autoconf's configure option validation more strict. + "--enable-option-checking=fatal" + # Shared builds are beneficial for a number of reasons: + # - Reduces the size of the build, since it avoids the duplication between + # the Python binary and the static library. + # - Permits use-cases that only work with the shared Python library, + # and not the static library (such as `pycall.rb` or `PyO3`). + # - More consistent with the official Python Docker images and other distributions. + # + # Shared builds are slower unless `no-semantic-interposition` and LTO are used, + # however, as of Python 3.10 `no-semantic-interposition` is enabled by default: + # https://fedoraproject.org/wiki/Changes/PythonNoSemanticInterpositionSpeedup + # https://github.com/python/cpython/issues/83161 + "--enable-shared" + # Install Python into `/tmp/python` rather than the default of `/usr/local`. + "--prefix=${INSTALL_DIR}" + # Skip running `ensurepip` as part of install, since the buildpack installs a curated + # version of pip itself (which ensures it's consistent across Python patch releases). + "--with-ensurepip=no" + "--with-lto" + # Counter-intuitively, the static library is still generated by default even when + # the shared library is enabled, so we disable it to reduce the build size. + # This option only exists for Python 3.10+. + "--without-static-libpython" ) -if [[ "${PYTHON_MAJOR_VERSION}" != 3.[8-9] ]]; then - CONFIGURE_OPTS+=( - # Shared builds are beneficial for a number of reasons: - # - Reduces the size of the build, since it avoids the duplication between - # the Python binary and the static library. - # - Permits use-cases that only work with the shared Python library, - # and not the static library (such as `pycall.rb` or `PyO3`). - # - More consistent with the official Python Docker images and other distributions. - # - # However, shared builds are slower unless `no-semantic-interposition`and LTO is used: - # https://fedoraproject.org/wiki/Changes/PythonNoSemanticInterpositionSpeedup - # https://github.com/python/cpython/issues/83161 - # - # It's only as of Python 3.10 that `no-semantic-interposition` is enabled by default, - # so we only use shared builds on Python 3.10+ to avoid needing to override the default - # compiler flags. - "--enable-shared" - "--with-lto" - # Counter-intuitively, the static library is still generated by default even when - # the shared library is enabled, so we disable it to reduce the build size. - # This option only exists for Python 3.10+. - "--without-static-libpython" - ) -fi - -if [[ "${PYTHON_MAJOR_VERSION}" == "3.11" || "${PYTHON_MAJOR_VERSION}" == "3.12" ]]; then - CONFIGURE_OPTS+=( - # Skip building the test modules, since we remove them after the build anyway. - # This feature was added in Python 3.10+, however it wasn't until Python 3.11 - # that compatibility issues between it and PGO were fixed: - # https://github.com/python/cpython/pull/29315 - "--disable-test-modules" - ) +if [[ "${PYTHON_MAJOR_VERSION}" != +(3.10) ]]; then + CONFIGURE_OPTS+=( + # Skip building the test modules, since we remove them after the build anyway. + # This feature was added in Python 3.10, however it wasn't until Python 3.11 + # that compatibility issues between it and PGO were fixed: + # https://github.com/python/cpython/pull/29315 + "--disable-test-modules" + ) fi ./configure "${CONFIGURE_OPTS[@]}" @@ -160,31 +142,15 @@ fi # - https://wiki.ubuntu.com/ToolChain/CompilerFlags # - https://wiki.debian.org/Hardening # - https://github.com/docker-library/python/issues/810 -# We only use `dpkg-buildflags` for Python versions where we build in shared mode (Python 3.9+), -# since some of the options it enables interferes with the stripping of static libraries. -if [[ "${PYTHON_MAJOR_VERSION}" == 3.[8-9] ]]; then - EXTRA_CFLAGS='' - LDFLAGS='-Wl,--strip-all' -else - EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)" - LDFLAGS="$(dpkg-buildflags --get LDFLAGS) -Wl,--strip-all" -fi +EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)" +LDFLAGS="$(dpkg-buildflags --get LDFLAGS) -Wl,--strip-all" -make -j "$(nproc)" "EXTRA_CFLAGS=${EXTRA_CFLAGS}" "LDFLAGS=${LDFLAGS}" +CPU_COUNT="$(nproc)" +make -j "${CPU_COUNT}" "EXTRA_CFLAGS=${EXTRA_CFLAGS}" "LDFLAGS=${LDFLAGS}" make install -if [[ "${PYTHON_MAJOR_VERSION}" == 3.[8-9] ]]; then - # On older versions of Python we're still building the static library, which has to be - # manually stripped since the linker stripping enabled in LDFLAGS doesn't cover them. - # We're using `--strip-unneeded` since `--strip-all` would remove the `.symtab` section - # that is required for static libraries to be able to be linked. - # `find` is used since there are multiple copies of the static library in version-specific - # locations, eg: - # - `lib/libpython3.9.a` - # - `lib/python3.9/config-3.9-x86_64-linux-gnu/libpython3.9.a` - find "${INSTALL_DIR}" -type f -name '*.a' -print -exec strip --strip-unneeded '{}' + -elif ! find "${INSTALL_DIR}" -type f -name '*.a' -print -exec false '{}' +; then - error "Error: Unexpected static libraries found!" +if ! find "${INSTALL_DIR}" -type f -name '*.a' -print -exec false '{}' +; then + abort "Unexpected static libraries found!" fi # Remove unneeded test directories, similar to the official Docker Python images: @@ -218,6 +184,16 @@ find "${INSTALL_DIR}" -depth -type f -name "*.pyc" -delete # https://github.com/python/cpython/blob/v3.11.3/Makefile.pre.in#L2087-L2113 LD_LIBRARY_PATH="${SRC_DIR}" "${SRC_DIR}/python" -m compileall -f --invalidation-mode unchecked-hash --workers 0 "${INSTALL_DIR}" +# Delete entrypoint scripts (and their symlinks) that don't work with relocated Python since they +# hardcode the Python install directory in their shebangs (e.g. `#!/tmp/python/bin/python3.NN`). +# These scripts are rarely used in production, and can still be accessed via their Python module +# (e.g. `python -m pydoc`) if needed. +rm "${INSTALL_DIR}"/bin/{idle,pydoc}* +# The 2to3 module and entrypoint was removed from the stdlib in Python 3.13. +if [[ "${PYTHON_MAJOR_VERSION}" == +(3.10|3.11|3.12) ]]; then + rm "${INSTALL_DIR}"/bin/2to3* +fi + # Support using Python 3 via the version-less `python` command, for parity with virtualenvs, # the Python Docker images and to also ensure buildpack Python shadows any installed system # Python, should that provide a version-less alias too. diff --git a/builds/test_python_runtime.sh b/builds/test_python_runtime.sh index 258600a31..36c3d114f 100755 --- a/builds/test_python_runtime.sh +++ b/builds/test_python_runtime.sh @@ -1,9 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +shopt -s inherit_errexit ARCHIVE_FILEPATH="${1:?"Error: The filepath of the Python runtime archive must be specified as the first argument."}" +function abort() { + echo "Error: ${1}" >&2 + exit 1 +} + +set -x + # We intentionally extract the Python runtime into a different directory to the one into which it # was originally installed before being packaged, to check that relocation works (since buildpacks # depend on it). Since the Python binary was built in shared mode, `LD_LIBRARY_PATH` must be set @@ -13,36 +21,74 @@ export LD_LIBRARY_PATH="${INSTALL_DIR}/lib/" tar --zstd --extract --verbose --file "${ARCHIVE_FILEPATH}" --directory "${INSTALL_DIR}" -# Check Python is able to start and is usable via both the default `python3` command and the -# `python` symlink we create during the build. We use the full filepath rather than adding the +# Check Python is able to start and is usable via both the default `python3` + `python3.XX` commands +# and the `python` symlink we create during the build. We use the full filepath rather than adding the # directory to PATH to ensure the test doesn't pass because of falling through to system Python. "${INSTALL_DIR}/bin/python3" --version +major_python_version="$("${INSTALL_DIR}/bin/python3" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" +"${INSTALL_DIR}/bin/python${major_python_version}" --version "${INSTALL_DIR}/bin/python" --version +# Check the Python config script still exists/works after the deletion of scripts with broken shebang lines. +"${INSTALL_DIR}/bin/python3-config" --help + +set +x + +# Check that the broken bin entrypoints and symlinks (such as `idle3` and `pydoc3`) were deleted. +UNEXPECTED_BIN_FILES="$(find "${INSTALL_DIR}/bin" -type 'f,l' -not -name 'python*')" +if [[ -n "${UNEXPECTED_BIN_FILES}" ]]; then + echo "${UNEXPECTED_BIN_FILES}" + abort "The above files were found in the bin/ directory but weren't expected!" +else + echo "No unexpected files found in the bin/ directory." +fi + # Check that all dynamically linked libraries exist in the run image (since it has fewer packages than the build image). LDD_OUTPUT=$(find "${INSTALL_DIR}" -type f,l \( -name 'python3' -o -name '*.so*' \) -exec ldd '{}' +) if grep 'not found' <<<"${LDD_OUTPUT}" | sort --unique; then - echo "The above dynamically linked libraries were not found!" - exit 1 + abort "The above dynamically linked libraries weren't found!" +else + echo "All dynamically linked libraries were found." fi # Check that optional and/or system library dependent stdlib modules were built. optional_stdlib_modules=( - _uuid - bz2 - ctypes - curses - dbm.gnu - dbm.ndbm - decimal - lzma - readline - sqlite3 - ssl - xml.parsers.expat - zlib + _uuid + bz2 + ctypes + curses + dbm.gnu + dbm.ndbm + decimal + lzma + readline + sqlite3 + ssl + xml.parsers.expat + zlib ) -if ! "${INSTALL_DIR}/bin/python3" -c "import $(IFS=, ; echo "${optional_stdlib_modules[*]}")"; then - echo "The above optional stdlib module failed to import! Check the compile logs to see if it was skipped due to missing libraries/headers." - exit 1 + +# zstd support was added in Python 3.14: +# https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-zstandard +if [[ "${major_python_version}" == "3.14" ]]; then + optional_stdlib_modules+=( + compression.zstd + ) +fi + +# crypt was removed in Python 3.13: +# https://docs.python.org/3.13/whatsnew/3.13.html#whatsnew313-pep594 +if [[ "${major_python_version}" == +(3.10|3.11|3.12) ]]; then + optional_stdlib_modules+=( + crypt + ) +fi + +if "${INSTALL_DIR}/bin/python3" -c "import $( + IFS=, + echo "${optional_stdlib_modules[*]}" +)"; then + echo "Successfully imported: ${optional_stdlib_modules[*]}" +else + abort "The above optional stdlib module failed to import! Check the compile logs to see if it was skipped due to missing libraries/headers." fi diff --git a/etc/publish.sh b/etc/publish.sh index c7801efe2..019bf60d3 100755 --- a/etc/publish.sh +++ b/etc/publish.sh @@ -1,26 +1,65 @@ #!/usr/bin/env bash set -euo pipefail +shopt -s inherit_errexit -BP_NAME=${1:-"heroku/python"} +buildpack_registry_name="heroku/python" -curVersion=$(heroku buildpacks:versions "$BP_NAME" | awk 'FNR == 3 { print $1 }') -newVersion="v$((curVersion + 1))" +function abort() { + echo + echo "Error: ${1}" >&2 + exit 1 +} -read -r -p "Deploy as version: $newVersion [y/n]? " choice -case "$choice" in - y|Y ) echo "";; - n|N ) exit 0;; - * ) exit 1;; -esac +echo "Checking environment..." + +if ! command -v gh >/dev/null; then + abort "Install the GitHub CLI first: https://cli.github.com" +fi + +if ! heroku buildpacks:publish --help >/dev/null; then + abort "Install the Buildpack Registry plugin first: https://github.com/heroku/plugin-buildpack-registry" +fi + +# Explicitly check the CLI is logged in, since the Buildpack Registry plugin doesn't handle re-authing +# expired logins properly, which can otherwise lead to the release aborting partway through. +if ! heroku whoami >/dev/null; then + abort "Log into the Heroku CLI first: heroku login" +fi + +echo "Checking buildpack versions in sync..." +current_github_release_version=$(gh release view --json tagName --jq '.tagName' | tr --delete 'v') +current_registry_version="$(heroku buildpacks:versions "${buildpack_registry_name}" | awk 'FNR == 3 { print $1 }')" + +if [[ "${current_github_release_version}" != "${current_registry_version}" ]]; then + abort "The current GitHub release version (v${current_github_release_version}) doesn't match the registry version (v${current_registry_version}), likely due to a registry rollback. Fix this first!" +fi +new_version="$((current_github_release_version + 1))" +new_git_tag="v${new_version}" + +echo "Extracting changelog entry for this release..." git fetch origin -originMain=$(git rev-parse origin/main) -echo "Tagging commit $originMain with $newVersion... " -git tag "$newVersion" "${originMain:?}" -git push origin "refs/tags/${newVersion}" +# Using `git show` to avoid having to disrupt the current branch/working directory. +changelog_entry="$(git show origin/main:CHANGELOG.md | awk "/^## \[v${new_version}\]/{flag=1; next} /^## /{flag=0} flag")" + +if [[ -n "${changelog_entry}" ]]; then + echo -e "${changelog_entry}\n" +else + abort "Unable to find changelog entry for v${new_version}. Has the prepare release PR been triggered/merged?" +fi + +read -r -p "Deploy as ${new_git_tag} [y/n]? " choice +case "${choice}" in + y | Y) ;; + n | N) exit 0 ;; + *) exit 1 ;; +esac + +echo -e "\nCreating GitHub release..." +gh release create "${new_git_tag}" --title "${new_git_tag}" --notes "${changelog_entry}" -echo -e "\nPublishing to the buildpack registry..." -heroku buildpacks:publish "$BP_NAME" "$newVersion" +echo -e "\nPublishing to the Heroku Buildpack Registry..." +heroku buildpacks:publish "${buildpack_registry_name}" "${new_git_tag}" echo -heroku buildpacks:versions "${BP_NAME}" | head -n 3 +heroku buildpacks:versions "${buildpack_registry_name}" | head -n 3 diff --git a/hatchet.json b/hatchet.json index a9f518db2..0967ef424 100644 --- a/hatchet.json +++ b/hatchet.json @@ -1,8 +1 @@ -{ - "hatchet": { - "directory": ".hatchet/" - }, - "heroku": [ - "https://github.com/heroku/python-getting-started" - ] -} +{} diff --git a/hatchet.lock b/hatchet.lock index 51aa7c698..dcd024e99 100644 --- a/hatchet.lock +++ b/hatchet.lock @@ -1,3 +1 @@ ---- -- - ".hatchet/repos/heroku/python-getting-started" - - main +--- [] diff --git a/lib/build_data.sh b/lib/build_data.sh new file mode 100644 index 000000000..856bb3df3 --- /dev/null +++ b/lib/build_data.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +BUILD_DATA_FILE="${CACHE_DIR:?}/build-data/python.json" +PREVIOUS_BUILD_DATA_FILE="${CACHE_DIR:?}/build-data/python-prev.json" + +# Legacy `key=value` format file used by older Python buildpack versions. +LEGACY_BUILD_DATA_FILE="${CACHE_DIR:?}/build-data/python" + +# Initializes the build data store, preserving the file from the previous build if it exists. +# Call this at the start of `bin/compile` before using any other functions from this file. +# +# Usage: +# ``` +# build_data::setup +# ``` +function build_data::setup() { + if [[ -f "${BUILD_DATA_FILE}" ]]; then + # Rename the existing build data file rather than overwriting it, so we can lookup values + # from the previous build (such as when determining whether to invalidate the cache). + mv "${BUILD_DATA_FILE}" "${PREVIOUS_BUILD_DATA_FILE}" + else + mkdir -p "$(dirname "${BUILD_DATA_FILE}")" + fi + + echo "{}" >"${BUILD_DATA_FILE}" +} + +# Sets a string build data value. The value will be wrapped in double quotes and escaped for JSON. +# +# Usage: +# ``` +# build_data::set_string "python_version" "1.2.3" +# build_data::set_string "failure_reason" "install-dependencies::pip" +# ``` +function build_data::set_string() { + local key="${1}" + local value="${2}" + build_data::_set "${key}" "${value}" "true" +} + +# Sets a build data value for the elapsed time in seconds between the provided start time and the +# current time, represented as a float with microseconds precision. +# +# Usage: +# ``` +# local dependencies_install_start_time=$(build_data::current_unix_realtime) +# # ... some operation ... +# build_data::set_duration "dependencies_install_duration" "${dependencies_install_start_time}" +# ``` +function build_data::set_duration() { + local key="${1}" + local start_time="${2}" + local end_time duration + end_time="$(build_data::current_unix_realtime)" + duration="$(awk -v start="${start_time}" -v end="${end_time}" 'BEGIN { printf "%f", (end - start) }')" + build_data::set_raw "${key}" "${duration}" +} + +# Sets a build data value as raw JSON data. The value parameter must be valid JSON value, that's also +# a supported Honeycomb data type (string, integer, float, or boolean only; no arrays or objects). +# For strings, use `build_data::set_string` instead since it will handle the escaping/quoting for you. +# And for durations, use `build_data::set_duration`. +# +# Usage: +# ``` +# build_data::set_raw "python_version_outdated" "true" +# build_data::set_raw "foo_size_mb" "42.5" +# ``` +function build_data::set_raw() { + local key="${1}" + local value="${2}" + build_data::_set "${key}" "${value}" "false" +} + +# Internal helper to write a key/value pair to the build data store. The buildpack shouldn't call this directly. +# Takes a key, value, and a boolean flag indicating whether the value needs to be quoted. +# +# Usage: +# ``` +# build_data::_set "foo_string" "quote me" "true" +# build_data::_set "bar_number" "99" "false" +# ``` +function build_data::_set() { + local key="${1}" + # Truncate the value to an arbitrary 200 characters since it will sometimes contain user-provided + # inputs which may be unbounded in size. Ideally individual call sites will perform more aggressive + # truncation themselves based on the expected value size, however this is here as a fallback. + # (Honeycomb supports string fields up to 64KB in size, however, it's not worth filling up the + # build data store or bloating the payload passed back to Vacuole/submitted to Honeycomb given the + # extra content in those cases is not normally useful.) + local value="${2:0:200}" + local needs_quoting="${3}" + + if [[ "${needs_quoting}" == "true" ]]; then + # Values passed using `--arg` are treated as strings, and so have double quotes added and any JSON + # special characters (such as newlines, carriage returns, double quotes, backslashes) are escaped. + local jq_args=(--arg value "${value}") + else + # Values passed using `--argjson` are treated as raw JSON values, and so aren't escaped or quoted. + local jq_args=(--argjson value "${value}") + fi + + if [[ -f "${BUILD_DATA_FILE}" ]]; then + local new_data_file_contents + new_data_file_contents="$(jq --exit-status --arg key "${key}" "${jq_args[@]}" '. + { ($key): ($value) }' "${BUILD_DATA_FILE}")" + echo "${new_data_file_contents}" >"${BUILD_DATA_FILE}" + else + output::error <<-EOF + Error: Can't find the buildpack's build data file. + + The Python buildpack's internal build data file is missing: + ${BUILD_DATA_FILE} + + This file is required for the buildpack to work correctly, + and so you must not delete it when removing files from the + build cache or /tmp directories. + EOF + build_data::setup + build_data::set_string "failure_reason" "build-data::data-file-deleted" + exit 1 + fi +} + +# Check whether an entry exists in the build data store for the current build. +# Returns zero if the key was found and non-zero otherwise. +# +# Usage: +# ``` +# build_data::has "failure_reason" +# ``` +function build_data::has() { + local key="${1}" + + jq --exit-status --arg key "${key}" 'has($key)' "${BUILD_DATA_FILE}" >/dev/null +} + +# Retrieve the value of an entry in the build data store from the previous successful build. +# Returns the empty string if the key wasn't found in the store. +# +# Usage: +# ``` +# build_data::get_previous "python_version" +# ``` +function build_data::get_previous() { + local key="${1}" + + # Older versions of this buildpack used a `key=value` format file instead of JSON, + # so we need to support this file format/location too, so older caches can be read. + # We check for this file first, so that we correctly handle the case where an app + # downgraded and then re-upgraded buildpack version, so has both files in the cache. + if [[ -f "${LEGACY_BUILD_DATA_FILE}" ]]; then + # The legacy file contains one entry per line, of form `key=value`. Entries were written in an + # append-only manner so there could be duplicate entries for each key, so we return only the + # last matching entry in the file. The empty string is returned if the key wasn't found. + tac "${LEGACY_BUILD_DATA_FILE}" | { grep --perl-regexp --only-matching --max-count=1 "^${key}=\K.*$" || true; } + elif [[ -f "${PREVIOUS_BUILD_DATA_FILE}" ]]; then + # By default jq will return `null` if the key isn't found, so we must handle this explicitly. + # We don't use `--exit-status` since `false` is a valid value for us to retrieve. + jq --raw-output --arg key "${key}" 'if has($key) then .[$key] else empty end' "${PREVIOUS_BUILD_DATA_FILE}" + fi +} + +# Returns the current time since the UNIX Epoch, as a float with microseconds precision. +# +# Usage: +# ``` +# local dependencies_install_start_time=$(build_data::current_unix_realtime) +# # ... some operation ... +# build_data::set_duration "dependencies_install_duration" "${dependencies_install_start_time}" +# ``` +function build_data::current_unix_realtime() { + # We use a subshell with `LC_ALL=C` to ensure the output format isn't affected by system locale. + ( + LC_ALL=C + echo "${EPOCHREALTIME}" + ) +} + +# Prints the contents of the build data store in sorted JSON format. +# +# Usage: +# ``` +# build_data::print_bin_report_json +# ``` +function build_data::print_bin_report_json() { + jq --exit-status --sort-keys '.' "${BUILD_DATA_FILE}" +} diff --git a/lib/cache.sh b/lib/cache.sh new file mode 100644 index 000000000..f2103a069 --- /dev/null +++ b/lib/cache.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +# Read the full Python version of the Python install in the cache, or the empty string +# if the cache is empty or doesn't contain a Python version metadata file. +function cache::cached_python_full_version() { + local cache_dir="${1}" + + if [[ -f "${cache_dir}/.heroku/python-version" ]]; then + local version + version="$(cat "${cache_dir}/.heroku/python-version")" + # For historical reasons the version has always been stored as `python-X.Y.Z`, + # so we have to remove the `python-` prefix. + echo "${version#python-}" + fi +} + +# Validates and restores the contents of the build cache if appropriate. +# The cache is discarded if any of the following have changed: +# - Stack +# - Python version +# - Package manager +# - Package manager version +# - requirements.txt contents (pip only) +function cache::restore() { + local build_dir="${1}" + local cache_dir="${2}" + local stack="${3}" + local cached_python_full_version="${4}" + local python_full_version="${5}" + local package_manager="${6}" + + local cache_restore_start_time + cache_restore_start_time=$(build_data::current_unix_realtime) + + if [[ ! -d "${cache_dir}/.heroku/python" ]]; then + build_data::set_string "cache_status" "empty" + build_data::set_duration "cache_restore_duration" "${cache_restore_start_time}" + return 0 + fi + + local cache_invalidation_reasons=() + + local cached_stack + cached_stack="$(cat "${cache_dir}/.heroku/python-stack" || true)" + if [[ "${cached_stack}" != "${stack}" ]]; then + cache_invalidation_reasons+=("The stack has changed from ${cached_stack:-"unknown"} to ${stack}") + fi + + if [[ "${cached_python_full_version}" != "${python_full_version}" ]]; then + cache_invalidation_reasons+=("The Python version has changed from ${cached_python_full_version:-"unknown"} to ${python_full_version}") + fi + + local cached_package_manager + cached_package_manager="$(build_data::get_previous "package_manager")" + if [[ -z "${cached_package_manager}" ]]; then + # The build data store only exists in caches created by v252+ of the buildpack (released 2024-06-17). + cache_invalidation_reasons+=("The buildpack cache format has changed") + elif [[ "${cached_package_manager}" != "${package_manager}" ]]; then + cache_invalidation_reasons+=("The package manager has changed from ${cached_package_manager:-"unknown"} to ${package_manager}") + else + case "${package_manager}" in + pip) + local cached_pip_version + cached_pip_version="$(build_data::get_previous "pip_version")" + if [[ "${cached_pip_version}" != "${PIP_VERSION:?}" ]]; then + cache_invalidation_reasons+=("The pip version has changed from ${cached_pip_version:-"unknown"} to ${PIP_VERSION}") + fi + # We invalidate the cache if requirements.txt changes since pip is a package installer + # rather than a project/environment manager, and so does not deterministically manage + # installed Python packages. For example, if a package entry in a requirements file is + # later removed, pip will not uninstall the package. This check can be removed if we + # ever switch to only caching pip's HTTP/wheel cache rather than site-packages. + # TODO: Switch this to using sha256sum like the Pipenv implementation. + if ! cmp --silent "${cache_dir}/.heroku/requirements.txt" "${build_dir}/requirements.txt"; then + cache_invalidation_reasons+=("The contents of requirements.txt changed") + fi + ;; + pipenv) + local cached_pipenv_version + cached_pipenv_version="$(build_data::get_previous "pipenv_version")" + if [[ "${cached_pipenv_version}" != "${PIPENV_VERSION:?}" ]]; then + cache_invalidation_reasons+=("The Pipenv version has changed from ${cached_pipenv_version:-"unknown"} to ${PIPENV_VERSION}") + fi + # `pipenv {install,sync}` by design don't actually uninstall packages on their own (!!): + # and we can't use `pipenv clean` since it isn't compatible with `--system`. + # https://github.com/pypa/pipenv/issues/3365 + # We have to explicitly check for the presence of the Pipfile.lock.sha256 file, + # since we only started writing it to the build cache as of buildpack v292 (released 2025-07-23). + local pipfile_lock_checksum_file="${cache_dir}/.heroku/python/Pipfile.lock.sha256" + if [[ -f "${pipfile_lock_checksum_file}" ]] && ! sha256sum --check --strict --status "${pipfile_lock_checksum_file}"; then + cache_invalidation_reasons+=("The contents of Pipfile.lock changed") + fi + ;; + poetry) + local cached_poetry_version + cached_poetry_version="$(build_data::get_previous "poetry_version")" + if [[ "${cached_poetry_version}" != "${POETRY_VERSION:?}" ]]; then + cache_invalidation_reasons+=("The Poetry version has changed from ${cached_poetry_version:-"unknown"} to ${POETRY_VERSION}") + fi + ;; + uv) + local cached_uv_version + cached_uv_version="$(build_data::get_previous "uv_version")" + if [[ "${cached_uv_version}" != "${UV_VERSION:?}" ]]; then + cache_invalidation_reasons+=("The uv version has changed from ${cached_uv_version:-"unknown"} to ${UV_VERSION}") + fi + ;; + *) + utils::abort_internal_error "Unhandled package manager: ${package_manager}" + ;; + esac + fi + + if [[ -f "${cache_dir}/.heroku/python/include/sqlite3.h" ]]; then + cache_invalidation_reasons+=("The legacy SQLite3 headers and CLI binary need to be uninstalled") + fi + + if [[ -n "${cache_invalidation_reasons[*]}" ]]; then + output::step "Discarding cache since:" + local reason + for reason in "${cache_invalidation_reasons[@]}"; do + echo " - ${reason}" + done + + rm -rf \ + "${cache_dir}/.heroku/python" \ + "${cache_dir}/.heroku/python-poetry" \ + "${cache_dir}/.heroku/python-stack" \ + "${cache_dir}/.heroku/python-uv" \ + "${cache_dir}/.heroku/python-version" \ + "${cache_dir}/.heroku/requirements.txt" + + build_data::set_string "cache_status" "discarded" + else + output::step "Restoring cache" + mkdir -p "${build_dir}/.heroku" + # Moving the files directly in place is much faster than copying when both the cache and + # build directory are on the same filesystem mount. The Python directory is guaranteed + # to not already exist thanks to the earlier `checks::existing_python_dir_present()`. + mv "${cache_dir}/.heroku/python" "${build_dir}/.heroku/" + build_data::set_string "cache_status" "reused" + fi + + # Remove any legacy cache contents written by older buildpack versions. + rm -rf \ + "${cache_dir}/build-data/python" \ + "${cache_dir}/build-data/python-prev" \ + "${cache_dir}/.heroku/python-sqlite3-version" \ + "${cache_dir}/.heroku/src" \ + "${cache_dir}/.heroku/vendor" + + build_data::set_duration "cache_restore_duration" "${cache_restore_start_time}" +} + +# Copies Python and dependencies from the build directory to the cache, for use by subsequent builds. +function cache::save() { + local build_dir="${1}" + local cache_dir="${2}" + local stack="${3}" + local python_full_version="${4}" + local package_manager="${5}" + + local cache_save_start_time + cache_save_start_time=$(build_data::current_unix_realtime) + + output::step "Saving cache" + + mkdir -p "${cache_dir}/.heroku" + rm -rf "${cache_dir}/.heroku/python" + + local build_dir_filesystem cache_dir_filesystem + build_dir_filesystem="$(df --output=target "${build_dir}")" + cache_dir_filesystem="$(df --output=target "${cache_dir}")" + + # For improved performance, we copy using hard-links if possible. This requires that the build + # and cache directory are on the same filesystem mount - which is the case for standard builds + # but not Heroku CI or build-in-app-dir. Ideally we would be able to use `--reflink=auto` here + # (which would avoid the need for a conditional and also mean accidental edits by users in later + # buildpacks to one location doesn't affect the other), however, with the current filesystems + # used in production benchmarking showed `--reflink=auto` was much slower than hardlinks. + if [[ "${build_dir_filesystem}" == "${cache_dir_filesystem}" ]]; then + local additional_copy_args=(--link) + else + local additional_copy_args=() + fi + + # We must explicitly use `--no-dereference` since the default cp behaviour varies based on other + # options used (such as `--link`), and we don't want symlinks to be resolved since otherwise the + # copy will fail when copying packages that contain broken symlinks. + cp --recursive --no-dereference "${additional_copy_args[@]}" "${build_dir}/.heroku/python" "${cache_dir}/.heroku/" + + # Metadata used by subsequent builds to determine whether the cache can be reused. + # These are written/consumed via separate files and not the build data store for compatibility + # with buildpack versions prior to the build data store existing (which was only added in v252). + echo "${stack}" >"${cache_dir}/.heroku/python-stack" + # For historical reasons the Python version was always stored with a `python-` prefix. + # We continue to use that format so that the file can be read by older buildpack versions. + echo "python-${python_full_version}" >"${cache_dir}/.heroku/python-version" + + if [[ "${package_manager}" == "pip" ]]; then + # TODO: Switch this to using sha256sum like the Pipenv implementation. + cp "${build_dir}/requirements.txt" "${cache_dir}/.heroku/" + elif [[ "${package_manager}" == "pipenv" ]]; then + # This must use a relative path for the lockfile, since the output file will contain + # the path specified, and the build directory path changes every build. + sha256sum Pipfile.lock >"${cache_dir}/.heroku/python/Pipfile.lock.sha256" + fi + + build_data::set_duration "cache_save_duration" "${cache_save_start_time}" +} diff --git a/lib/checks.sh b/lib/checks.sh new file mode 100644 index 000000000..5e9e4b65e --- /dev/null +++ b/lib/checks.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +function checks::ensure_supported_stack() { + local stack="${1}" + + case "${stack}" in + heroku-22 | heroku-24 | heroku-26) + return 0 + ;; + cedar* | heroku-16 | heroku-18 | heroku-20) + # This error will only ever be seen on non-Heroku environments, since the + # Heroku build system rejects builds using EOL stacks. + output::error <<-EOF + Error: The '${stack}' stack is no longer supported. + + This buildpack no longer supports the '${stack}' stack since it has + reached its end-of-life: + https://devcenter.heroku.com/articles/stack#stack-support-details + + Upgrade to a newer stack to continue using this buildpack. + EOF + build_data::set_string "failure_reason" "checks::stack::eol" + build_data::set_string "failure_detail" "${stack}" + exit 1 + ;; + *) + output::error <<-EOF + Error: The '${stack}' stack isn't recognised. + + This buildpack doesn't recognise or support the '${stack}' stack. + + If '${stack}' is a valid stack, make sure that you are using the latest + version of this buildpack and haven't pinned to an older release: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + EOF + build_data::set_string "failure_reason" "checks::stack::unknown" + build_data::set_string "failure_detail" "${stack}" + exit 1 + ;; + esac +} + +function checks::duplicate_python_buildpack() { + local build_dir="${1}" + + # The check for the `PYTHONHOME` env var prevents this warning triggering in the case + # where the Python install was committed to the Git repo (which will be handled later). + # (The env var can only have come from the `export` file of an earlier buildpack, + # since app provided config vars haven't been exported to the environment here.) + if [[ -f "${build_dir}/.heroku/python/bin/python" && -v PYTHONHOME ]]; then + output::error <<-EOF + Error: The Python buildpack has already been run this build. + + An existing Python installation was found in the build directory + from a buildpack run earlier in the build. + + This normally means there are duplicate Python buildpacks set + on your app, which isn't supported, can cause errors and + slow down builds. + + Check the buildpacks set on your app and remove any duplicate + Python buildpack entries: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#remove-classic-buildpacks + + Note: This error replaces the deprecation warning which was + displayed in build logs starting 13th December 2024. + EOF + build_data::set_string "failure_reason" "checks::duplicate-python-buildpack" + exit 1 + fi +} + +function checks::existing_python_dir_present() { + local build_dir="${1}" + + # We use `-e` here to catch the case where `python` is a file rather than a directory. + if [[ -e "${build_dir}/.heroku/python" ]]; then + output::error <<-EOF + Error: Existing '.heroku/python/' directory found. + + Your app's source code contains an existing directory named + '.heroku/python/', which is where the Python buildpack needs + to install its files. This existing directory contains: + + $(find .heroku/python/ -maxdepth 2 || true) + + Writing to internal locations used by the Python buildpack + isn't supported and can cause unexpected errors. + + If you have committed a '.heroku/python/' directory to your + Git repo, you must delete it or use a different location. + + Otherwise, check that an earlier buildpack or 'bin/pre_compile' + hook hasn't created this directory. + + Note: This error replaces the deprecation warning which was + displayed in build logs starting 13th December 2024. + EOF + build_data::set_string "failure_reason" "checks::existing-python-dir" + exit 1 + fi +} + +function checks::existing_venv_dir_present() { + local build_dir="${1}" + local venv_name + + for venv_name in ".venv" "venv"; do + if [[ -f "${build_dir}/${venv_name}/pyvenv.cfg" ]]; then + output::error <<-EOF + Error: Existing '${venv_name}/' directory found. + + Your app's source code contains an existing directory named + '${venv_name}/', which looks like a Python virtual environment: + + $(find "${venv_name}/" -maxdepth 2 | sort || true) + + Including a virtual environment directory in your app source + isn't supported since the files within it are specific to a + single machine and so won't work when run somewhere else. + + If you've committed a '${venv_name}/' directory to your Git repo, you + must delete it and add the directory to your .gitignore file. + + To do this: + 1. Run 'git rm --cached -r ${venv_name}/' to remove the directory + from the Git index. + 2. Create a '.gitignore' file in the root of your repository + if it doesn't already exist. + 3. Add the '${venv_name}/' directory to the .gitignore file as a + new entry on its own line (don't include the quotes). + 4. Stage the change using 'git add .gitignore' and then + 'git commit' all changes. + + For more information, see: + https://docs.github.com/en/get-started/git-basics/ignoring-files + + If the directory was created by a 'bin/pre_compile' hook or + an earlier buildpack, you must instead update them to create + the virtual environment in a different location. + + Note: This error replaces the previous warning which had been + displayed in build logs since 2nd September 2025. + EOF + build_data::set_string "failure_reason" "checks::existing-venv-dir" + build_data::set_string "failure_detail" "${venv_name}" + exit 1 + fi + done +} diff --git a/lib/hooks.sh b/lib/hooks.sh new file mode 100644 index 000000000..b99dc4950 --- /dev/null +++ b/lib/hooks.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +# Used to run the `bin/pre_compile` and `bin/post_compile` scripts if found in the app source, +# allowing for build customisation. +function hooks::run_hook() { + local hook_name="${1}" + local script_path="bin/${hook_name}" + + if [[ -f "${script_path}" ]]; then + local hook_start_time + hook_start_time=$(build_data::current_unix_realtime) + output::step "Running ${script_path} hook" + build_data::set_raw "${hook_name}_hook" "true" + chmod +x "${script_path}" + + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! sub_env "${script_path}" |& output::indent; then + output::error <<-EOF + Error: Failed to run the ${script_path} script. + + We found a '${script_path}' script in your app source, so ran + it to allow for customisation of the build process. + + However, this script exited with a non-zero exit status. + + Fix any errors output by your script above, or remove/rename + the script to prevent it from being run during the build. + EOF + build_data::set_string "failure_reason" "hooks::${hook_name}" + exit 1 + fi + + build_data::set_duration "${hook_name}_hook_duration" "${hook_start_time}" + else + build_data::set_raw "${hook_name}_hook" "false" + fi +} diff --git a/lib/kvstore.sh b/lib/kvstore.sh deleted file mode 100644 index bd5c6fad0..000000000 --- a/lib/kvstore.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash - -# Taken from: https://github.com/heroku/heroku-buildpack-nodejs/blob/main/lib/kvstore.sh - -kv_create() { - local f="${1}" - mkdir -p "$(dirname "${f}")" - touch "${f}" -} - -kv_clear() { - local f="${1}" - echo "" >"${f}" -} - -kv_set() { - if [[ $# -eq 3 ]]; then - local f="${1}" - if [[ -f "${f}" ]]; then - echo "${2}=${3}" >>"${f}" - fi - fi -} - -kv_get() { - if [[ $# -eq 2 ]]; then - local f="${1}" - if [[ -f "${f}" ]]; then - grep "^${2}=" "${f}" | sed -e "s/^${2}=//" | tail -n 1 - fi - fi -} - -# get the value, but wrap it in quotes if it contains a space -kv_get_escaped() { - local value - value=$(kv_get "${1}" "${2}") - if [[ "${value}" =~ [[:space:]]+ ]]; then - echo "\"${value}\"" - else - echo "${value}" - fi -} - -kv_keys() { - local f="${1}" - local keys=() - - if [[ -f "${f}" ]]; then - # Iterate over each line, splitting on the '=' character - # - # The || [[ -n "${key}" ]] statement addresses an issue with reading the last line - # of a file when there is no newline at the end. This will not happen if the file - # is created with this module, but can happen if it is written by hand. - # See: https://stackoverflow.com/questions/12916352/shell-script-read-missing-last-line - while IFS="=" read -r key value || [[ -n "${key}" ]]; do - # if there are any empty lines in the store, skip them - if [[ -n "${key}" ]]; then - keys+=("${key}") - fi - done <"${f}" - - echo "${keys[@]}" | tr ' ' '\n' | sort -u - fi -} - -kv_list() { - local f="${1}" - - kv_keys "${f}" | tr ' ' '\n' | while read -r key; do - if [[ -n "${key}" ]]; then - echo "${key}=$(kv_get_escaped "${f}" "${key}")" - fi - done -} diff --git a/lib/metadata.sh b/lib/metadata.sh deleted file mode 100644 index 20aab3da3..000000000 --- a/lib/metadata.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash - -# Based on: https://github.com/heroku/heroku-buildpack-nodejs/blob/main/lib/metadata.sh - -source "${BUILDPACK_DIR}/lib/kvstore.sh" - -# Variables shared by this whole module -BUILD_DATA_FILE="" -PREVIOUS_BUILD_DATA_FILE="" - -# Must be called before you can use any other methods -meta_init() { - local cache_dir="${1}" - local buildpack_name="${2}" - BUILD_DATA_FILE="${cache_dir}/build-data/${buildpack_name}" - PREVIOUS_BUILD_DATA_FILE="${cache_dir}/build-data/${buildpack_name}-prev" -} - -# Moves the data from the last build into the correct place, and clears the store -# This should be called after meta_init in bin/compile -meta_setup() { - # if the file already exists because it's from the last build, save it - if [[ -f "${BUILD_DATA_FILE}" ]]; then - cp "${BUILD_DATA_FILE}" "${PREVIOUS_BUILD_DATA_FILE}" - fi - - kv_create "${BUILD_DATA_FILE}" - kv_clear "${BUILD_DATA_FILE}" -} - -# Force removal of exiting data file state. This is mostly useful during testing and not -# expected to be used during buildpack execution. -meta_force_clear() { - [[ -f "${BUILD_DATA_FILE}" ]] && rm "${BUILD_DATA_FILE}" - [[ -f "${PREVIOUS_BUILD_DATA_FILE}" ]] && rm "${PREVIOUS_BUILD_DATA_FILE}" -} - -meta_get() { - kv_get "${BUILD_DATA_FILE}" "${1}" -} - -meta_set() { - kv_set "${BUILD_DATA_FILE}" "${1}" "${2}" -} - -# Similar to mtime from buildpack-stdlib -meta_time() { - local key="${1}" - local start="${2}" - local end="${3:-$(nowms)}" - local time - time="$(echo "${start}" "${end}" | awk '{ printf "%.3f", ($2 - $1)/1000 }')" - kv_set "${BUILD_DATA_FILE}" "${key}" "${time}" -} - -# Retrieve a value from a previous build if it exists -# This is useful to give the user context about what changed if the -# build has failed. Ex: -# - changed stacks -# - deployed with a new major version of Node -# - etc -meta_prev_get() { - kv_get "${PREVIOUS_BUILD_DATA_FILE}" "${1}" -} - -log_meta_data() { - # print all values on one line in logfmt format - # https://brandur.org/logfmt - # the echo call ensures that all values are printed on a single line - # shellcheck disable=SC2005 disable=SC2046 - echo $(kv_list "${BUILD_DATA_FILE}") -} diff --git a/lib/output.sh b/lib/output.sh new file mode 100644 index 000000000..1f6dda760 --- /dev/null +++ b/lib/output.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +ANSI_BLUE=$'\e[1;34m' +ANSI_RED=$'\e[1;31m' +ANSI_YELLOW=$'\e[1;33m' +ANSI_RESET=$'\e[0m' + +# Output a single line step message to stdout. +# +# Usage: +# ``` +# output::step "Installing Python ..." +# ``` +function output::step() { + echo "-----> ${1}" +} + +# Indent passed stdout. Typically used to indent command output within a step. +# +# Usage: +# ``` +# pip install ... |& output::indent +# ``` +function output::indent() { + sed --unbuffered "s/^/ /" +} + +# Output a styled multi-line notice message to stderr. +# +# Usage: +# ``` +# output::notice <<-EOF +# Note: The note summary. +# +# Detailed description. +# EOF +# ``` +function output::notice() { + echo >&2 + sed --expression "s/^/${ANSI_BLUE} ! /" --expression "s/$/${ANSI_RESET}/" >&2 + echo >&2 +} + +# Output a styled multi-line warning message to stderr. +# +# Usage: +# ``` +# output::warning <<-EOF +# Warning: The warning summary. +# +# Detailed description. +# EOF +# ``` +function output::warning() { + echo >&2 + sed --expression "s/^/${ANSI_YELLOW} ! /" --expression "s/$/${ANSI_RESET}/" >&2 + echo >&2 +} + +# Output a styled multi-line error message to stderr. +# +# Usage: +# ``` +# output::error <<-EOF +# Error: The error summary. +# +# Detailed description. +# EOF +# ``` +function output::error() { + echo >&2 + sed --expression "s/^/${ANSI_RED} ! /" --expression "s/$/${ANSI_RESET}/" >&2 + echo >&2 +} diff --git a/lib/package_manager.sh b/lib/package_manager.sh new file mode 100644 index 000000000..95c4868b6 --- /dev/null +++ b/lib/package_manager.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +function package_manager::determine_package_manager() { + local build_dir="${1}" + local package_managers_found=() + local package_managers_found_display_text=() + + if [[ -f "${build_dir}/Pipfile.lock" ]]; then + package_managers_found+=(pipenv) + package_managers_found_display_text+=("Pipfile.lock (Pipenv)") + elif [[ -f "${build_dir}/Pipfile" ]]; then + output::error <<-'EOF' + Error: No 'Pipfile.lock' found! + + A 'Pipfile' file was found, however, the associated 'Pipfile.lock' + Pipenv lockfile wasn't. This means your app dependency versions + aren't pinned, which means the package versions used on Heroku + might not match those installed in other environments. + + Using Pipenv in this way is unsafe and no longer supported. + + Run 'pipenv lock' locally to generate the lockfile, and make sure + that 'Pipfile.lock' isn't listed in '.gitignore' or '.slugignore'. + + Alternatively, if you wish to switch to another package manager, + delete your 'Pipfile' and then add either a 'requirements.txt', + 'poetry.lock' or 'uv.lock' file. + + If you aren't sure which package manager to use, we recommend + trying uv, since it supports lockfiles, is extremely fast, and + is actively maintained by a full-time team: + https://docs.astral.sh/uv/ + EOF + build_data::set_string "failure_reason" "package-manager::pipenv-missing-lockfile" + exit 1 + fi + + if [[ -f "${build_dir}/requirements.txt" ]]; then + package_managers_found+=(pip) + package_managers_found_display_text+=("requirements.txt (pip)") + fi + + if [[ -f "${build_dir}/poetry.lock" ]]; then + package_managers_found+=(poetry) + package_managers_found_display_text+=("poetry.lock (Poetry)") + fi + + if [[ -f "${build_dir}/uv.lock" ]]; then + package_managers_found+=(uv) + package_managers_found_display_text+=("uv.lock (uv)") + fi + + local num_package_managers_found=${#package_managers_found[@]} + + case "${num_package_managers_found}" in + 1) + echo "${package_managers_found[0]}" + return 0 + ;; + 0) + if [[ -f "${build_dir}/setup.py" ]]; then + output::error <<-EOF + Error: Implicit setup.py file support has been sunset. + + Your app currently only has a setup.py file and no Python + package manager files. This means that the buildpack can't + tell which package manager you want to use, and whether to + install your project in editable mode or not. + + Previously the buildpack guessed and used pip to install your + dependencies in editable mode. However, this fallback was + deprecated in September 2025 and has now been sunset. + + You must now add an explicit package manager file to your app, + such as a requirements.txt, poetry.lock or uv.lock file. + + To continue using your setup.py file with pip in editable + mode, create a new file in the root directory of your app + named 'requirements.txt' containing the requirement + '--editable .' (without quotes). + + Alternatively, if you wish to switch to another package + manager, we recommend uv, since it supports lockfiles, is + faster, and is actively maintained by a full-time team: + https://docs.astral.sh/uv/ + EOF + build_data::set_string "failure_reason" "package-manager::setup-py-only" + exit 1 + fi + + output::error <<-EOF + Error: Couldn't find any supported Python package manager files. + + A Python app on Heroku must have either a 'requirements.txt', + 'Pipfile.lock', 'poetry.lock' or 'uv.lock' package manager file + in the root directory of its source code. + + Currently the root directory of your app contains: + + $(ls -1A --indicator-style=slash "${build_dir}" || true) + + If your app already has a package manager file, check that it: + + 1. Is in the top level directory (not a subdirectory). + 2. Has the correct spelling (the filenames are case-sensitive). + 3. Isn't listed in '.gitignore' or '.slugignore'. + 4. Has been added to the Git repository using 'git add --all' + and then committed using 'git commit'. + + Otherwise, add a package manager file to your app. If your app has + no dependencies, then create an empty 'requirements.txt' file. + + If you aren't sure which package manager to use, we recommend + trying uv, since it supports lockfiles, is extremely fast, and + is actively maintained by a full-time team: + https://docs.astral.sh/uv/ + + For help with using Python on Heroku, see: + https://devcenter.heroku.com/articles/getting-started-with-python + https://devcenter.heroku.com/articles/python-support + EOF + build_data::set_string "failure_reason" "package-manager::none-found" + exit 1 + ;; + *) + output::error <<-EOF + Error: Multiple Python package manager files were found. + + Exactly one package manager file should be present in your app's + source code, however, several were found: + + $(printf -- "%s\n" "${package_managers_found_display_text[@]}") + + Previously, the buildpack guessed which package manager to use + and installed your dependencies with the first package manager + listed above. However, this implicit behaviour was deprecated + in November 2024 and is now no longer supported. + + You must decide which package manager you want to use with your + app, and then delete the file(s) and any config from the others. + + If you aren't sure which package manager to use, we recommend + trying uv, since it supports lockfiles, is extremely fast, and + is actively maintained by a full-time team: + https://docs.astral.sh/uv/ + + Note: If you use a third-party uv or Poetry buildpack, you must + remove it from your app, since it's no longer required and the + requirements.txt file it generates will trigger this error. See: + https://devcenter.heroku.com/articles/managing-buildpacks#remove-classic-buildpacks + EOF + build_data::set_string "failure_reason" "package-manager::multiple-found" + build_data::set_string "failure_detail" "$( + IFS=, + echo "${package_managers_found[*]}" + )" + exit 1 + ;; + esac +} diff --git a/lib/pip.sh b/lib/pip.sh new file mode 100644 index 000000000..479ad5e32 --- /dev/null +++ b/lib/pip.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +PIP_VERSION=$(utils::get_requirement_version 'pip') +# This is hardcoded rather than managed by Dependabot since we can't update to newer versions due to: +# https://github.com/heroku/heroku-buildpack-python/pull/1630#issuecomment-2324236653 +# Also, once we drop support for older Pythons we'll stop installing setuptools entirely. +SETUPTOOLS_VERSION='70.3.0' + +function pip::install_pip() { + local python_home="${1}" + local python_major_version="${2}" + local export_file="${3}" + local profile_d_file="${4}" + + build_data::set_string "pip_version" "${PIP_VERSION}" + + local packages_to_install=( + "pip==${PIP_VERSION}" + ) + local packages_display_text="pip ${PIP_VERSION}" + + # We only install setuptools on Python 3.12 and older, since: + # - pip now uses isolated build environments into which it installs setuptools and wheel + # if needed when installing packages from an sdist. + # - Most of the Python ecosystem has stopped installing them for Python 3.12+ already. + # See the Python CNB's removal for more details: https://github.com/heroku/buildpacks-python/pull/243 + if [[ "${python_major_version}" == +(3.10|3.11|3.12) ]]; then + build_data::set_string "setuptools_version" "${SETUPTOOLS_VERSION}" + packages_to_install+=( + "setuptools==${SETUPTOOLS_VERSION}" + ) + packages_display_text+=" and setuptools ${SETUPTOOLS_VERSION}" + fi + + # Note: We still perform this install step even if the cache was reused, since we have no guarantee + # that the cached package versions are correct (different versions could have been specified in the + # app's requirements.txt in the last build). The install will be a no-op if the versions match. + output::step "Installing ${packages_display_text}" + + # We use the pip wheel bundled within Python's standard library to install our chosen + # pip version, since it's faster than `ensurepip` followed by an upgrade in place. + local bundled_pip_module_path + bundled_pip_module_path="$(utils::bundled_pip_module_path "${python_home}" "${python_major_version}")" + + # `--isolated`: Prevents any custom pip configuration added by third party buildpacks (via env + # vars or global config files) from breaking package manager bootstrapping. + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + python "${bundled_pip_module_path}" \ + install \ + --disable-pip-version-check \ + --isolated \ + --no-cache-dir \ + --no-input \ + --quiet \ + "${packages_to_install[@]}" \ + |& output::indent + }; then + output::error <<-EOF + Error: Unable to install pip. + + In some cases, this happens due to a temporary issue with + the network connection or Python Package Index (PyPI). + + Try building again to see if the error resolves itself. + + If that doesn't help, check the status of PyPI here: + https://status.python.org + EOF + build_data::set_string "failure_reason" "install-package-manager::pip" + exit 1 + fi + + # Saves us having to pass `--disable-pip-version-check` each time pip is used. Note: We can't use + # this for the pip install usage above, since `--isolated` disables reading of `PIP_` env vars. + export PIP_DISABLE_PIP_VERSION_CHECK="1" + + # Set the same env vars in the environment used by later buildpacks. + cat >>"${export_file}" <<-EOF + export PIP_DISABLE_PIP_VERSION_CHECK="1" + EOF + + # And the environment used at app run-time. + cat >>"${profile_d_file}" <<-EOF + export PIP_DISABLE_PIP_VERSION_CHECK="1" + EOF +} + +function pip::install_dependencies() { + # Make select pip config vars set on the Heroku app available to pip. + # TODO: Expose all config vars (after suitable checks are added for unsafe env vars) + # to allow for the env var interpolation feature of requirements files to work. + # + # PIP_EXTRA_INDEX_URL allows for an alternate pypi URL to be used. + # shellcheck disable=SC2154 # TODO: Env var is referenced but not assigned. + if [[ -r "${ENV_DIR}/PIP_EXTRA_INDEX_URL" ]]; then + PIP_EXTRA_INDEX_URL="$(cat "${ENV_DIR}/PIP_EXTRA_INDEX_URL")" + export PIP_EXTRA_INDEX_URL + fi + + local pip_install_command=( + pip + install + -r requirements.txt + ) + + # Install test dependencies too when the buildpack is invoked via `bin/test-compile` on Heroku CI. + # We install both requirements files at the same time to allow pip to resolve version conflicts. + if [[ -v INSTALL_TEST && -f requirements-test.txt ]]; then + pip_install_command+=(-r requirements-test.txt) + fi + + # We only display the most relevant command args here, to improve the signal to noise ratio. + output::step "Installing dependencies using '${pip_install_command[*]}'" + + local install_log + install_log=$(mktemp) + + # The sed usage is to reduce the verbosity of output lines like: + # ...when using Python 3.10 and older: + # "Requirement already satisfied: typing-extensions==4.15.0 in ./.heroku/python/lib/python3.10/site-packages (from -r requirements.txt (line 2)) (4.15.0)" + # ...when using Python 3.11+: + # "Requirement already satisfied: typing-extensions==4.15.0 in /app/.heroku/python/lib/python3.14/site-packages (from -r requirements.txt (line 5)) (4.15.0)" + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + "${pip_install_command[@]}" \ + --exists-action=w \ + --no-cache-dir \ + --no-input \ + --progress-bar off \ + --src='/app/.heroku/python/src' \ + |& tee "${install_log}" \ + |& sed --unbuffered --regexp-extended \ + --expression 's# in (/app|\.)/\.heroku/python/lib/python[0-9.]+/site-packages##' \ + |& output::indent + }; then + # TODO: Overhaul warnings and combine them with error handling. + show-warnings "${install_log}" + + output::error <<-EOF + Error: Unable to install dependencies using pip. + + See the log output above for more information. + EOF + build_data::set_string "failure_reason" "install-dependencies::pip" + exit 1 + fi +} diff --git a/lib/pipenv.sh b/lib/pipenv.sh new file mode 100644 index 000000000..073f00e87 --- /dev/null +++ b/lib/pipenv.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +PIPENV_VERSION=$(utils::get_requirement_version 'pipenv') + +function pipenv::install_pipenv() { + local python_home="${1}" + local python_major_version="${2}" + local export_file="${3}" + local profile_d_file="${4}" + + # Ideally we would only make Pipenv available during the build to reduce slug size, however, + # the buildpack has historically not done that and so some apps are relying on it at run-time + # (for example via `pipenv run` commands in their Procfile). As such, we have to store it in + # the build directory, but must do so via the symlinked `/app/.heroku/python` path so the + # venv doesn't break when the build directory is relocated to /app at run-time. + local pipenv_root="${python_home}/pipenv" + + # We nest the venv and then symlink the `pipenv` script to prevent the rest of `venv/bin/` + # (such as entrypoint scripts from Pipenv's dependencies, or the venv's activation scripts) + # from being added to PATH and exposed to the app. + local pipenv_bin_dir="${pipenv_root}/bin" + local pipenv_venv_dir="${pipenv_root}/venv" + + build_data::set_string "pipenv_version" "${PIPENV_VERSION}" + + # The earlier buildpack cache invalidation step will have already handled the case where the + # Pipenv version has changed, so here we only need to check that a Pipenv install exists. + # Note: We don't need to use the `-e` trick of `install_poetry()` since we're installing into + # a constant path, rather than the cache directory (which can change location). + if [[ -f "${pipenv_bin_dir}/pipenv" ]]; then + output::step "Using cached Pipenv ${PIPENV_VERSION}" + else + output::step "Installing Pipenv ${PIPENV_VERSION}" + + mkdir -p "${pipenv_root}" + + # We use the pip wheel bundled within Python's standard library to install Pipenv, + # since Pipenv vendors its own pip, so doesn't need an install in the venv. + python -m venv --without-pip "${pipenv_venv_dir}" + + local bundled_pip_module_path + bundled_pip_module_path="$(utils::bundled_pip_module_path "${python_home}" "${python_major_version}")" + + # `--isolated`: Prevents any custom pip configuration added by third party buildpacks (via env + # vars or global config files) from breaking package manager bootstrapping. + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + "${pipenv_venv_dir}/bin/python" "${bundled_pip_module_path}" \ + install \ + --disable-pip-version-check \ + --isolated \ + --no-cache-dir \ + --no-input \ + --quiet \ + "pipenv==${PIPENV_VERSION}" \ + |& output::indent + }; then + output::error <<-EOF + Error: Unable to install Pipenv. + + In some cases, this happens due to a temporary issue with + the network connection or Python Package Index (PyPI). + + Try building again to see if the error resolves itself. + + If that doesn't help, check the status of PyPI here: + https://status.python.org + EOF + build_data::set_string "failure_reason" "install-package-manager::pipenv" + exit 1 + fi + + mkdir -p "${pipenv_bin_dir}" + ln --symbolic --no-target-directory "${pipenv_venv_dir}/bin/pipenv" "${pipenv_bin_dir}/pipenv" + fi + + export PATH="${pipenv_bin_dir}:${PATH}" + # Force Pipenv to manage the system Python site-packages instead of using venvs. + export PIPENV_SYSTEM="1" + # Hide Pipenv's notice about finding/using an existing virtual environment. + export PIPENV_VERBOSITY="-1" + # Work around a Pipenv bug when using `--system`, whereby it doesn't correctly install dependencies + # that happen to also be a dependency of Pipenv (such as `certifi` and `packaging`). In general + # Pipenv's support for its `--system` mode is very buggy. Longer term we should explore moving + # to venvs, however, that will need to be coordinated across all package managers and will also + # change paths for Python which could break other use cases. Be careful removing this even if the + # `pip_basic` test that installs certifi/packaging still passes, since the repro seems to depend + # on specific package version combinations / other factors and so the testcase is very fragile. + export VIRTUAL_ENV="${python_home}" + + # Set the same env vars in the environment used by later buildpacks. + cat >>"${export_file}" <<-EOF + export PATH="${pipenv_bin_dir}:\${PATH}" + export PIPENV_SYSTEM="1" + export PIPENV_VERBOSITY="-1" + export VIRTUAL_ENV="${python_home}" + EOF + + # And the environment used at app run-time. + cat >>"${profile_d_file}" <<-EOF + export PATH="${pipenv_bin_dir}:\${PATH}" + export PIPENV_SYSTEM="1" + export PIPENV_VERBOSITY="-1" + export VIRTUAL_ENV="${python_home}" + EOF +} + +# Previous versions of the buildpack used to cache the checksum of the lockfile to allow +# for skipping pipenv install if the lockfile was unchanged. However, this is not always safe +# to do (the lockfile can refer to dependencies that can change independently of the lockfile, +# for example, when using a local non-editable file dependency), so we no longer ever skip +# install, and instead defer to Pipenv to determine whether install is actually a no-op. +function pipenv::install_dependencies() { + # Make select pip config vars set on the Heroku app available to the pip used by Pipenv. + # TODO: Expose all config vars (after suitable checks are added for unsafe env vars). + # + # PIP_EXTRA_INDEX_URL allows for an alternate pypi URL to be used. + # shellcheck disable=SC2154 # TODO: Env var is referenced but not assigned. + if [[ -r "${ENV_DIR}/PIP_EXTRA_INDEX_URL" ]]; then + PIP_EXTRA_INDEX_URL="$(cat "${ENV_DIR}/PIP_EXTRA_INDEX_URL")" + export PIP_EXTRA_INDEX_URL + fi + + # Note: We can't use `pipenv sync` since it doesn't support `--deploy` and so won't abort + # if the lockfile is out of date. + local pipenv_install_command=( + pipenv + install + --deploy + ) + + # Install test dependencies too when the buildpack is invoked via `bin/test-compile` on Heroku CI. + if [[ -v INSTALL_TEST ]]; then + pipenv_install_command+=(--dev) + fi + + # We only display the most relevant command args here, to improve the signal to noise ratio. + output::step "Installing dependencies using '${pipenv_install_command[*]}'" + + local install_log + install_log=$(mktemp) + + # TODO: Expose app config vars to the install command as part of doing so for all package managers. + # `PIPENV_NOSPIN`: Disable progress spinners. + # `PIP_SRC`: Override the editable VCS repo location from its default of inside the build directory + # (Pipenv uses pip internally, and doesn't offer its own config option for this). + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + PIPENV_NOSPIN="1" PIP_SRC="/app/.heroku/python/src" \ + "${pipenv_install_command[@]}" \ + |& tee "${install_log}" \ + |& output::indent + }; then + # TODO: Overhaul warnings and combine them with error handling. + show-warnings "${install_log}" + + output::error <<-EOF + Error: Unable to install dependencies using Pipenv. + + See the log output above for more information. + EOF + build_data::set_string "failure_reason" "install-dependencies::pipenv" + exit 1 + fi +} diff --git a/lib/poetry.sh b/lib/poetry.sh new file mode 100644 index 000000000..507185b03 --- /dev/null +++ b/lib/poetry.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +POETRY_VERSION=$(utils::get_requirement_version 'poetry') + +function poetry::install_poetry() { + local python_home="${1}" + local python_major_version="${2}" + local cache_dir="${3}" + local export_file="${4}" + + # We store Poetry in the build cache, since we only need it during the build. + local poetry_root="${cache_dir}/.heroku/python-poetry" + + # We nest the venv and then symlink the `poetry` script to prevent the rest of `venv/bin/` + # (such as entrypoint scripts from Poetry's dependencies, or the venv's activation scripts) + # from being added to PATH and exposed to the app. + local poetry_bin_dir="${poetry_root}/bin" + local poetry_venv_dir="${poetry_root}/venv" + + build_data::set_string "poetry_version" "${POETRY_VERSION}" + + # The earlier buildpack cache invalidation step will have already handled the case where the + # Poetry version has changed, so here we only need to check that a valid Poetry install exists. + # venvs are not relocatable, so if the cache directory were ever to change location, the cached + # Poetry installation would stop working. To save having to track the cache location via build + # metadata, we instead rely on the fact that relocating the venv would also break the absolute + # path `poetry` symlink created below, and that the `-e` condition not only checks that the + # `poetry` symlink exists, but that its target is also valid. + # Note: Whilst the Codon cache location remains stable from build to build, for Heroku CI the + # cache directory currently does not, so the cached Poetry will always be invalidated there. + if [[ -e "${poetry_bin_dir}/poetry" ]]; then + output::step "Using cached Poetry ${POETRY_VERSION}" + else + output::step "Installing Poetry ${POETRY_VERSION}" + + # The Poetry directory will already exist in the relocated cache case mentioned above. + rm -rf "${poetry_root}" + mkdir -p "${poetry_root}" + + # We use the pip wheel bundled within Python's standard library to install Poetry. + # Whilst Poetry does still require pip for some tasks (such as package uninstalls), + # it bundles its own copy for use as a fallback. As such we don't need to install pip + # into the Poetry venv (and in fact, Poetry wouldn't use this install anyway, since + # it only finds an external pip if it exists in the target venv). + python -m venv --without-pip "${poetry_venv_dir}" + + local bundled_pip_module_path + bundled_pip_module_path="$(utils::bundled_pip_module_path "${python_home}" "${python_major_version}")" + + # `--isolated`: Prevents any custom pip configuration added by third party buildpacks (via env + # vars or global config files) from breaking package manager bootstrapping. + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + "${poetry_venv_dir}/bin/python" "${bundled_pip_module_path}" \ + install \ + --disable-pip-version-check \ + --isolated \ + --no-cache-dir \ + --no-input \ + --quiet \ + "poetry==${POETRY_VERSION}" \ + |& output::indent + }; then + output::error <<-EOF + Error: Unable to install Poetry. + + In some cases, this happens due to a temporary issue with + the network connection or Python Package Index (PyPI). + + Try building again to see if the error resolves itself. + + If that doesn't help, check the status of PyPI here: + https://status.python.org + EOF + build_data::set_string "failure_reason" "install-package-manager::poetry" + exit 1 + fi + + mkdir -p "${poetry_bin_dir}" + # NB: This symlink must not use `--relative`, since we want the symlink to break if the cache + # (and thus venv) were ever relocated - so that it triggers a reinstall (see above). + ln --symbolic --no-target-directory "${poetry_venv_dir}/bin/poetry" "${poetry_bin_dir}/poetry" + fi + + export PATH="${poetry_bin_dir}:${PATH}" + # Force Poetry to manage the system Python site-packages instead of using venvs. + export POETRY_VIRTUALENVS_CREATE="false" + # Force Poetry to use our Python rather than scanning PATH (which might pick system Python). + # Though this currently doesn't work as documented: https://github.com/python-poetry/poetry/issues/10226 + export POETRY_VIRTUALENVS_USE_POETRY_PYTHON="true" + + # Set the same env vars in the environment used by later buildpacks. + cat >>"${export_file}" <<-EOF + export PATH="${poetry_bin_dir}:\${PATH}" + export POETRY_VIRTUALENVS_CREATE="false" + export POETRY_VIRTUALENVS_USE_POETRY_PYTHON="true" + EOF +} + +# Note: We cache site-packages since: +# - It results in faster builds than only caching Poetry's download/wheel cache. +# - It improves the UX of the build log, since Poetry will display which packages were +# added/removed since the last successful build. +# - It's safe to do so, since `poetry sync` fully manages the environment (including +# e.g. uninstalling packages when they are removed from the lockfile). +# +# With site-packages cached there is no need to persist Poetry's download/wheel cache in the build +# cache, so we let Poetry write it to the home directory where it will be discarded at the end of +# the build. We don't use `--no-cache` since the cache still offers benefits (such as avoiding +# repeat downloads of PEP-517/518 build requirements). +function poetry::install_dependencies() { + local poetry_install_command=( + poetry + sync + ) + + # On Heroku CI, all default Poetry dependency groups are installed (i.e. all groups minus those + # marked as `optional = true`). Otherwise, only the 'main' Poetry dependency group is installed. + if [[ ! -v INSTALL_TEST ]]; then + poetry_install_command+=(--only main) + fi + + # We only display the most relevant command args here, to improve the signal to noise ratio. + output::step "Installing dependencies using '${poetry_install_command[*]}'" + + local install_log + install_log=$(mktemp) + + # `--compile`: Compiles Python bytecode, to improve app boot times (pip does this by default). + # `--no-ansi`: Whilst we'd prefer to enable colour if possible, Poetry also emits ANSI escape + # codes for redrawing lines, which renders badly in persisted build logs. + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + "${poetry_install_command[@]}" \ + --compile \ + --no-ansi \ + --no-interaction \ + |& tee "${install_log}" \ + |& sed --unbuffered --expression '/Skipping virtualenv creation/d' \ + |& output::indent + }; then + # TODO: Overhaul warnings and combine them with error handling. + show-warnings "${install_log}" + + output::error <<-EOF + Error: Unable to install dependencies using Poetry. + + See the log output above for more information. + EOF + build_data::set_string "failure_reason" "install-dependencies::poetry" + exit 1 + fi +} diff --git a/lib/python.sh b/lib/python.sh new file mode 100644 index 000000000..0fa91862d --- /dev/null +++ b/lib/python.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +S3_BASE_URL="https://heroku-buildpack-python.s3.dualstack.us-east-1.amazonaws.com" + +function python::install() { + local build_dir="${1}" + local stack="${2}" + local python_full_version="${3}" + local python_major_version="${4}" + local python_version_origin="${5}" + + local install_python_start_time + install_python_start_time=$(build_data::current_unix_realtime) + local install_dir="${build_dir}/.heroku/python" + + if [[ -f "${install_dir}/bin/python" ]]; then + output::step "Using cached install of Python ${python_full_version}" + else + output::step "Installing Python ${python_full_version}" + + mkdir -p "${install_dir}" + + # Calculating the Ubuntu version from the stack name saves having to shell out to `lsb_release`. + local ubuntu_version="${stack/heroku-/}.04" + local arch + arch=$(dpkg --print-architecture) + # e.g.: https://heroku-buildpack-python.s3.dualstack.us-east-1.amazonaws.com/python-3.14.0-ubuntu-24.04-amd64.tar.zst + local python_url="${S3_BASE_URL}/python-${python_full_version}-ubuntu-${ubuntu_version}-${arch}.tar.zst" + + local error_log + error_log=$(mktemp) + + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + { + # We set max-time for improved UX/metrics for hanging downloads compared to relying on the build + # system timeout. We don't use `--speed-limit` since it gives worse error messages when used with + # retries and piping to tar. The Python archives are ~10 MB so only take ~1s to download on Heroku, + # so we set low timeouts to reduce delays before retries. However, we allow customising the timeouts + # to support non-Heroku environments that may be far from `us-east-1` or have a slower connection. + # We use `--no-progress-meter` rather than `--silent` so that retry status messages are printed. + curl \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-3}" \ + --fail \ + --max-time "${CURL_TIMEOUT:-60}" \ + --no-progress-meter \ + --retry-max-time "${CURL_TIMEOUT:-60}" \ + --retry 5 \ + --retry-connrefused \ + "${python_url}" \ + | tar \ + --directory "${install_dir}" \ + --extract \ + --zstd + } \ + |& tee "${error_log}" \ + |& output::indent + }; then + local latest_known_patch_version + latest_known_patch_version="$(python_version::resolve_python_version "${python_major_version}" "${python_version_origin}")" + # Ideally we would inspect the HTTP status code directly instead of grepping, however: + # 1. We want to pipe to tar (since it's faster than performing the download and + # decompression/extraction as separate steps), so can't write to stdout. + # 2. We want to display the original stderr to the user, so can't write to stderr. + # 3. Curl's `--write-out` feature only supports outputting to a file (as opposed to + # stdout/stderr) as of curl v8.3.0, which is newer than the curl on Heroku-22. + # This has an integration test run against all stacks, which will mean we will know + # if future versions of curl change the error message string. + # + # We have to check for HTTP 403s too, since S3 will return a 403 instead of a 404 for + # missing files, if the S3 bucket does not have public list permissions enabled. + if [[ "${python_full_version}" != "${latest_known_patch_version}" ]] && grep --quiet "The requested URL returned error: 40[34]" "${error_log}"; then + output::error <<-EOF + Error: The requested Python version isn't available. + + Your app's ${python_version_origin} file specifies a Python version + of ${python_full_version}, however, we couldn't find that version on S3. + + Check that this Python version has been released upstream, + and that the Python buildpack has added support for it: + https://www.python.org/downloads/ + https://github.com/heroku/heroku-buildpack-python/blob/main/CHANGELOG.md + + If it has, make sure that you are using the latest version + of this buildpack, and haven't pinned to an older release: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + + We also strongly recommend that you don't pin your app to an + exact Python version such as ${python_full_version}, and instead only specify + the major Python version of ${python_major_version} in your ${python_version_origin} file. + This will allow your app to receive the latest available Python + patch version automatically, and prevent this type of error. + EOF + build_data::set_string "failure_reason" "python-version::unknown-patch" + build_data::set_string "failure_detail" "${python_full_version}" + else + output::error <<-EOF + Error: Unable to download/install Python. + + An error occurred while downloading/installing the Python + runtime archive from: + ${python_url} + + In some cases, this happens due to a temporary issue with + the network connection or server. + + First, make sure that you are using the latest version + of this buildpack, and haven't pinned to an older release: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + + Then try building again to see if the error resolves itself. + EOF + build_data::set_string "failure_reason" "install-python" + # e.g.: 'curl: (6) Could not resolve host: heroku-buildpack-python.s3.dualstack.us-east-1.amazonaws.com' + build_data::set_string "failure_detail" "$(head --lines=1 "${error_log}" || true)" + fi + + exit 1 + fi + fi + + build_data::set_duration "python_install_duration" "${install_python_start_time}" +} diff --git a/lib/python_version.sh b/lib/python_version.sh new file mode 100644 index 000000000..0b814cd61 --- /dev/null +++ b/lib/python_version.sh @@ -0,0 +1,746 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +LATEST_PYTHON_3_10="3.10.20" +LATEST_PYTHON_3_11="3.11.15" +LATEST_PYTHON_3_12="3.12.13" +LATEST_PYTHON_3_13="3.13.14" +LATEST_PYTHON_3_14="3.14.6" + +OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION=10 +NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION=14 + +DEFAULT_PYTHON_FULL_VERSION="${LATEST_PYTHON_3_14}" +DEFAULT_PYTHON_MAJOR_VERSION="${DEFAULT_PYTHON_FULL_VERSION%.*}" + +# Integer with no redundant leading zeros. +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}" + +# Misspellings of the `.python-version` file seen in the wild. +MISSPELLED_PYTHON_VERSION_FILE_NAMES=( + " .python-version" + ". python-version" + ".python_version.txt" + ".python_version" + ".python-version " + ",python-version" + ".python-version." + ".python-version.py" + ".python-version.rtf" + ".python-version.txt" + ".python-version.TXT" + ".Python-version" + ".python.version" + ".pythonversion" + "'.python-version'" + "python_version.txt" + "python_version" + "python-version." + "python-version.txt" + "python-version" +) + +# Determine what Python version has been requested for the project. +# +# Returns a version request of form N.N or N.N.N, with basic validation checks that the version +# matches those forms. EOL version checks will be performed later, when this version request is +# resolved to an exact Python version. +# +# If an app specifies the Python version via multiple means, then the order of precedence is: +# 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 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 +# the same Python major version in perpetuity (aka sticky versions). Sticky versioning leads to +# confusing UX so is something we want to deprecate/sunset in the future (and have already done +# so in the Python CNB). +function python_version::read_requested_python_version() { + local build_dir="${1}" + local package_manager="${2}" + local cached_python_full_version="${3}" + # We use the Bash 4.3+ `nameref` feature to pass back multiple values from this function + # without having to hardcode globals. See: https://stackoverflow.com/a/38997681 + declare -n version="${4}" + declare -n origin="${5}" + + local runtime_txt_path="${build_dir}/runtime.txt" + if [[ -f "${runtime_txt_path}" ]]; then + version="$(python_version::parse_runtime_txt "${runtime_txt_path}")" + origin="runtime.txt" + return 0 + fi + + local misspelled_python_version_file_name + for misspelled_python_version_file_name in "${MISSPELLED_PYTHON_VERSION_FILE_NAMES[@]}"; do + if [[ -f "${build_dir}/${misspelled_python_version_file_name}" ]]; then + # We use quotes around the current filename since one of the misspellings seen in the + # wild is where the filename contains trailing whitespace. + output::error <<-EOF + Error: Your .python-version file is spelled incorrectly. + + Your app's .python-version file currently has the filename: + '${misspelled_python_version_file_name}' + + However, the correct spelling is (without quotes): + '.python-version' + + You must rename your file to the correct name. + EOF + build_data::set_string "failure_reason" "python-version-file::misspelled" + build_data::set_string "failure_detail" "${misspelled_python_version_file_name}" + exit 1 + fi + done + + local python_version_file_path="${build_dir}/.python-version" + if [[ -f "${python_version_file_path}" ]]; then + version="$(python_version::parse_python_version_file "${python_version_file_path}")" + origin=".python-version" + return 0 + fi + + # Record the names of files similar to `.python-version` in the root of the app, so we can track + # additional misspellings that might need to be added to `MISSPELLED_PYTHON_VERSION_FILE_NAMES`. + local python_version_files + python_version_files="$( + find . -maxdepth 1 -type f -iregex '\./.*python.?version.*' -printf '%P\n' | sort | tr '\n' ',' || true + )" + if [[ -n "${python_version_files}" ]]; then + build_data::set_string "python_version_files" "${python_version_files}" + 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. + if [[ -n "${version}" ]]; then + origin="Pipfile.lock" + return 0 + fi + fi + + # Protect against unsupported (e.g. PyPy) or invalid versions being found in the cache metadata. + if [[ "${cached_python_full_version}" =~ ^${PYTHON_FULL_VERSION_REGEX}$ ]]; then + local cached_python_major_version="${cached_python_full_version%.*}" + version="${cached_python_major_version}" + origin="cached" + else + version="${DEFAULT_PYTHON_MAJOR_VERSION}" + # shellcheck disable=SC2034 # This isn't unused, Shellcheck doesn't follow namerefs. + origin="default" + fi +} + +# 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 runtime_txt_path="${1}" + local trimmed_contents + # The file contents with commented lines and leading/trailing whitespace removed. + trimmed_contents="$(python_version::read_trimmed_version_lines "${runtime_txt_path}")" + + # The version must be of form `python-N.N` or `python-N.N.N`. + if [[ "${trimmed_contents}" =~ ^python-(${PYTHON_VERSION_REGEX})$ ]]; then + local version="${BASH_REMATCH[1]}" + echo "${version}" + else + local instructions + instructions="$(python_version::python_version_file_instructions "runtime.txt" "${DEFAULT_PYTHON_MAJOR_VERSION}")" + # We intentionally don't display the current contents of the file here, to prevent users + # from copying that into the .python-version file instead of our example valid version. + output::error <<-EOF + Error: Invalid Python version in runtime.txt. + + The Python version specified in your runtime.txt file isn't + in the correct format. + + However, the runtime.txt file is deprecated since it has been + replaced by the more widely supported .python-version file: + https://devcenter.heroku.com/changelog-items/3141 + + As such, we recommend that you switch to using .python-version + instead of fixing your runtime.txt file. + + ${instructions} + EOF + build_data::set_string "failure_reason" "runtime-txt::invalid-version" + build_data::set_string "failure_detail" "${trimmed_contents:0:100}" + exit 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 python_version_file_path="${1}" + local versions=() + local version + + # shellcheck disable=SC2312 # Shellcheck doesn't take the `wait $!` into account. + mapfile -t versions < <(python_version::read_trimmed_version_lines "${python_version_file_path}") + # Ensure that if `python_version::read_trimmed_version_lines` fails inside the process + # substitution that the non-zero exit status is propagated. + wait $! + + # We validate all of the version lines up front, since in the case where there are multiple invalid + # versions, we want to show an "invalid version" error rather than the "multiple versions" error. + for version in "${versions[@]}"; do + if [[ "${version}" =~ ^${PYTHON_VERSION_REGEX}$ ]]; then + continue + fi + + # If we didn't find a valid Python version string, we check the text encoding so that we + # can display a more helpful error message if it turns out that the version was valid but + # that the file was just saved in the wrong encoding (such as UTF-8 with BOM or UTF-16). + # + # Example values `file` can return: + # `ASCII text` + # `ASCII text, with CRLF line terminators` + # `ASCII text, with no line terminators` + # `Unicode text, UTF-8 text` + # `Unicode text, UTF-8 (with BOM) text` + # `Unicode text, UTF-16, little-endian text, with CRLF line terminators` + # `data` (such as when the file contains a NUL or other control code characters) + # `very short file (no magic)` (such as when the file contains a single ESC character) + # + # Note: File can also return `empty` but in that case we wouldn't be iterating over found lines. + local file_encoding + # We exclude some file type tests to avoid false positives, since we only need the encoding. + file_encoding="$(file --brief --dereference --exclude json --exclude soft "${python_version_file_path}")" + + case "${file_encoding}" in + # Cases where the text encoding isn't the issue, and so the version itself must be invalid. + *"ASCII text"* | *"UTF-8 text"* | *"very short file"* | "data") + # Replace everything but printable ASCII, spaces and tabs with the Unicode replacement + # character, so any invisible unwanted characters (such as ASCII control codes or the + # Unicode zero width space character) are visible in the error message. + local escaped_version + escaped_version="$( + LC_ALL=C + echo "${version//[^[:print:][:blank:]]/�}" + )" + output::error <<-EOF + Error: Invalid Python version in .python-version. + + The Python version specified in your .python-version file + isn't in the correct format. + + The following version was found: + ${escaped_version} + + However, the Python version must be specified as either: + 1. The major version only, for example: ${DEFAULT_PYTHON_MAJOR_VERSION} (recommended) + 2. An exact patch version, for example: ${DEFAULT_PYTHON_MAJOR_VERSION}.999 + + Don't include quotes, a 'python-' prefix or wildcards. Any + code comments must be on a separate line prefixed with '#'. + + For example, to request the latest version of Python ${DEFAULT_PYTHON_MAJOR_VERSION}, + update your .python-version file so it contains exactly: + ${DEFAULT_PYTHON_MAJOR_VERSION} + + We strongly recommend that you don't specify the Python patch + version number, since it will pin your app to an exact Python + version and so stop your app from receiving security updates + each time it builds. + EOF + build_data::set_string "failure_reason" "python-version-file::invalid-version" + build_data::set_string "failure_detail" "${version:0:100}" + exit 1 + ;; + # Unsupported text encodings such as UTF-8 with BOM or UTF-16. + *) + output::error <<-EOF + Error: Unable to read .python-version. + + Your .python-version file couldn't be read because it's using + an unsupported text encoding: + ${file_encoding} + + Configure your editor to save files as UTF-8, without a BOM, + then delete and recreate the file using the correct encoding. + + If that doesn't work, make sure you don't have a .gitattributes + file that's overriding the text encoding. + + Note: On Windows, if you pipe or redirect output to a file + it can result in the file being encoded in UTF-16 LE when + using certain terminals and Windows settings. We recommend + you create the file using a text editor instead. + EOF + build_data::set_string "failure_reason" "python-version-file::invalid-encoding" + build_data::set_string "failure_detail" "${file_encoding}" + exit 1 + ;; + esac + done + + case "${#versions[@]}" in + 1) + echo "${versions[0]}" + return 0 + ;; + 0) + output::error <<-EOF + Error: No Python version found in .python-version. + + No Python version was found in your .python-version file. + + Update the file so that it contains your app's major Python + version number. Don't include quotes or a 'python-' prefix. + + For example, to request the latest version of Python ${DEFAULT_PYTHON_MAJOR_VERSION}, + update your .python-version file so it contains exactly: + ${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 + build_data::set_string "failure_reason" "python-version-file::no-version" + exit 1 + ;; + *) + output::error <<-EOF + Error: Multiple Python versions found in .python-version. + + Multiple versions were found in your .python-version file: + + $( + IFS=$'\n' + echo "${versions[*]}" + ) + + Update the file so it contains only one Python version. + + For example, to request the latest version of Python ${DEFAULT_PYTHON_MAJOR_VERSION}, + update your .python-version file so it contains exactly: + ${DEFAULT_PYTHON_MAJOR_VERSION} + EOF + build_data::set_string "failure_reason" "python-version-file::multiple-versions" + build_data::set_string "failure_detail" "$( + IFS=, + echo "${versions[*]}" + )" + exit 1 + ;; + esac +} + +# Outputs all populated (non-empty and not commented with '#') lines from the passed file, +# with leading/trailing whitespace (including Unicode whitespace) trimmed from each line. +# We replace any NUL characters with a placeholder since Bash variables can't store them. +function python_version::read_trimmed_version_lines() { + local file="${1}" + LC_ALL=C.UTF-8 sed \ + --regexp-extended \ + --expression 's/^[[:space:]]+//' \ + --expression 's/[[:space:]]+$//' \ + --expression 's/\x0/␀/g' \ + --expression '/^(#|$)/d' \ + "${file}" +} + +# 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: +# https://pipenv.pypa.io/en/stable/specifiers.html#specifying-versions-of-python +function python_version::read_pipenv_python_version() { + local build_dir="${1}" + local pipfile_lock_path="${build_dir}/Pipfile.lock" + local version + + if ! version=$(jq --raw-output '._meta.requires.python_full_version // ._meta.requires.python_version' "${pipfile_lock_path}" 2>&1); then + local jq_error_message="${version}" + output::error <<-EOF + Error: Can't parse Pipfile.lock. + + A Pipfile.lock file was found, however, it couldn't be parsed: + ${jq_error_message} + + This is likely due to it not being valid JSON. + + Run 'pipenv lock' to regenerate/fix the lockfile. + EOF + build_data::set_string "failure_reason" "pipfile-lock::invalid-json" + build_data::set_string "failure_detail" "${jq_error_message:0:100}" + exit 1 + fi + + # Neither of the optional fields were found. + if [[ "${version}" == "null" ]]; then + return 0 + fi + + # We don't use separate version validation rules for both fields (e.g. ensuring a patch version + # only exists for `python_full_version`) since Pipenv doesn't distinguish between them either: + # https://github.com/pypa/pipenv/blob/v2024.1.0/pipenv/project.py#L392-L398 + if [[ "${version}" =~ ^${PYTHON_VERSION_REGEX}$ ]]; then + echo "${version}" + else + output::error <<-EOF + Error: Invalid Python version in Pipfile.lock. + + The Python version specified in your Pipfile.lock file by the + 'python_version' or 'python_full_version' fields isn't valid. + + The following version was found: + ${version} + + However, the Python version must be specified as either: + 1. The major version only, for example: ${DEFAULT_PYTHON_MAJOR_VERSION} (recommended) + 2. An exact patch version, for example: ${DEFAULT_PYTHON_MAJOR_VERSION}.999 + + Wildcards aren't supported. + + Please update your Pipfile to use a valid Python version and + then run 'pipenv lock' to regenerate Pipfile.lock. + + We strongly recommend that you don't specify the Python patch + version number, since it will pin your app to an exact Python + version and so stop your app from receiving security updates + each time it builds. + + For more information, see: + https://pipenv.pypa.io/en/stable/specifiers.html#specifying-versions-of-python + EOF + build_data::set_string "failure_reason" "pipfile-lock::invalid-version" + build_data::set_string "failure_detail" "${version:0:50}" + exit 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 aren't supported. +function python_version::resolve_python_version() { + local requested_python_version="${1}" + local python_version_origin="${2}" + + if [[ ! "${requested_python_version}" =~ ^${PYTHON_VERSION_REGEX}$ ]]; then + # The Python version was previously validated, so this should never occur. + utils::abort_internal_error "Invalid Python version: ${requested_python_version}" + fi + + local major="${BASH_REMATCH[1]}" + local minor="${BASH_REMATCH[2]}" + + if ((major < 3 || (major == 3 && minor < OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION))); then + if [[ "${python_version_origin}" == "cached" ]]; then + local instructions + instructions="$(python_version::python_version_file_instructions "${python_version_origin}" "3.${OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION}")" + output::error <<-EOF + Error: The cached Python version has reached end-of-life. + + 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, + and is therefore no longer receiving security updates: + https://devguide.python.org/versions/#supported-versions + + As such, it's no longer supported by this buildpack: + https://devcenter.heroku.com/articles/python-support#supported-python-versions + + Please upgrade to at least Python 3.${OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION} by configuring an + explicit Python version for your app. + + ${instructions} + EOF + else + output::error <<-EOF + Error: The requested Python version has reached end-of-life. + + Python ${major}.${minor} has reached its upstream end-of-life, and is + therefore no longer receiving security updates: + https://devguide.python.org/versions/#supported-versions + + As such, it's no longer supported by this buildpack: + https://devcenter.heroku.com/articles/python-support#supported-python-versions + + Please upgrade to at least Python 3.${OLDEST_SUPPORTED_PYTHON_3_MINOR_VERSION} by changing the + version in your ${python_version_origin} file. + + If possible, we recommend upgrading all the way to Python ${DEFAULT_PYTHON_MAJOR_VERSION}, + since it contains many performance and usability improvements. + EOF + fi + build_data::set_string "failure_reason" "python-version::eol" + build_data::set_string "failure_detail" "${major}.${minor}" + exit 1 + fi + + if (((major == 3 && minor > NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION) || major >= 4)); then + if [[ "${python_version_origin}" == "cached" ]]; then + output::error <<-EOF + Error: The cached Python version isn't recognised. + + 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} isn't recognised by this version + of the buildpack. + + This can occur if you have downgraded the version of the + buildpack to an older version. + + Please switch back to a newer version of this buildpack: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + + Alternatively, request an older Python version by creating + a .python-version file in the root directory of your app, + that contains a Python version like: + 3.${NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION} + EOF + else + output::error <<-EOF + Error: The requested Python version isn't 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: + https://devguide.python.org/versions/#supported-versions + https://devcenter.heroku.com/articles/python-support#supported-python-versions + + If it has, make sure that you are using the latest version + of this buildpack, and haven't pinned to an older release: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + + Otherwise, switch to a supported version (such as Python 3.${NEWEST_SUPPORTED_PYTHON_3_MINOR_VERSION}) + by changing the version in your ${python_version_origin} file. + EOF + fi + build_data::set_string "failure_reason" "python-version::unknown-major" + build_data::set_string "failure_detail" "${major}.${minor}" + exit 1 + fi + + # If an exact Python version was requested, there's nothing to resolve. + # Otherwise map major version specifiers to the latest patch release. + case "${requested_python_version}" in + *.*.*) echo "${requested_python_version}" ;; + 3.10) echo "${LATEST_PYTHON_3_10}" ;; + 3.11) echo "${LATEST_PYTHON_3_11}" ;; + 3.12) echo "${LATEST_PYTHON_3_12}" ;; + 3.13) echo "${LATEST_PYTHON_3_13}" ;; + 3.14) echo "${LATEST_PYTHON_3_14}" ;; + *) utils::abort_internal_error "Unhandled Python major version: ${requested_python_version}" ;; + esac +} + +function python_version::warn_or_error_if_python_version_file_missing() { + local python_version_origin="${1}" + local python_major_version="${2}" + local package_manager="${3}" + + if [[ "${python_version_origin}" == ".python-version" ]]; then + return 0 + fi + + local instructions + instructions="$(python_version::python_version_file_instructions "${python_version_origin}" "${python_major_version}")" + + case "${python_version_origin}" in + default | cached) + if [[ "${package_manager}" == "uv" ]]; then + output::error <<-EOF + Error: No Python version was specified. + + When using the package manager uv on Heroku, you must specify + your app's Python version with a .python-version file. + + To add a .python-version file: + + 1. Make sure you are in the root directory of your app + and not a subdirectory. + 2. Run 'uv python pin ${python_major_version}' + (adjust to match your app's major Python version). + 3. Commit the changes to your Git repository using + 'git add --all' and then 'git commit'. + + Note: We strongly recommend that you don't specify the Python + patch version number in your .python-version file, since it will + pin your app to an exact Python version and so stop your app from + receiving security updates each time it builds. + EOF + build_data::set_string "failure_reason" "python-version-file::not-found" + exit 1 + fi + + output::warning <<-EOF + Warning: No Python version was specified. + + Your app doesn't specify a Python version and so the buildpack + picked a default version for you. + + Relying on this default version isn't recommended, since it + can change over time and may not be consistent with your local + development environment, CI or other instances of your app. + + Please configure an explicit Python version for your app. + + ${instructions} + + If your app already has a .python-version file, check that it: + + 1. Is in the top level directory (not a subdirectory). + 2. Is named exactly '.python-version' in all lowercase. + 3. Isn't listed in '.gitignore' or '.slugignore'. + 4. Has been added to the Git repository using 'git add --all' + and then committed using 'git commit'. + + In the future we will require the use of a .python-version + file and this warning will be made an error. + EOF + ;; + runtime.txt) + if [[ "${package_manager}" == "uv" ]]; then + output::error <<-EOF + Error: The runtime.txt file isn't supported when using uv. + + When using the package manager uv on Heroku, you must specify + your app's Python version with a .python-version file and not + a runtime.txt file. + + To switch to a .python-version file: + + 1. Make sure you are in the root directory of your app + and not a subdirectory. + 2. Delete your runtime.txt file. + 3. Run 'uv python pin ${python_major_version}' + (adjust to match your app's major Python version). + 4. Commit the changes to your Git repository using + 'git add --all' and then 'git commit'. + + Note: We strongly recommend that you don't specify the Python + patch version number in your .python-version file, since it will + pin your app to an exact Python version and so stop your app from + receiving security updates each time it builds. + EOF + build_data::set_string "failure_reason" "runtime-txt::not-supported" + exit 1 + fi + + output::warning <<-EOF + Warning: The runtime.txt file is deprecated. + + The runtime.txt file is deprecated since it has been replaced + by the more widely supported .python-version file: + https://devcenter.heroku.com/changelog-items/3141 + + Please switch to using a .python-version file instead. + + ${instructions} + + In the future support for runtime.txt will be removed and + this warning will be made an error. + EOF + ;; + Pipfile.lock) + # Decide if we want to warn for this case. + ;; + *) utils::abort_internal_error "Unhandled Python version origin: ${python_version_origin}" ;; + esac +} + +function python_version::python_version_file_instructions() { + local python_version_origin="${1}" + local python_major_version="${2}" + + if [[ "${python_version_origin}" == "runtime.txt" ]]; then + cat <<-EOF + Delete your runtime.txt file and create a new file in the + root directory of your app named: + EOF + else + cat <<-EOF + Create a new file in the root directory of your app named: + EOF + fi + + cat <<-EOF + .python-version + + Make sure to include the '.' character at the start of the + filename. Don't add a file extension such as '.txt'. + + In the new file, specify your app's major Python version number + only. Don't include quotes or a 'python-' prefix. + + For example, to request the latest version of Python ${python_major_version}, + update your .python-version file so it contains exactly: + ${python_major_version} + + We strongly recommend that you don't specify the Python patch + version number, since it will pin your app to an exact Python + version and so stop your app from receiving security updates + each time it builds. + EOF +} + +function python_version::warn_if_deprecated_major_version() { + local requested_major_version="${1}" + local version_origin="${2}" + + if [[ "${requested_major_version}" == "3.10" ]]; then + output::warning <<-EOF + Warning: Support for Python 3.10 is deprecated! + + Python 3.10 will reach its upstream end-of-life in October 2026, + at which point it will no longer receive security updates: + https://devguide.python.org/versions/#supported-versions + + As such, support for Python 3.10 will be removed from this + buildpack on 6th January 2027. + + Upgrade to a newer Python version as soon as possible, by + changing the version in your ${version_origin} file. + + For more information, see: + https://devcenter.heroku.com/articles/python-support#supported-python-versions + EOF + fi +} + +function python_version::warn_if_patch_update_available() { + local python_full_version="${1}" + local python_major_version="${2}" + local python_version_origin="${3}" + + local latest_known_patch_version + latest_known_patch_version="$(python_version::resolve_python_version "${python_major_version}" "${python_version_origin}")" + # Extract the patch version component of the version strings (ie: the '2' in '3.13.2'). + local requested_patch_number="${python_full_version##*.}" + local latest_patch_number="${latest_known_patch_version##*.}" + + if ((requested_patch_number < latest_patch_number)); then + output::warning <<-EOF + Warning: A Python patch update is available! + + Your app is using Python ${python_full_version}, however, there is a newer + patch release of Python ${python_major_version} available: ${latest_known_patch_version} + + It is important to always use the latest patch version of + Python to keep your app secure. + + Update your ${python_version_origin} file to use the new version. + + We strongly recommend that you don't pin your app to an + exact Python version such as ${python_full_version}, and instead only specify + the major Python version of ${python_major_version} in your ${python_version_origin} file. + This will allow your app to receive the latest available Python + patch version automatically and prevent this warning. + EOF + build_data::set_raw "python_version_outdated" "true" + else + build_data::set_raw "python_version_outdated" "false" + fi +} diff --git a/lib/utils.sh b/lib/utils.sh new file mode 100644 index 000000000..4a05e2897 --- /dev/null +++ b/lib/utils.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +# The requirement versions are effectively buildpack constants, however, we want +# Dependabot to be able to update them, which requires that they be in requirements +# files. The requirements files contain contents like `package==1.2.3` (and not just +# the package version) so we have to extract the version substring from it. +function utils::get_requirement_version() { + local package_name="${1}" + local requirement + requirement=$(cat "${BUILDPACK_DIR:?}/requirements/${package_name}.txt") + local requirement_version="${requirement#"${package_name}=="}" + echo "${requirement_version}" +} + +# Python bundles pip within its standard library, which we can use to install our chosen +# pip version from PyPI, saving us from having to download the usual pip bootstrap script. +function utils::bundled_pip_module_path() { + local python_home="${1}" + local python_major_version="${2}" + + local bundled_wheels_dir="${python_home}/lib/python${python_major_version}/ensurepip/_bundled" + + # We have to use a glob since the bundled wheel filename contains the pip version, which differs + # between Python versions. We use compgen to avoid having to set nullglob, since there may be no + # matches in the case of a broken Python install. We also have to handle the case where there are + # multiple matching pip wheels, since in some versions of Python (e.g. 3.9.0) multiple versions of + # pip were accidentally bundled upstream (we use tail since we want the newest pip version). + if bundled_pip_wheel="$(compgen -G "${bundled_wheels_dir}/pip-*.whl" | tail --lines=1)"; then + # The pip module exists inside the pip wheel (which is a zip file), however, Python can load + # it directly by appending the module name to the zip filename, as though it were a path. + echo "${bundled_pip_wheel}/pip" + else + output::error <<-EOF + Internal Error: Unable to locate the Python stdlib's bundled pip. + + Couldn't find the pip wheel file bundled inside the Python + stdlib's 'ensurepip' module: + + $(find "${bundled_wheels_dir}/" 2>&1 || find "${python_home}/" -type d 2>&1 || true) + EOF + build_data::set_string "failure_reason" "internal-error::bundled-pip-not-found" + exit 1 + fi +} + +function utils::abort_internal_error() { + local message + message="${1} (line $(caller || true))" + output::error <<-EOF + Internal error: ${message}. + EOF + build_data::set_string "failure_reason" "internal-error::abort" + build_data::set_string "failure_detail" "${message}" + exit 1 +} + +function utils::err_trap() { + # We use `errtrace` which means the ERR trap can fire multiple times, such as when an error + # occurs in a buildpack function called from within a command substitution. + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if build_data::has "failure_reason"; then + return + fi + + local failing_command="${BASH_COMMAND}" + local stack_trace + stack_trace=$( + local frame=0 + while read -r line_number function_name source_file < <(caller "${frame}" || true); do + echo "${function_name} @ ${source_file}:${line_number}" + ((++frame)) + done + ) + + output::error <<-EOF + Internal Error: An unhandled buildpack error occurred. + + An unhandled error occurred while executing the command: + ${failing_command} + + If this issue persists, please open a support ticket or file + an issue on the buildpack's GitHub repository: + https://help.heroku.com/ + https://github.com/heroku/heroku-buildpack-python/issues + + Stack trace: + ${stack_trace} + EOF + build_data::set_string "failure_reason" "internal-error::unhandled" + build_data::set_string "failure_detail" "${failing_command}" +} diff --git a/lib/uv.sh b/lib/uv.sh new file mode 100644 index 000000000..cdbef4acb --- /dev/null +++ b/lib/uv.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +# This is technically redundant, since all consumers of this lib will have enabled these, +# however, it helps Shellcheck realise the options under which these functions will run. +set -euo pipefail + +UV_VERSION=$(utils::get_requirement_version 'uv') + +function uv::install_uv() { + local cache_dir="${1}" + local export_file="${2}" + local python_home="${3}" + + # We store uv in the build cache, since we only need it during the build. + local uv_dir="${cache_dir}/.heroku/python-uv" + + build_data::set_string "uv_version" "${UV_VERSION}" + + # The earlier buildpack cache invalidation step will have already handled the case where + # the uv version has changed, so here we only need to check whether the uv binary exists. + if [[ -f "${uv_dir}/uv" ]]; then + output::step "Using cached uv ${UV_VERSION}" + else + output::step "Installing uv ${UV_VERSION}" + mkdir -p "${uv_dir}" + + local gnu_arch + # e.g.: `x86_64` or `aarch64`. + gnu_arch=$(arch) + local uv_url="https://releases.astral.sh/github/uv/releases/download/${UV_VERSION}/uv-${gnu_arch}-unknown-linux-gnu.tar.gz" + + local error_log + error_log=$(mktemp) + + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + { + # We set max-time for improved UX/metrics for hanging downloads compared to relying on the build + # system timeout. We don't use `--speed-limit` since it gives worse error messages when used with + # retries and piping to tar. The uv archives are ~20 MB so only take ~1s to download on Heroku, + # so we set low timeouts to reduce delays before retries. However, we allow customising the timeouts + # to support non-Heroku environments that may be far from `us-east-1` or have a slower connection. + # We use `--no-progress-meter` rather than `--silent` so that retry status messages are printed. + # We have to use `--strip-components` since the uv binary is nested under a subdirectory. + curl \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-3}" \ + --fail \ + --location \ + --max-time "${CURL_TIMEOUT:-60}" \ + --no-progress-meter \ + --retry-max-time "${CURL_TIMEOUT:-60}" \ + --retry 5 \ + --retry-connrefused \ + "${uv_url}" \ + | tar \ + --directory "${uv_dir}" \ + --extract \ + --gzip \ + --strip-components 1 + } \ + |& tee "${error_log}" \ + |& output::indent + }; then + output::error <<-EOF + Error: Unable to install uv. + + Failed to download/install uv from: + ${uv_url} + + In some cases, this happens due to a temporary issue with + the network connection or Astral's CDN, Cloudflare. + + Try building again to see if the error resolves itself. + + If that doesn't help, check the status of Cloudflare here: + https://www.cloudflarestatus.com + EOF + build_data::set_string "failure_reason" "install-package-manager::uv" + # e.g.: 'curl: (56) Recv failure: Connection reset by peer' + build_data::set_string "failure_detail" "$(head --lines=1 "${error_log}" || true)" + exit 1 + fi + fi + + export PATH="${uv_dir}:${PATH}" + # Make uv manage the system site-packages of our Python install instead of creating a venv. + export UV_PROJECT_ENVIRONMENT="${python_home}" + # Prevent uv from downloading/using its own Python installation. + export UV_NO_MANAGED_PYTHON="1" + export UV_PYTHON_DOWNLOADS="never" + # Force uv to use hardlinks rather than the new default of reflinks, since the latter are + # significantly slower on Kodon: https://github.com/astral-sh/uv/issues/18259 + export UV_LINK_MODE="hardlink" + + # Set the same env vars in the environment used by later buildpacks. + cat >>"${export_file}" <<-EOF + export PATH="${uv_dir}:\${PATH}" + export UV_PROJECT_ENVIRONMENT="${python_home}" + export UV_NO_MANAGED_PYTHON="1" + export UV_PYTHON_DOWNLOADS="never" + export UV_LINK_MODE="hardlink" + EOF + + # As a performance optimisation, uv attempts to use hardlinks instead of copying files from its + # download cache into site-packages, and will emit a warning if it has to fall back to copying. + # By default uv stores its cache under `$HOME/.cache`, and for standard Heroku builds `$HOME` is + # `/app`, which is on a different filesystem mount to the build directory (which is under `/tmp`), + # meaning hardlinks can't be used. To avoid this we tell uv to store its cache in `/tmp`. + # However, we have to do so conditionally, since for Heroku CI both the home directory and + # the build directory are `/app`, where hardlinks already work and changing the cache to `/tmp` + # would instead break them. + # + # There's also a third case, a non-CI build where the app has the undocumented `build-in-app-dir` + # labs enabled, however, for that scenario the build directory is `/app` and the home directory + # is `/tmp`, so we can't use hardlinks unless we wrote the cache to the build directory and + # manually deleted it after. For now we ignore this case since not many apps use that labs, + # and uv will still work after falling back to copying files (with a warning). + # + # Longer term, ideally uv's `--no-cache` option would co-locate the temporary cache it writes + # alongside site-packages (see https://github.com/astral-sh/uv/issues/11385), and we could use + # that everywhere since we don't actually need to persist uv's cache (see below). + # shellcheck disable=SC2312 # Invoke this command separately to avoid masking its return value. + if [[ "$(realpath "${python_home}")" =~ ^/tmp/ ]]; then + export UV_CACHE_DIR='/tmp/uv-cache' + echo 'export UV_CACHE_DIR="/tmp/uv-cache"' >>"${export_file}" + fi +} + +# Note: We cache site-packages since: +# - It results in faster builds than only caching uv's download/wheel cache. +# - It improves the UX of the build log, since uv will display which packages were +# added/removed since the last successful build. +# - It's safe to do so, since `uv sync` fully manages the environment (including +# e.g. uninstalling packages when they are removed from the lockfile). +# +# With site-packages cached there is no need to persist uv's cache in the build cache, so we let +# uv write it to the home directory (or `UV_CACHE_DIR`, see above) where it will be discarded at +# the end of the build. We don't use `--no-cache` since all it does is make uv switch to a temporary +# cache directory under `/tmp`, which would mean hardlinks can't be used for Heroku CI (see above). +function uv::install_dependencies() { + local uv_install_command=( + uv + sync + --locked + ) + + # Unless we're building on Heroku CI, we omit the default dependency groups (such as `dev`): + # https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-groups + if [[ ! -v INSTALL_TEST ]]; then + uv_install_command+=(--no-default-groups) + fi + + # We only display the most relevant command args here, to improve the signal to noise ratio. + output::step "Installing dependencies using '${uv_install_command[*]}'" + + local install_log + install_log=$(mktemp) + + # TODO: Expose app config vars to the install command as part of doing so for all package managers. + # `--compile-bytecode`: Improves app boot times (pip does this by default). + # shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled. + if ! { + "${uv_install_command[@]}" \ + --color always \ + --compile-bytecode \ + --no-progress \ + |& tee "${install_log}" \ + |& output::indent + }; then + # TODO: Overhaul warnings and combine them with error handling (for all package managers). + show-warnings "${install_log}" + + output::error <<-EOF + Error: Unable to install dependencies using uv. + + See the log output above for more information. + EOF + build_data::set_string "failure_reason" "install-dependencies::uv" + exit 1 + fi +} diff --git a/requirements/pip.txt b/requirements/pip.txt index f293eef6f..657e2159e 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1 +1 @@ -pip==24.0 +pip==26.1.2 diff --git a/requirements/pipenv.txt b/requirements/pipenv.txt index 34b9e5c44..5392a7319 100644 --- a/requirements/pipenv.txt +++ b/requirements/pipenv.txt @@ -1 +1 @@ -pipenv==2024.0.1 +pipenv==2026.6.2 diff --git a/requirements/poetry.txt b/requirements/poetry.txt new file mode 100644 index 000000000..c3973d4c7 --- /dev/null +++ b/requirements/poetry.txt @@ -0,0 +1 @@ +poetry==2.4.1 diff --git a/requirements/setuptools.txt b/requirements/setuptools.txt deleted file mode 100644 index d125505ce..000000000 --- a/requirements/setuptools.txt +++ /dev/null @@ -1 +0,0 @@ -setuptools==70.3.0 diff --git a/requirements/uv.txt b/requirements/uv.txt new file mode 100644 index 000000000..f329fecf5 --- /dev/null +++ b/requirements/uv.txt @@ -0,0 +1 @@ +uv==0.11.28 diff --git a/requirements/wheel.txt b/requirements/wheel.txt deleted file mode 100644 index ee8fec0fb..000000000 --- a/requirements/wheel.txt +++ /dev/null @@ -1 +0,0 @@ -wheel==0.43.0 diff --git a/spec/fixtures/ci_pip/.python-version b/spec/fixtures/ci_pip/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/ci_pip/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/ci_requirements/app.json b/spec/fixtures/ci_pip/app.json similarity index 54% rename from spec/fixtures/ci_requirements/app.json rename to spec/fixtures/ci_pip/app.json index 99d1d237f..59617c169 100644 --- a/spec/fixtures/ci_requirements/app.json +++ b/spec/fixtures/ci_pip/app.json @@ -2,7 +2,7 @@ "environments": { "test": { "scripts": { - "test": "pytest --version" + "test": "./bin/print-env-vars.sh && pytest --version" } } } diff --git a/spec/fixtures/ci_pip/bin/compile b/spec/fixtures/ci_pip/bin/compile new file mode 100755 index 000000000..fcb7055e3 --- /dev/null +++ b/spec/fixtures/ci_pip/bin/compile @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +exec bin/print-env-vars.sh diff --git a/spec/fixtures/requirements_editable/bin/detect b/spec/fixtures/ci_pip/bin/detect similarity index 100% rename from spec/fixtures/requirements_editable/bin/detect rename to spec/fixtures/ci_pip/bin/detect diff --git a/spec/fixtures/ci_pip/bin/post_compile b/spec/fixtures/ci_pip/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/ci_pip/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_pip/bin/print-env-vars.sh b/spec/fixtures/ci_pip/bin/print-env-vars.sh new file mode 100755 index 000000000..6cc32b420 --- /dev/null +++ b/spec/fixtures/ci_pip/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|CI_NODE_.+|DYNO|HEROKU_TEST_RUN_.+|HOME|OLDPWD|PORT|PWD|SHLVL|STACK|TERM)=' diff --git a/spec/fixtures/ci_pip/requirements-test.txt b/spec/fixtures/ci_pip/requirements-test.txt new file mode 100644 index 000000000..d197ada2f --- /dev/null +++ b/spec/fixtures/ci_pip/requirements-test.txt @@ -0,0 +1 @@ +pytest==8.3.4 diff --git a/spec/fixtures/ci_pip/requirements.txt b/spec/fixtures/ci_pip/requirements.txt new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/ci_pip/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.15.0 diff --git a/spec/fixtures/ci_pipenv/.python-version b/spec/fixtures/ci_pipenv/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/ci_pipenv/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/pipenv_python_3.6/Pipfile b/spec/fixtures/ci_pipenv/Pipfile similarity index 67% rename from spec/fixtures/pipenv_python_3.6/Pipfile rename to spec/fixtures/ci_pipenv/Pipfile index 62a39bb91..6d6473eb4 100644 --- a/spec/fixtures/pipenv_python_3.6/Pipfile +++ b/spec/fixtures/ci_pipenv/Pipfile @@ -4,9 +4,7 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] - -[requires] -python_version = "3.6" +pytest = "*" diff --git a/spec/fixtures/ci_pipenv/Pipfile.lock b/spec/fixtures/ci_pipenv/Pipfile.lock new file mode 100644 index 000000000..f86d87a9d --- /dev/null +++ b/spec/fixtures/ci_pipenv/Pipfile.lock @@ -0,0 +1,70 @@ +{ + "_meta": { + "hash": { + "sha256": "9ac30f761973e7bb9a0425635eb284370fede0e49a74b475c418f98bf13f3075" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + } + }, + "develop": { + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pytest": { + "hashes": [ + "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", + "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==9.0.1" + } + } +} diff --git a/spec/fixtures/ci_pipenv/app.json b/spec/fixtures/ci_pipenv/app.json new file mode 100644 index 000000000..59617c169 --- /dev/null +++ b/spec/fixtures/ci_pipenv/app.json @@ -0,0 +1,9 @@ +{ + "environments": { + "test": { + "scripts": { + "test": "./bin/print-env-vars.sh && pytest --version" + } + } + } +} diff --git a/spec/fixtures/ci_pipenv/bin/compile b/spec/fixtures/ci_pipenv/bin/compile new file mode 100755 index 000000000..fcb7055e3 --- /dev/null +++ b/spec/fixtures/ci_pipenv/bin/compile @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_pipenv/bin/detect b/spec/fixtures/ci_pipenv/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/ci_pipenv/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/ci_pipenv/bin/post_compile b/spec/fixtures/ci_pipenv/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/ci_pipenv/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_pipenv/bin/print-env-vars.sh b/spec/fixtures/ci_pipenv/bin/print-env-vars.sh new file mode 100755 index 000000000..6cc32b420 --- /dev/null +++ b/spec/fixtures/ci_pipenv/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|CI_NODE_.+|DYNO|HEROKU_TEST_RUN_.+|HOME|OLDPWD|PORT|PWD|SHLVL|STACK|TERM)=' diff --git a/spec/fixtures/ci_poetry/.python-version b/spec/fixtures/ci_poetry/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/ci_poetry/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/ci_poetry/app.json b/spec/fixtures/ci_poetry/app.json new file mode 100644 index 000000000..59617c169 --- /dev/null +++ b/spec/fixtures/ci_poetry/app.json @@ -0,0 +1,9 @@ +{ + "environments": { + "test": { + "scripts": { + "test": "./bin/print-env-vars.sh && pytest --version" + } + } + } +} diff --git a/spec/fixtures/ci_poetry/bin/compile b/spec/fixtures/ci_poetry/bin/compile new file mode 100755 index 000000000..fcb7055e3 --- /dev/null +++ b/spec/fixtures/ci_poetry/bin/compile @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_poetry/bin/detect b/spec/fixtures/ci_poetry/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/ci_poetry/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/ci_poetry/bin/post_compile b/spec/fixtures/ci_poetry/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/ci_poetry/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_poetry/bin/print-env-vars.sh b/spec/fixtures/ci_poetry/bin/print-env-vars.sh new file mode 100755 index 000000000..6cc32b420 --- /dev/null +++ b/spec/fixtures/ci_poetry/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|CI_NODE_.+|DYNO|HEROKU_TEST_RUN_.+|HOME|OLDPWD|PORT|PWD|SHLVL|STACK|TERM)=' diff --git a/spec/fixtures/ci_poetry/poetry.lock b/spec/fixtures/ci_poetry/poetry.lock new file mode 100644 index 000000000..51cc64181 --- /dev/null +++ b/spec/fixtures/ci_poetry/poetry.lock @@ -0,0 +1,108 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"}, + {file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14" +content-hash = "a0f8d6facd04ce28abc860e56bb4af6e1acac71a1f2a93e4e6ed4e13a299e055" diff --git a/spec/fixtures/ci_poetry/pyproject.toml b/spec/fixtures/ci_poetry/pyproject.toml new file mode 100644 index 000000000..161499343 --- /dev/null +++ b/spec/fixtures/ci_poetry/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "ci-poetry" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "typing-extensions", +] + +[dependency-groups] +dev = [ + "pytest", +] + + +[tool.poetry] +package-mode = false diff --git a/spec/fixtures/ci_requirements/requirements-test.txt b/spec/fixtures/ci_requirements/requirements-test.txt deleted file mode 100644 index e079f8a60..000000000 --- a/spec/fixtures/ci_requirements/requirements-test.txt +++ /dev/null @@ -1 +0,0 @@ -pytest diff --git a/spec/fixtures/ci_requirements/requirements.txt b/spec/fixtures/ci_requirements/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/ci_requirements/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/ci_uv/.python-version b/spec/fixtures/ci_uv/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/ci_uv/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/ci_uv/app.json b/spec/fixtures/ci_uv/app.json new file mode 100644 index 000000000..59617c169 --- /dev/null +++ b/spec/fixtures/ci_uv/app.json @@ -0,0 +1,9 @@ +{ + "environments": { + "test": { + "scripts": { + "test": "./bin/print-env-vars.sh && pytest --version" + } + } + } +} diff --git a/spec/fixtures/ci_uv/bin/compile b/spec/fixtures/ci_uv/bin/compile new file mode 100755 index 000000000..fcb7055e3 --- /dev/null +++ b/spec/fixtures/ci_uv/bin/compile @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_uv/bin/detect b/spec/fixtures/ci_uv/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/ci_uv/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/ci_uv/bin/post_compile b/spec/fixtures/ci_uv/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/ci_uv/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/ci_uv/bin/print-env-vars.sh b/spec/fixtures/ci_uv/bin/print-env-vars.sh new file mode 100755 index 000000000..6cc32b420 --- /dev/null +++ b/spec/fixtures/ci_uv/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|CI_NODE_.+|DYNO|HEROKU_TEST_RUN_.+|HOME|OLDPWD|PORT|PWD|SHLVL|STACK|TERM)=' diff --git a/spec/fixtures/ci_uv/pyproject.toml b/spec/fixtures/ci_uv/pyproject.toml new file mode 100644 index 000000000..443befffc --- /dev/null +++ b/spec/fixtures/ci_uv/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "ci-uv" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "typing-extensions", +] + +[dependency-groups] +dev = [ + "pytest", +] diff --git a/spec/fixtures/ci_uv/uv.lock b/spec/fixtures/ci_uv/uv.lock new file mode 100644 index 000000000..da352f67b --- /dev/null +++ b/spec/fixtures/ci_uv/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "ci-uv" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "typing-extensions" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/spec/fixtures/django_collectstatic_disabled_file/manage.py b/spec/fixtures/django_collectstatic_disabled_file/manage.py new file mode 100644 index 000000000..5ed9a654d --- /dev/null +++ b/spec/fixtures/django_collectstatic_disabled_file/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nonexistent-module.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/spec/fixtures/django_invalid_settings_module/manage.py b/spec/fixtures/django_invalid_settings_module/manage.py new file mode 100644 index 000000000..5ed9a654d --- /dev/null +++ b/spec/fixtures/django_invalid_settings_module/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nonexistent-module.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/spec/fixtures/django_broken_project/requirements.txt b/spec/fixtures/django_invalid_settings_module/requirements.txt similarity index 100% rename from spec/fixtures/django_broken_project/requirements.txt rename to spec/fixtures/django_invalid_settings_module/requirements.txt diff --git a/spec/fixtures/requirements_django_latest/requirements.txt b/spec/fixtures/django_no_manage_py/requirements.txt similarity index 100% rename from spec/fixtures/requirements_django_latest/requirements.txt rename to spec/fixtures/django_no_manage_py/requirements.txt diff --git a/spec/fixtures/django_staticfiles_app_not_enabled/manage.py b/spec/fixtures/django_staticfiles_app_not_enabled/manage.py new file mode 100644 index 000000000..8bd034f0d --- /dev/null +++ b/spec/fixtures/django_staticfiles_app_not_enabled/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/spec/fixtures/django_staticfiles_app_not_enabled/requirements.txt b/spec/fixtures/django_staticfiles_app_not_enabled/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/spec/fixtures/django_staticfiles_app_not_enabled/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/spec/fixtures/hooks/requirements.txt b/spec/fixtures/django_staticfiles_app_not_enabled/testproject/__init__.py similarity index 100% rename from spec/fixtures/hooks/requirements.txt rename to spec/fixtures/django_staticfiles_app_not_enabled/testproject/__init__.py diff --git a/spec/fixtures/django_staticfiles_app_not_enabled/testproject/settings.py b/spec/fixtures/django_staticfiles_app_not_enabled/testproject/settings.py new file mode 100644 index 000000000..5922fd00c --- /dev/null +++ b/spec/fixtures/django_staticfiles_app_not_enabled/testproject/settings.py @@ -0,0 +1,15 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + # The staticfiles app (which is what provides the collectstatic command) is not enabled. + # "django.contrib.staticfiles", +] + +STATIC_ROOT = BASE_DIR / "staticfiles" +STATIC_URL = "static/" diff --git a/spec/fixtures/django_staticfiles_latest_django/backend/manage.py b/spec/fixtures/django_staticfiles_latest_django/backend/manage.py new file mode 100644 index 000000000..8bd034f0d --- /dev/null +++ b/spec/fixtures/django_staticfiles_latest_django/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/spec/fixtures/django_staticfiles_latest_django/backend/requirements.txt b/spec/fixtures/django_staticfiles_latest_django/backend/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/spec/fixtures/django_staticfiles_latest_django/backend/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/spec/fixtures/python_3.10_outdated/requirements.txt b/spec/fixtures/django_staticfiles_latest_django/backend/testapp/__init__.py similarity index 100% rename from spec/fixtures/python_3.10_outdated/requirements.txt rename to spec/fixtures/django_staticfiles_latest_django/backend/testapp/__init__.py diff --git a/spec/fixtures/python_3.11_outdated/requirements.txt b/spec/fixtures/django_staticfiles_latest_django/backend/testapp/static/robots.txt similarity index 100% rename from spec/fixtures/python_3.11_outdated/requirements.txt rename to spec/fixtures/django_staticfiles_latest_django/backend/testapp/static/robots.txt diff --git a/spec/fixtures/python_3.12_outdated/requirements.txt b/spec/fixtures/django_staticfiles_latest_django/backend/testproject/__init__.py similarity index 100% rename from spec/fixtures/python_3.12_outdated/requirements.txt rename to spec/fixtures/django_staticfiles_latest_django/backend/testproject/__init__.py diff --git a/spec/fixtures/django_staticfiles_latest_django/backend/testproject/settings.py b/spec/fixtures/django_staticfiles_latest_django/backend/testproject/settings.py new file mode 100644 index 000000000..91b07ab13 --- /dev/null +++ b/spec/fixtures/django_staticfiles_latest_django/backend/testproject/settings.py @@ -0,0 +1,32 @@ +import os +import sys +from pathlib import Path +from pprint import pprint + +BASE_DIR = Path(__file__).resolve().parent.parent + +INSTALLED_APPS = [ + "django.contrib.staticfiles", + "testapp", +] + +STATIC_ROOT = BASE_DIR / "staticfiles" +STATIC_URL = "static/" + +ENV_VARS_TO_OMIT = { + "_", + "BUILDPACK_LOG_FILE", + "DYNO", + "HOME", + "OLDPWD", + "REQUEST_ID", + "SHLVL", + "SOURCE_VERSION", + "STACK", +} +pprint({k: v for k, v in os.environ.items() if k not in ENV_VARS_TO_OMIT}) +print() +pprint(sys.path) + +# Tests that app env vars are passed to the 'manage.py' script invocations. +assert "EXPECTED_ENV_VAR" in os.environ diff --git a/spec/fixtures/django_staticfiles_latest_django/requirements.txt b/spec/fixtures/django_staticfiles_latest_django/requirements.txt new file mode 120000 index 000000000..ed17bf41b --- /dev/null +++ b/spec/fixtures/django_staticfiles_latest_django/requirements.txt @@ -0,0 +1 @@ +backend/requirements.txt \ No newline at end of file diff --git a/spec/fixtures/django_staticfiles_legacy_django/.python-version b/spec/fixtures/django_staticfiles_legacy_django/.python-version new file mode 100644 index 000000000..c8cfe3959 --- /dev/null +++ b/spec/fixtures/django_staticfiles_legacy_django/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/spec/fixtures/django_broken_project/manage.py b/spec/fixtures/django_staticfiles_legacy_django/manage.py similarity index 69% rename from spec/fixtures/django_broken_project/manage.py rename to spec/fixtures/django_staticfiles_legacy_django/manage.py index eb3ab8af5..97ed576b6 100644 --- a/spec/fixtures/django_broken_project/manage.py +++ b/spec/fixtures/django_staticfiles_legacy_django/manage.py @@ -3,7 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gettingstarted.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") from django.core.management import execute_from_command_line diff --git a/spec/fixtures/django_staticfiles_legacy_django/requirements.txt b/spec/fixtures/django_staticfiles_legacy_django/requirements.txt new file mode 100644 index 000000000..7f5462e9c --- /dev/null +++ b/spec/fixtures/django_staticfiles_legacy_django/requirements.txt @@ -0,0 +1,2 @@ +# This is the oldest Django version that works on Python 3.10 (our oldest supported Python version). +Django==2.1.15 diff --git a/spec/fixtures/python_3.8_outdated/requirements.txt b/spec/fixtures/django_staticfiles_legacy_django/testapp/__init__.py similarity index 100% rename from spec/fixtures/python_3.8_outdated/requirements.txt rename to spec/fixtures/django_staticfiles_legacy_django/testapp/__init__.py diff --git a/spec/fixtures/python_3.9_outdated/requirements.txt b/spec/fixtures/django_staticfiles_legacy_django/testapp/static/robots.txt similarity index 100% rename from spec/fixtures/python_3.9_outdated/requirements.txt rename to spec/fixtures/django_staticfiles_legacy_django/testapp/static/robots.txt diff --git a/spec/fixtures/python_version_invalid/requirements.txt b/spec/fixtures/django_staticfiles_legacy_django/testproject/__init__.py similarity index 100% rename from spec/fixtures/python_version_invalid/requirements.txt rename to spec/fixtures/django_staticfiles_legacy_django/testproject/__init__.py diff --git a/spec/fixtures/django_staticfiles_legacy_django/testproject/settings.py b/spec/fixtures/django_staticfiles_legacy_django/testproject/settings.py new file mode 100644 index 000000000..ad916f8e8 --- /dev/null +++ b/spec/fixtures/django_staticfiles_legacy_django/testproject/settings.py @@ -0,0 +1,35 @@ +import os +import sys +from pathlib import Path +from pprint import pprint + +BASE_DIR = Path(__file__).resolve().parent.parent + +INSTALLED_APPS = [ + "django.contrib.staticfiles", + "testapp", +] + +STATIC_ROOT = BASE_DIR / "staticfiles" +STATIC_URL = "static/" + +# Older versions of Django require that `SECRET_KEY` is set when running collectstatic. +SECRET_KEY = "example" + +ENV_VARS_TO_OMIT = { + "_", + "BUILDPACK_LOG_FILE", + "DYNO", + "HOME", + "OLDPWD", + "REQUEST_ID", + "SHLVL", + "SOURCE_VERSION", + "STACK", +} +pprint({k: v for k, v in os.environ.items() if k not in ENV_VARS_TO_OMIT}) +print() +pprint(sys.path) + +# Tests that app env vars are passed to the 'manage.py' script invocations. +assert "EXPECTED_ENV_VAR" in os.environ diff --git a/spec/fixtures/django_staticfiles_misconfigured/manage.py b/spec/fixtures/django_staticfiles_misconfigured/manage.py new file mode 100644 index 000000000..8bd034f0d --- /dev/null +++ b/spec/fixtures/django_staticfiles_misconfigured/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/spec/fixtures/django_staticfiles_misconfigured/requirements.txt b/spec/fixtures/django_staticfiles_misconfigured/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/spec/fixtures/django_staticfiles_misconfigured/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/spec/fixtures/runtime_txt_only/subdir/some-file b/spec/fixtures/django_staticfiles_misconfigured/testproject/__init__.py similarity index 100% rename from spec/fixtures/runtime_txt_only/subdir/some-file rename to spec/fixtures/django_staticfiles_misconfigured/testproject/__init__.py diff --git a/spec/fixtures/django_staticfiles_misconfigured/testproject/settings.py b/spec/fixtures/django_staticfiles_misconfigured/testproject/settings.py new file mode 100644 index 000000000..1a521e130 --- /dev/null +++ b/spec/fixtures/django_staticfiles_misconfigured/testproject/settings.py @@ -0,0 +1,6 @@ +INSTALLED_APPS = [ + "django.contrib.staticfiles", +] + +# This is an invalid STATIC_ROOT value if collectstatic is being used. +STATIC_ROOT = None diff --git a/spec/fixtures/hooks/bin/post_compile b/spec/fixtures/hooks/bin/post_compile deleted file mode 100644 index 373d33889..000000000 --- a/spec/fixtures/hooks/bin/post_compile +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -echo 'post_compile ran with env vars:' -printenv | cut -d '=' -f 1 | sort diff --git a/spec/fixtures/hooks/bin/pre_compile b/spec/fixtures/hooks/bin/pre_compile deleted file mode 100644 index 40d1a402e..000000000 --- a/spec/fixtures/hooks/bin/pre_compile +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -echo 'pre_compile ran with env vars:' -printenv | cut -d '=' -f 1 | sort diff --git a/spec/fixtures/hooks_delete_cache_dir/bin/post_compile b/spec/fixtures/hooks_delete_cache_dir/bin/post_compile new file mode 100644 index 000000000..383d0656b --- /dev/null +++ b/spec/fixtures/hooks_delete_cache_dir/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +set -x +rm -rf "${CACHE_DIR:?}" diff --git a/spec/fixtures/hooks_delete_cache_dir/requirements.txt b/spec/fixtures/hooks_delete_cache_dir/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/hooks_pre_compile_fail/bin/pre_compile b/spec/fixtures/hooks_pre_compile_fail/bin/pre_compile new file mode 100644 index 000000000..07e0236cf --- /dev/null +++ b/spec/fixtures/hooks_pre_compile_fail/bin/pre_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo 'Some pre_compile error!' >&2 +exit 1 diff --git a/spec/fixtures/hooks_pre_compile_fail/requirements.txt b/spec/fixtures/hooks_pre_compile_fail/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/bin/post_compile b/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/bin/post_compile new file mode 100644 index 000000000..9cdc2b9a1 --- /dev/null +++ b/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo 'Some post_compile error!' >&2 +exit 1 diff --git a/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/bin/pre_compile b/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/bin/pre_compile new file mode 100644 index 000000000..d1401761e --- /dev/null +++ b/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/bin/pre_compile @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo '~ pre_compile ran with env vars:' +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)=' +echo '~ pre_compile complete' diff --git a/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/requirements.txt b/spec/fixtures/hooks_pre_compile_pass_post_compile_fail/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/multiple_package_managers/Pipfile b/spec/fixtures/multiple_package_managers/Pipfile new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/multiple_package_managers/Pipfile.lock b/spec/fixtures/multiple_package_managers/Pipfile.lock new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/multiple_package_managers/poetry.lock b/spec/fixtures/multiple_package_managers/poetry.lock new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/multiple_package_managers/pyproject.toml b/spec/fixtures/multiple_package_managers/pyproject.toml new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/multiple_package_managers/requirements.txt b/spec/fixtures/multiple_package_managers/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/multiple_package_managers/setup.py b/spec/fixtures/multiple_package_managers/setup.py new file mode 100644 index 000000000..ab45d8b72 --- /dev/null +++ b/spec/fixtures/multiple_package_managers/setup.py @@ -0,0 +1 @@ +# This file tests that the setup.py sunset error is not shown when other package manager files exist. diff --git a/spec/fixtures/multiple_package_managers/uv.lock b/spec/fixtures/multiple_package_managers/uv.lock new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/nltk_txt_invalid/nltk.txt b/spec/fixtures/nltk_txt_invalid/nltk.txt new file mode 100644 index 000000000..719ce91ff --- /dev/null +++ b/spec/fixtures/nltk_txt_invalid/nltk.txt @@ -0,0 +1 @@ +invalid! diff --git a/spec/fixtures/nltk_txt_invalid/requirements.txt b/spec/fixtures/nltk_txt_invalid/requirements.txt new file mode 100644 index 000000000..846929621 --- /dev/null +++ b/spec/fixtures/nltk_txt_invalid/requirements.txt @@ -0,0 +1 @@ +nltk diff --git a/spec/fixtures/no_python_project_files/.example-dotfile b/spec/fixtures/no_python_project_files/.example-dotfile new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/pip_basic/.python-version b/spec/fixtures/pip_basic/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/pip_basic/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/pip_basic/bin/compile b/spec/fixtures/pip_basic/bin/compile new file mode 100755 index 000000000..7d1d552df --- /dev/null +++ b/spec/fixtures/pip_basic/bin/compile @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" +CACHE_DIR="${2}" + +cd "${BUILD_DIR}" + +bin/print-env-vars.sh +echo + +python -c 'import pprint, sys; pprint.pp(sys.path)' +echo + +pip --version +pip list +echo + +python -c 'import typing_extensions; print(typing_extensions)' +echo + +jq --sort-keys '.' "${CACHE_DIR}/build-data/python.json" diff --git a/spec/fixtures/pip_basic/bin/detect b/spec/fixtures/pip_basic/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/pip_basic/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/pip_basic/bin/post_compile b/spec/fixtures/pip_basic/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/pip_basic/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/pip_basic/bin/print-env-vars.sh b/spec/fixtures/pip_basic/bin/print-env-vars.sh new file mode 100755 index 000000000..ebc9ad735 --- /dev/null +++ b/spec/fixtures/pip_basic/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PORT|PS1|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK|TERM)=' diff --git a/spec/fixtures/pip_basic/cat b/spec/fixtures/pip_basic/cat new file mode 100755 index 000000000..d213c595e --- /dev/null +++ b/spec/fixtures/pip_basic/cat @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "This custom script shouldn't take precedence over /usr/bin/cat!" >&2 +exit 1 diff --git a/spec/fixtures/pip_basic/manage.py b/spec/fixtures/pip_basic/manage.py new file mode 100644 index 000000000..db7e7d588 --- /dev/null +++ b/spec/fixtures/pip_basic/manage.py @@ -0,0 +1,2 @@ +# Tests that manage.py alone doesn't trigger Django collectstatic. +raise RuntimeError("This is not a Django app, so manage.py should not be run!") diff --git a/spec/fixtures/pip_basic/requirements-test.txt b/spec/fixtures/pip_basic/requirements-test.txt new file mode 100644 index 000000000..abe3e9819 --- /dev/null +++ b/spec/fixtures/pip_basic/requirements-test.txt @@ -0,0 +1,2 @@ +# This shouldn't be installed, since requirements-test.txt should only be used on Heroku CI. +pytest diff --git a/spec/fixtures/pip_basic/requirements.txt b/spec/fixtures/pip_basic/requirements.txt new file mode 120000 index 000000000..eb513c3cd --- /dev/null +++ b/spec/fixtures/pip_basic/requirements.txt @@ -0,0 +1 @@ +requirements/prod.txt \ No newline at end of file diff --git a/spec/fixtures/pip_basic/requirements/prod.txt b/spec/fixtures/pip_basic/requirements/prod.txt new file mode 100644 index 000000000..728919fa5 --- /dev/null +++ b/spec/fixtures/pip_basic/requirements/prod.txt @@ -0,0 +1,5 @@ +# This requirements file is symlinked from the repo root's requirements.txt +# in order to test that symlinked requirements files work. + +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.15.0 diff --git a/spec/fixtures/pip_basic/setup.py b/spec/fixtures/pip_basic/setup.py new file mode 100644 index 000000000..ab45d8b72 --- /dev/null +++ b/spec/fixtures/pip_basic/setup.py @@ -0,0 +1 @@ +# This file tests that the setup.py sunset error is not shown when other package manager files exist. diff --git a/spec/fixtures/pip_compiled/requirements.txt b/spec/fixtures/pip_compiled/requirements.txt new file mode 100644 index 000000000..ce7cf1ab7 --- /dev/null +++ b/spec/fixtures/pip_compiled/requirements.txt @@ -0,0 +1,2 @@ +# A quick to install package that relies on the Python headers from the base image. +git+https://github.com/pypa/wheel.git@0.44.0#egg=extension.dist&subdirectory=tests/testdata/extension.dist diff --git a/spec/fixtures/pip_editable/.python-version b/spec/fixtures/pip_editable/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/pip_editable/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/requirements_editable/bin/compile b/spec/fixtures/pip_editable/bin/compile similarity index 89% rename from spec/fixtures/requirements_editable/bin/compile rename to spec/fixtures/pip_editable/bin/compile index c506ad6b7..df17e9401 100755 --- a/spec/fixtures/requirements_editable/bin/compile +++ b/spec/fixtures/pip_editable/bin/compile @@ -9,4 +9,4 @@ BUILD_DIR="${1}" cd "${BUILD_DIR}" -exec bin/test-entrypoints +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/pip_editable/bin/detect b/spec/fixtures/pip_editable/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/pip_editable/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/requirements_editable/bin/post_compile b/spec/fixtures/pip_editable/bin/post_compile similarity index 57% rename from spec/fixtures/requirements_editable/bin/post_compile rename to spec/fixtures/pip_editable/bin/post_compile index 460dc2de7..6e77d159a 100755 --- a/spec/fixtures/requirements_editable/bin/post_compile +++ b/spec/fixtures/pip_editable/bin/post_compile @@ -2,4 +2,4 @@ set -euo pipefail -exec bin/test-entrypoints +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/requirements_editable/bin/test-entrypoints b/spec/fixtures/pip_editable/bin/test-entrypoints.sh similarity index 66% rename from spec/fixtures/requirements_editable/bin/test-entrypoints rename to spec/fixtures/pip_editable/bin/test-entrypoints.sh index 4c27ba0f4..0e66f44bb 100755 --- a/spec/fixtures/requirements_editable/bin/test-entrypoints +++ b/spec/fixtures/pip_editable/bin/test-entrypoints.sh @@ -4,8 +4,8 @@ set -euo pipefail cd .heroku/python/lib/python*/site-packages/ -# List any path like strings in .egg-link, .pth, and finder files in site-packages. -grep --extended-regexp --only-matching -- '/\S+' *.egg-link *.pth __editable___*_finder.py | sort +# List any path like strings in the .pth and finder files in site-packages. +grep --extended-regexp --only-matching -- '/\S+' *.pth __editable___*_finder.py | sort echo echo -n "Running entrypoint for the pyproject.toml-based local package: " diff --git a/spec/fixtures/pip_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py b/spec/fixtures/pip_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py new file mode 100644 index 000000000..6ce02c341 --- /dev/null +++ b/spec/fixtures/pip_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py @@ -0,0 +1,2 @@ +def hello(): + print("Hello from pyproject.toml!") diff --git a/spec/fixtures/requirements_editable/packages/local_package_pyproject_toml/pyproject.toml b/spec/fixtures/pip_editable/packages/local_package_pyproject_toml/pyproject.toml similarity index 100% rename from spec/fixtures/requirements_editable/packages/local_package_pyproject_toml/pyproject.toml rename to spec/fixtures/pip_editable/packages/local_package_pyproject_toml/pyproject.toml diff --git a/spec/fixtures/pip_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py b/spec/fixtures/pip_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py new file mode 100644 index 000000000..8dccb8a0c --- /dev/null +++ b/spec/fixtures/pip_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py @@ -0,0 +1,2 @@ +def hello(): + print("Hello from setup.py!") diff --git a/spec/fixtures/requirements_editable/packages/local_package_setup_py/setup.cfg b/spec/fixtures/pip_editable/packages/local_package_setup_py/setup.cfg similarity index 100% rename from spec/fixtures/requirements_editable/packages/local_package_setup_py/setup.cfg rename to spec/fixtures/pip_editable/packages/local_package_setup_py/setup.cfg diff --git a/spec/fixtures/requirements_editable/packages/local_package_setup_py/setup.py b/spec/fixtures/pip_editable/packages/local_package_setup_py/setup.py similarity index 100% rename from spec/fixtures/requirements_editable/packages/local_package_setup_py/setup.py rename to spec/fixtures/pip_editable/packages/local_package_setup_py/setup.py diff --git a/spec/fixtures/requirements_editable/requirements.txt b/spec/fixtures/pip_editable/requirements.txt similarity index 66% rename from spec/fixtures/requirements_editable/requirements.txt rename to spec/fixtures/pip_editable/requirements.txt index 54de167d1..a514dabc1 100644 --- a/spec/fixtures/requirements_editable/requirements.txt +++ b/spec/fixtures/pip_editable/requirements.txt @@ -2,4 +2,4 @@ # https://github.com/pypa/setuptools/issues/3535 -e ./packages/local_package_pyproject_toml -e ./packages/local_package_setup_py --e git+https://github.com/benoitc/gunicorn@20.1.0#egg=gunicorn +-e git+https://github.com/benoitc/gunicorn@56b5ad87f8d72a674145c273ed8f547513c2b409#egg=gunicorn diff --git a/spec/fixtures/requirements_gdal/requirements.txt b/spec/fixtures/pip_gdal/requirements.txt similarity index 100% rename from spec/fixtures/requirements_gdal/requirements.txt rename to spec/fixtures/pip_gdal/requirements.txt diff --git a/spec/fixtures/pip_invalid_requirement/requirements.txt b/spec/fixtures/pip_invalid_requirement/requirements.txt new file mode 100644 index 000000000..db42b7ee6 --- /dev/null +++ b/spec/fixtures/pip_invalid_requirement/requirements.txt @@ -0,0 +1 @@ +an-invalid-requirement! diff --git a/spec/fixtures/pip_legacy_celery/requirements.txt b/spec/fixtures/pip_legacy_celery/requirements.txt new file mode 100644 index 000000000..2abca0701 --- /dev/null +++ b/spec/fixtures/pip_legacy_celery/requirements.txt @@ -0,0 +1 @@ +celery==5.2.0 diff --git a/spec/fixtures/pip_oldest_python/requirements.txt b/spec/fixtures/pip_oldest_python/requirements.txt new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/pip_oldest_python/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.15.0 diff --git a/spec/fixtures/pip_oldest_python/runtime.txt b/spec/fixtures/pip_oldest_python/runtime.txt new file mode 100644 index 000000000..fadb07024 --- /dev/null +++ b/spec/fixtures/pip_oldest_python/runtime.txt @@ -0,0 +1 @@ +python-3.10.0 diff --git a/spec/fixtures/pip_pysqlite3/requirements.txt b/spec/fixtures/pip_pysqlite3/requirements.txt new file mode 100644 index 000000000..043c5fdbe --- /dev/null +++ b/spec/fixtures/pip_pysqlite3/requirements.txt @@ -0,0 +1,3 @@ +# We have to use an older version to test the custom SQLite headers error message, +# since pysqlite3 0.6.0+ ships with precompiled wheels and so doesn't error. +pysqlite3==0.5.4 diff --git a/spec/fixtures/pipenv_and_requirements_txt/requirements.txt b/spec/fixtures/pipenv_and_requirements_txt/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/pipenv_and_requirements_txt/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/pipenv_and_runtime_txt/Pipfile b/spec/fixtures/pipenv_and_runtime_txt/Pipfile deleted file mode 100644 index 1d28b2380..000000000 --- a/spec/fixtures/pipenv_and_runtime_txt/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_and_runtime_txt/Pipfile.lock b/spec/fixtures/pipenv_and_runtime_txt/Pipfile.lock deleted file mode 100644 index 5b0ccff7d..000000000 --- a/spec/fixtures/pipenv_and_runtime_txt/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_and_runtime_txt/runtime.txt b/spec/fixtures/pipenv_and_runtime_txt/runtime.txt deleted file mode 100644 index 74d315a82..000000000 --- a/spec/fixtures/pipenv_and_runtime_txt/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.4 diff --git a/spec/fixtures/pipenv_basic/Pipfile b/spec/fixtures/pipenv_basic/Pipfile new file mode 100644 index 000000000..ec52c677c --- /dev/null +++ b/spec/fixtures/pipenv_basic/Pipfile @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" +# Test that dependencies that happen to also be vendored dependencies of Pipenv are correctly installed, +# since Pipenv's --system mode is buggy and requires a workaround to ensure they aren't skipped. +certifi = "*" +packaging = "*" + +[dev-packages] + +[requires] +python_version = "3.14" diff --git a/spec/fixtures/pipenv_basic/Pipfile.lock b/spec/fixtures/pipenv_basic/Pipfile.lock new file mode 100644 index 000000000..059ed0e08 --- /dev/null +++ b/spec/fixtures/pipenv_basic/Pipfile.lock @@ -0,0 +1,48 @@ +{ + "_meta": { + "hash": { + "sha256": "0ef98f1284f466d096b821c028367648e5141cc11150e42daa0b60172cbe5f43" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", + "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2026.1.4" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + } + }, + "develop": {} +} diff --git a/spec/fixtures/pipenv_basic/bin/compile b/spec/fixtures/pipenv_basic/bin/compile new file mode 100755 index 000000000..31c5a74fb --- /dev/null +++ b/spec/fixtures/pipenv_basic/bin/compile @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" +CACHE_DIR="${2}" + +cd "${BUILD_DIR}" + +bin/print-env-vars.sh +echo + +python -c 'import pprint, sys; pprint.pp(sys.path)' +echo + +pipenv --version +# We have to resort to using pip to list installed packages, since `pipenv graph` doesn't support `--system`. +python -m ensurepip --default-pip >/dev/null +pip list --disable-pip-version-check --exclude pip +echo + +python -c 'import typing_extensions; print(typing_extensions)' +# Test that dependencies that happen to also be vendored dependencies of Pipenv are correctly installed, +# since Pipenv's --system mode is buggy and requires a workaround to ensure they aren't skipped. +python -c 'import certifi; print(certifi)' +python -c 'import packaging; print(packaging)' +echo + +jq --sort-keys '.' "${CACHE_DIR}/build-data/python.json" diff --git a/spec/fixtures/pipenv_basic/bin/detect b/spec/fixtures/pipenv_basic/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/pipenv_basic/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/pipenv_basic/bin/post_compile b/spec/fixtures/pipenv_basic/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/pipenv_basic/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/pipenv_basic/bin/print-env-vars.sh b/spec/fixtures/pipenv_basic/bin/print-env-vars.sh new file mode 100755 index 000000000..ebc9ad735 --- /dev/null +++ b/spec/fixtures/pipenv_basic/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PORT|PS1|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK|TERM)=' diff --git a/spec/fixtures/pipenv_editable/Pipfile b/spec/fixtures/pipenv_editable/Pipfile index fcbd9c1a0..8bb51ee30 100644 --- a/spec/fixtures/pipenv_editable/Pipfile +++ b/spec/fixtures/pipenv_editable/Pipfile @@ -4,6 +4,7 @@ verify_ssl = true name = "pypi" [packages] +gunicorn = {git = "git+https://github.com/benoitc/gunicorn", editable = true} local-package-pyproject-toml = {file = "packages/local_package_pyproject_toml", editable = true} local-package-setup-py = {file = "packages/local_package_setup_py", editable = true} -gunicorn = {git = "git+https://github.com/benoitc/gunicorn", ref = "20.1.0", editable = true} +pipenv-editable = {file = ".", editable = true} diff --git a/spec/fixtures/pipenv_editable/Pipfile.lock b/spec/fixtures/pipenv_editable/Pipfile.lock index 9ed2169b9..1ebf46629 100644 --- a/spec/fixtures/pipenv_editable/Pipfile.lock +++ b/spec/fixtures/pipenv_editable/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5de61064f835cc6b28000b3ac1b7b5c38a40829ccc38ff46088e36bb3bd628fa" + "sha256": "bedd8ea507283c5458c9c2cb1fd55a6e5e69fecba7814ffc96bf25e03feaeabf" }, "pipfile-spec": 6, "requires": {}, @@ -18,7 +18,7 @@ "editable": true, "git": "git+https://github.com/benoitc/gunicorn", "markers": "python_version >= '3.7'", - "ref": "61ccfd6c38d477a908e0f376757bbb884438053a" + "ref": "56b5ad87f8d72a674145c273ed8f547513c2b409" }, "local-package-pyproject-toml": { "editable": true, @@ -28,13 +28,17 @@ "editable": true, "file": "packages/local_package_setup_py" }, - "setuptools": { + "packaging": { "hashes": [ - "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", - "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" ], "markers": "python_version >= '3.8'", - "version": "==69.5.1" + "version": "==25.0" + }, + "pipenv-editable": { + "editable": true, + "file": "." } }, "develop": {} diff --git a/spec/fixtures/pipenv_editable/bin/compile b/spec/fixtures/pipenv_editable/bin/compile index c506ad6b7..df17e9401 100755 --- a/spec/fixtures/pipenv_editable/bin/compile +++ b/spec/fixtures/pipenv_editable/bin/compile @@ -9,4 +9,4 @@ BUILD_DIR="${1}" cd "${BUILD_DIR}" -exec bin/test-entrypoints +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/pipenv_editable/bin/post_compile b/spec/fixtures/pipenv_editable/bin/post_compile index 460dc2de7..6e77d159a 100755 --- a/spec/fixtures/pipenv_editable/bin/post_compile +++ b/spec/fixtures/pipenv_editable/bin/post_compile @@ -2,4 +2,4 @@ set -euo pipefail -exec bin/test-entrypoints +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/pipenv_editable/bin/test-entrypoints.sh b/spec/fixtures/pipenv_editable/bin/test-entrypoints.sh new file mode 100755 index 000000000..8057cbdcf --- /dev/null +++ b/spec/fixtures/pipenv_editable/bin/test-entrypoints.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd .heroku/python/lib/python*/site-packages/ + +# List any path like strings in the .pth and finder files in site-packages. +grep --extended-regexp --only-matching -- '/\S+' *.pth __editable___*_finder.py | sort +echo + +echo -n "Running entrypoint for the current package: " +pipenv-editable + +echo -n "Running entrypoint for the pyproject.toml-based local package: " +local_package_pyproject_toml + +echo -n "Running entrypoint for the setup.py-based local package: " +local_package_setup_py + +echo -n "Running entrypoint for the VCS package: " +gunicorn --version diff --git a/spec/fixtures/pipenv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py b/spec/fixtures/pipenv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py index b86d791d2..6ce02c341 100644 --- a/spec/fixtures/pipenv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py +++ b/spec/fixtures/pipenv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py @@ -1,2 +1,2 @@ def hello(): - print("Hello pyproject.toml!") + print("Hello from pyproject.toml!") diff --git a/spec/fixtures/pipenv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py b/spec/fixtures/pipenv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py index e98d42373..8dccb8a0c 100644 --- a/spec/fixtures/pipenv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py +++ b/spec/fixtures/pipenv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py @@ -1,2 +1,2 @@ def hello(): - print("Hello setup.py!") + print("Hello from setup.py!") diff --git a/spec/fixtures/pipenv_editable/pipenv_editable/__init__.py b/spec/fixtures/pipenv_editable/pipenv_editable/__init__.py new file mode 100644 index 000000000..0f03b9343 --- /dev/null +++ b/spec/fixtures/pipenv_editable/pipenv_editable/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from pipenv-editable!") diff --git a/spec/fixtures/pipenv_editable/pyproject.toml b/spec/fixtures/pipenv_editable/pyproject.toml new file mode 100644 index 000000000..934632a78 --- /dev/null +++ b/spec/fixtures/pipenv_editable/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "pipenv-editable" +version = "0.0.0" +requires-python = ">=3.14" + +[project.scripts] +pipenv-editable = "pipenv_editable:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/spec/fixtures/pipenv_and_requirements_txt/Pipfile b/spec/fixtures/pipenv_lockfile_invalid_json/Pipfile similarity index 100% rename from spec/fixtures/pipenv_and_requirements_txt/Pipfile rename to spec/fixtures/pipenv_lockfile_invalid_json/Pipfile diff --git a/spec/fixtures/pipenv_lockfile_invalid_json/Pipfile.lock b/spec/fixtures/pipenv_lockfile_invalid_json/Pipfile.lock new file mode 100644 index 000000000..0e5491864 --- /dev/null +++ b/spec/fixtures/pipenv_lockfile_invalid_json/Pipfile.lock @@ -0,0 +1 @@ +INVALID JSON diff --git a/spec/fixtures/pipenv_lockfile_out_of_sync/.python-version b/spec/fixtures/pipenv_lockfile_out_of_sync/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/pipenv_lockfile_out_of_sync/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile index 9bf60112c..c8c201a39 100644 --- a/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile +++ b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile @@ -4,9 +4,7 @@ verify_ssl = true name = "pypi" [packages] +# This dependency isn't in the lockfile. urllib3 = "*" [dev-packages] - -[requires] -python_version = "3.12" diff --git a/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock index 4c426c2ae..d2c2cba4c 100644 --- a/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock +++ b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a95c318e4395270fbd1e0f52dd8f12185db4c3258d4e03dd80de54b7c7aad8b1" + "sha256": "ebffa69a1fa192d1cef7cb42ad79231ca976565c5ce371a70160b3048d3cbc06" }, "pipfile-spec": 6, "requires": {}, @@ -13,16 +13,6 @@ } ] }, - "default": { - "urllib3": { - "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" - } - }, + "default": {}, "develop": {} } diff --git a/spec/fixtures/pipenv_mismatched_python_version/.python-version b/spec/fixtures/pipenv_mismatched_python_version/.python-version new file mode 100644 index 000000000..d0c1d45b8 --- /dev/null +++ b/spec/fixtures/pipenv_mismatched_python_version/.python-version @@ -0,0 +1,2 @@ +# The version here intentionally doesn't match `python_version` in Pipfile.lock +3.13 diff --git a/spec/fixtures/pipenv_python_3.12/Pipfile b/spec/fixtures/pipenv_mismatched_python_version/Pipfile similarity index 80% rename from spec/fixtures/pipenv_python_3.12/Pipfile rename to spec/fixtures/pipenv_mismatched_python_version/Pipfile index 9bf60112c..f438ee65c 100644 --- a/spec/fixtures/pipenv_python_3.12/Pipfile +++ b/spec/fixtures/pipenv_mismatched_python_version/Pipfile @@ -4,9 +4,7 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" - -[dev-packages] +typing-extensions = "*" [requires] python_version = "3.12" diff --git a/spec/fixtures/pipenv_and_requirements_txt/Pipfile.lock b/spec/fixtures/pipenv_mismatched_python_version/Pipfile.lock similarity index 53% rename from spec/fixtures/pipenv_and_requirements_txt/Pipfile.lock rename to spec/fixtures/pipenv_mismatched_python_version/Pipfile.lock index cc5ce05bc..517924633 100644 --- a/spec/fixtures/pipenv_and_requirements_txt/Pipfile.lock +++ b/spec/fixtures/pipenv_mismatched_python_version/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:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } }, "develop": {} diff --git a/spec/fixtures/pipenv_no_lockfile/Pipfile b/spec/fixtures/pipenv_no_lockfile/Pipfile index 67803f45b..a62d277f4 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.14" diff --git a/spec/fixtures/pipenv_python_3.10/Pipfile.lock b/spec/fixtures/pipenv_python_3.10/Pipfile.lock deleted file mode 100644 index 7b07be0dc..000000000 --- a/spec/fixtures/pipenv_python_3.10/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "0975b9caa75f76ce36c7cf6ee6fc4b63510c0970ff8e322e0b5e6ee7af2d32e8" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.10" - }, - "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.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.12/Pipfile.lock b/spec/fixtures/pipenv_python_3.12/Pipfile.lock deleted file mode 100644 index cc5ce05bc..000000000 --- a/spec/fixtures/pipenv_python_3.12/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "13de6076b4dc8bbad334b7e4ae8e8ae63f7f8b4aad794c7f0173871b6927c8d0" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.12" - }, - "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.6/Pipfile.lock b/spec/fixtures/pipenv_python_3.6/Pipfile.lock deleted file mode 100644 index 738941ddd..000000000 --- a/spec/fixtures/pipenv_python_3.6/Pipfile.lock +++ /dev/null @@ -1,29 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "fe045e48581ffc6cb50af922ec04120b504f96ff6d6917fc535c8a9b76b8b71b" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.6" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "urllib3": { - "hashes": [ - "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72", - "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1" - ], - "index": "pypi", - "version": "==1.26.14" - } - }, - "develop": {} -} diff --git a/spec/fixtures/pipenv_python_3.7/Pipfile b/spec/fixtures/pipenv_python_3.7/Pipfile deleted file mode 100644 index c973bafe3..000000000 --- a/spec/fixtures/pipenv_python_3.7/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.7" 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.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 88c5c59bc..1b3a10cc1 100644 --- a/spec/fixtures/pipenv_python_full_version/Pipfile +++ b/spec/fixtures/pipenv_python_full_version/Pipfile @@ -4,9 +4,13 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] [requires] -python_full_version = "3.12.2" +# Uses the oldest Python version supported by all stacks, to validate Pipenv works with it. +python_full_version = "3.10.0" + +# `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 2346c8a33..8b1a4f32a 100644 --- a/spec/fixtures/pipenv_python_full_version/Pipfile.lock +++ b/spec/fixtures/pipenv_python_full_version/Pipfile.lock @@ -1,11 +1,12 @@ { "_meta": { "hash": { - "sha256": "5f4af5700c245f61e22d09479627ca936d458a6aa693452337170f369c642f40" + "sha256": "e9ceff57b2002f3a53ac8e84fc449e7aacbb62139f45e8299bde332e3ef9f6b2" }, "pipfile-spec": 6, "requires": { - "python_full_version": "3.12.2" + "python_full_version": "3.10.0", + "python_version": "3.12" }, "sources": [ { @@ -16,14 +17,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile index 683918cbd..4016fd876 100644 --- a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile +++ b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile @@ -4,9 +4,9 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] [requires] -python_full_version = "X.Y.Z" +python_full_version = "3.9.*" diff --git a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock index 3b2d178ab..983c376a9 100644 --- a/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock +++ b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "7efd722f93856d46f698123972037594c6b8621e54b5a2be8ce71f5707a13c33" + "sha256": "254efba85c5858a7dfe232cb38f9ead91bf6e50bbf82f51a8a5a01904ca8712e" }, "pipfile-spec": 6, "requires": { - "python_full_version": "X.Y.Z" + "python_full_version": "3.9.*" }, "sources": [ { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } }, "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_python_3.10/Pipfile b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile similarity index 50% rename from spec/fixtures/pipenv_python_3.10/Pipfile rename to spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile index 37291dc4f..d9d1ea61b 100644 --- a/spec/fixtures/pipenv_python_3.10/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] -python_version = "3.10" +# The version in .python-version should take precedence over this. +python_version = "3.13" diff --git a/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile.lock b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile.lock new file mode 100644 index 000000000..2a3e9cd35 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile.lock @@ -0,0 +1,30 @@ +{ + "_meta": { + "hash": { + "sha256": "83d7242edaa31ec24731c102c5debc217f3e5f5fa5b4e992de07d4d25805715e" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.13" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + } + }, + "develop": {} +} diff --git a/spec/fixtures/pipenv_python_3.8/Pipfile b/spec/fixtures/pipenv_python_version_eol/Pipfile similarity index 85% rename from spec/fixtures/pipenv_python_3.8/Pipfile rename to spec/fixtures/pipenv_python_version_eol/Pipfile index 5aea20cfa..40c03e309 100644 --- a/spec/fixtures/pipenv_python_3.8/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.8/Pipfile.lock b/spec/fixtures/pipenv_python_version_eol/Pipfile.lock similarity index 59% rename from spec/fixtures/pipenv_python_3.8/Pipfile.lock rename to spec/fixtures/pipenv_python_version_eol/Pipfile.lock index f1618e6a9..026a1b0b1 100644 --- a/spec/fixtures/pipenv_python_3.8/Pipfile.lock +++ b/spec/fixtures/pipenv_python_version_eol/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e9eb0bba6cb0a97533d25cd3d68db92d4afd6b1dc88cd7a51607d8c34475eae0" + "sha256": "feaf6b91bd5a191f779b2c2199b3e328369c87e6a598f02a3ba6c76d877e176f" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", + "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==4.13.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..44f3e9fc6 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:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } }, "develop": {} diff --git a/spec/fixtures/pipenv_python_version_unspecified/Pipfile b/spec/fixtures/pipenv_python_version_unspecified/Pipfile index 67803f45b..496fa1558 100644 --- a/spec/fixtures/pipenv_python_version_unspecified/Pipfile +++ b/spec/fixtures/pipenv_python_version_unspecified/Pipfile @@ -4,6 +4,6 @@ verify_ssl = true name = "pypi" [packages] -urllib3 = "*" +typing-extensions = "*" [dev-packages] diff --git a/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock b/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock index 4c426c2ae..2b7b232d6 100644 --- a/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock +++ b/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a95c318e4395270fbd1e0f52dd8f12185db4c3258d4e03dd80de54b7c7aad8b1" + "sha256": "beb76460a63ef2f29eec7b281a3c7114d442db105096d7472b4b72a7504df8fe" }, "pipfile-spec": 6, "requires": {}, @@ -14,14 +14,14 @@ ] }, "default": { - "urllib3": { + "typing-extensions": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } }, "develop": {} diff --git a/spec/fixtures/poetry_basic/.python-version b/spec/fixtures/poetry_basic/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/poetry_basic/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/poetry_basic/bin/compile b/spec/fixtures/poetry_basic/bin/compile new file mode 100755 index 000000000..8b23ed9ce --- /dev/null +++ b/spec/fixtures/poetry_basic/bin/compile @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" +CACHE_DIR="${2}" + +cd "${BUILD_DIR}" + +bin/print-env-vars.sh +echo + +python -c 'import pprint, sys; pprint.pp(sys.path)' +echo + +poetry --version +# The show command also lists dependencies that are in optional groups in pyproject.toml +# but that aren't actually installed, for which the only option is to filter out by hand. +poetry show | grep -v ' (!) ' +echo + +python -c 'import typing_extensions; print(typing_extensions)' +echo + +jq --sort-keys '.' "${CACHE_DIR}/build-data/python.json" diff --git a/spec/fixtures/poetry_basic/bin/detect b/spec/fixtures/poetry_basic/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/poetry_basic/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/poetry_basic/bin/post_compile b/spec/fixtures/poetry_basic/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/poetry_basic/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/poetry_basic/bin/print-env-vars.sh b/spec/fixtures/poetry_basic/bin/print-env-vars.sh new file mode 100755 index 000000000..ebc9ad735 --- /dev/null +++ b/spec/fixtures/poetry_basic/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PORT|PS1|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK|TERM)=' diff --git a/spec/fixtures/poetry_basic/poetry.lock b/spec/fixtures/poetry_basic/poetry.lock new file mode 100644 index 000000000..51cc64181 --- /dev/null +++ b/spec/fixtures/poetry_basic/poetry.lock @@ -0,0 +1,108 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"}, + {file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14" +content-hash = "a0f8d6facd04ce28abc860e56bb4af6e1acac71a1f2a93e4e6ed4e13a299e055" diff --git a/spec/fixtures/poetry_basic/pyproject.toml b/spec/fixtures/poetry_basic/pyproject.toml new file mode 100644 index 000000000..3a23e1a00 --- /dev/null +++ b/spec/fixtures/poetry_basic/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "poetry-basic" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "typing-extensions", +] + +[dependency-groups] +# This group shouldn't be installed due to us passing `--only main`. +dev = [ + "pytest", +] + +[tool.poetry] +package-mode = false diff --git a/spec/fixtures/poetry_editable/bin/compile b/spec/fixtures/poetry_editable/bin/compile new file mode 100755 index 000000000..df17e9401 --- /dev/null +++ b/spec/fixtures/poetry_editable/bin/compile @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that editable requirements are +# usable by buildpacks that run after the Python buildpack during the build. + +set -euo pipefail + +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/poetry_editable/bin/detect b/spec/fixtures/poetry_editable/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/poetry_editable/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/poetry_editable/bin/post_compile b/spec/fixtures/poetry_editable/bin/post_compile new file mode 100755 index 000000000..6e77d159a --- /dev/null +++ b/spec/fixtures/poetry_editable/bin/post_compile @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/poetry_editable/bin/test-entrypoints.sh b/spec/fixtures/poetry_editable/bin/test-entrypoints.sh new file mode 100755 index 000000000..dbdf50fee --- /dev/null +++ b/spec/fixtures/poetry_editable/bin/test-entrypoints.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd .heroku/python/lib/python*/site-packages/ + +# List any path like strings in the .pth and finder files in site-packages. +grep --extended-regexp --only-matching -- '/\S+' *.pth __editable___*_finder.py | sort +echo + +echo -n "Running entrypoint for the current package: " +poetry-editable + +echo -n "Running entrypoint for the pyproject.toml-based local package: " +local_package_pyproject_toml + +echo -n "Running entrypoint for the setup.py-based local package: " +local_package_setup_py + +echo -n "Running entrypoint for the VCS package: " +gunicorn --version diff --git a/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py new file mode 100644 index 000000000..6ce02c341 --- /dev/null +++ b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py @@ -0,0 +1,2 @@ +def hello(): + print("Hello from pyproject.toml!") diff --git a/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml new file mode 100644 index 000000000..f567c3777 --- /dev/null +++ b/spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "local_package_pyproject_toml" +version = "0.0.1" + +[project.scripts] +local_package_pyproject_toml = "local_package_pyproject_toml:hello" diff --git a/spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py b/spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py new file mode 100644 index 000000000..8dccb8a0c --- /dev/null +++ b/spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py @@ -0,0 +1,2 @@ +def hello(): + print("Hello from setup.py!") diff --git a/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg new file mode 100644 index 000000000..eff513964 --- /dev/null +++ b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = local_package_setup_py +version = 0.0.1 + +[options] +packages = local_package_setup_py + +[options.entry_points] +console_scripts = + local_package_setup_py = local_package_setup_py:hello diff --git a/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py new file mode 100644 index 000000000..606849326 --- /dev/null +++ b/spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/spec/fixtures/poetry_editable/poetry.lock b/spec/fixtures/poetry_editable/poetry.lock new file mode 100644 index 000000000..9e21fd4e9 --- /dev/null +++ b/spec/fixtures/poetry_editable/poetry.lock @@ -0,0 +1,72 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "gunicorn" +version = "23.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [] +develop = true + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + +[package.source] +type = "git" +url = "https://github.com/benoitc/gunicorn.git" +reference = "HEAD" +resolved_reference = "56b5ad87f8d72a674145c273ed8f547513c2b409" + +[[package]] +name = "local-package-pyproject-toml" +version = "0.0.1" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] +develop = true + +[package.source] +type = "directory" +url = "packages/local_package_pyproject_toml" + +[[package]] +name = "local_package_setup_py" +version = "0.0.1" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] +develop = true + +[package.source] +type = "directory" +url = "packages/local_package_setup_py" + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "^3.14" +content-hash = "f0f202f553940509c2ae9bfc77fdd0df2448f9df01eee351f823249128699160" diff --git a/spec/fixtures/poetry_editable/poetry_editable/__init__.py b/spec/fixtures/poetry_editable/poetry_editable/__init__.py new file mode 100644 index 000000000..e00661f98 --- /dev/null +++ b/spec/fixtures/poetry_editable/poetry_editable/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from poetry-editable!") diff --git a/spec/fixtures/poetry_editable/pyproject.toml b/spec/fixtures/poetry_editable/pyproject.toml new file mode 100644 index 000000000..cc56aaad4 --- /dev/null +++ b/spec/fixtures/poetry_editable/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "poetry-editable" +version = "0.0.1" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "^3.14" +gunicorn = { git = "https://github.com/benoitc/gunicorn.git", develop = true } +local-package-pyproject-toml = { path = "packages/local_package_pyproject_toml", develop = true } +local-package-setup-py = { path = "packages/local_package_setup_py", develop = true } + +[tool.poetry.scripts] +poetry-editable = 'poetry_editable:main' + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock b/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock new file mode 100644 index 000000000..9b4be8159 --- /dev/null +++ b/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.1" +python-versions = "^3.14" +content-hash = "d3f696e8f01aec1733802da3db722ca7c5e48e3d967cd7e2ce28ee65396abe6c" diff --git a/spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml b/spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml new file mode 100644 index 000000000..1c9cd7013 --- /dev/null +++ b/spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml @@ -0,0 +1,8 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.14" + +# This dependency isn't in the lockfile. +typing-extensions = "*" diff --git a/spec/fixtures/poetry_mismatched_python_version/.python-version b/spec/fixtures/poetry_mismatched_python_version/.python-version new file mode 100644 index 000000000..88c9ebc82 --- /dev/null +++ b/spec/fixtures/poetry_mismatched_python_version/.python-version @@ -0,0 +1,2 @@ +# The version here intentionally doesn't match requires-python in pyproject.toml +3.13 diff --git a/spec/fixtures/poetry_mismatched_python_version/poetry.lock b/spec/fixtures/poetry_mismatched_python_version/poetry.lock new file mode 100644 index 000000000..d7e662543 --- /dev/null +++ b/spec/fixtures/poetry_mismatched_python_version/poetry.lock @@ -0,0 +1,18 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "3.12.*" +content-hash = "30427e182c131408fcdf15a6838a771f7e7c1e67fa6e0c33ccc82194052de8df" diff --git a/spec/fixtures/poetry_mismatched_python_version/pyproject.toml b/spec/fixtures/poetry_mismatched_python_version/pyproject.toml new file mode 100644 index 000000000..ed26e3dc4 --- /dev/null +++ b/spec/fixtures/poetry_mismatched_python_version/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "poetry-mismatched-python-version" +version = "0.0.0" +# This Python version intentionally doesn't match that in .python-version. +# The version here also matches the distro Python version in Ubuntu 24.04, +# so we can also test Poetry doesn't fall back to system Python. +requires-python = "3.12.*" +dependencies = [ + "typing-extensions" +] + +[tool.poetry] +package-mode = false diff --git a/spec/fixtures/poetry_oldest_python/.python-version b/spec/fixtures/poetry_oldest_python/.python-version new file mode 100644 index 000000000..30291cba2 --- /dev/null +++ b/spec/fixtures/poetry_oldest_python/.python-version @@ -0,0 +1 @@ +3.10.0 diff --git a/spec/fixtures/poetry_oldest_python/brotli/.gitkeep b/spec/fixtures/poetry_oldest_python/brotli/.gitkeep new file mode 100644 index 000000000..98d7394a0 --- /dev/null +++ b/spec/fixtures/poetry_oldest_python/brotli/.gitkeep @@ -0,0 +1,2 @@ +The brotli directory tests the workaround for an `ensurepip` bug in older Python versions: +https://github.com/heroku/heroku-buildpack-python/issues/1697 diff --git a/spec/fixtures/poetry_oldest_python/poetry.lock b/spec/fixtures/poetry_oldest_python/poetry.lock new file mode 100644 index 000000000..e8576cea7 --- /dev/null +++ b/spec/fixtures/poetry_oldest_python/poetry.lock @@ -0,0 +1,17 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "cfec7fa4755d6e9851dbc7848a76babbc6c288c7f13cc2bed2e60f83783d4fd2" diff --git a/spec/fixtures/poetry_oldest_python/pyproject.toml b/spec/fixtures/poetry_oldest_python/pyproject.toml new file mode 100644 index 000000000..e193f693f --- /dev/null +++ b/spec/fixtures/poetry_oldest_python/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.10" +typing-extensions = "*" diff --git a/spec/fixtures/pyproject_toml_only/.example-dotfile b/spec/fixtures/pyproject_toml_only/.example-dotfile new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/pyproject_toml_only/pyproject.toml b/spec/fixtures/pyproject_toml_only/pyproject.toml new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/pyproject_toml_only/subdir/some-file b/spec/fixtures/pyproject_toml_only/subdir/some-file new file mode 100644 index 000000000..e69de29bb 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..f6e499a84 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.15.0 diff --git a/spec/fixtures/python_3.10/runtime.txt b/spec/fixtures/python_3.10/runtime.txt deleted file mode 100644 index 2bccd9d9d..000000000 --- a/spec/fixtures/python_3.10/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.10.14 diff --git a/spec/fixtures/python_3.10_outdated/runtime.txt b/spec/fixtures/python_3.10_outdated/runtime.txt deleted file mode 100644 index a5da7cc4d..000000000 --- a/spec/fixtures/python_3.10_outdated/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.10.5 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..f6e499a84 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.15.0 diff --git a/spec/fixtures/python_3.11/runtime.txt b/spec/fixtures/python_3.11/runtime.txt deleted file mode 100644 index 546f3c8de..000000000 --- a/spec/fixtures/python_3.11/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.11.9 diff --git a/spec/fixtures/python_3.11_outdated/runtime.txt b/spec/fixtures/python_3.11_outdated/runtime.txt deleted file mode 100644 index 335156c09..000000000 --- a/spec/fixtures/python_3.11_outdated/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.11.0 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..f6e499a84 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.15.0 diff --git a/spec/fixtures/python_3.12/runtime.txt b/spec/fixtures/python_3.12/runtime.txt deleted file mode 100644 index 74d315a82..000000000 --- a/spec/fixtures/python_3.12/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.4 diff --git a/spec/fixtures/python_3.12_outdated/runtime.txt b/spec/fixtures/python_3.12_outdated/runtime.txt deleted file mode 100644 index 44f8fbe3a..000000000 --- a/spec/fixtures/python_3.12_outdated/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.0 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 new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/python_3.13/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.15.0 diff --git a/spec/fixtures/python_3.14/.python-version b/spec/fixtures/python_3.14/.python-version new file mode 100644 index 000000000..70dc33adb --- /dev/null +++ b/spec/fixtures/python_3.14/.python-version @@ -0,0 +1,2 @@ +# Comments are supported +3.14 diff --git a/spec/fixtures/python_3.14/requirements.txt b/spec/fixtures/python_3.14/requirements.txt new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/python_3.14/requirements.txt @@ -0,0 +1,2 @@ +# This package has been picked since it has no dependencies and is small/fast to install. +typing-extensions==4.15.0 diff --git a/spec/fixtures/python_3.6/requirements.txt b/spec/fixtures/python_3.6/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/python_3.6/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/python_3.6/runtime.txt b/spec/fixtures/python_3.6/runtime.txt deleted file mode 100644 index 6aaad49f7..000000000 --- a/spec/fixtures/python_3.6/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.6.15 diff --git a/spec/fixtures/python_3.7/requirements.txt b/spec/fixtures/python_3.7/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/python_3.7/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 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/requirements.txt b/spec/fixtures/python_3.8/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/python_3.8/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/python_3.8/runtime.txt b/spec/fixtures/python_3.8/runtime.txt deleted file mode 100644 index 96307d57b..000000000 --- a/spec/fixtures/python_3.8/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.8.19 diff --git a/spec/fixtures/python_3.8_outdated/runtime.txt b/spec/fixtures/python_3.8_outdated/runtime.txt deleted file mode 100644 index d9a16ae1e..000000000 --- a/spec/fixtures/python_3.8_outdated/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.8.12 diff --git a/spec/fixtures/python_3.9/requirements.txt b/spec/fixtures/python_3.9/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/python_3.9/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/python_3.9/runtime.txt b/spec/fixtures/python_3.9/runtime.txt deleted file mode 100644 index 4458b433c..000000000 --- a/spec/fixtures/python_3.9/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.9.19 diff --git a/spec/fixtures/python_3.9_outdated/runtime.txt b/spec/fixtures/python_3.9_outdated/runtime.txt deleted file mode 100644 index 540296197..000000000 --- a/spec/fixtures/python_3.9_outdated/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.9.12 diff --git a/spec/fixtures/python_in_app_source/.heroku/python/bin/python b/spec/fixtures/python_in_app_source/.heroku/python/bin/python new file mode 100644 index 000000000..04b03ed28 --- /dev/null +++ b/spec/fixtures/python_in_app_source/.heroku/python/bin/python @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# This file emulates a Python install having been committed to the app's Git repo. +# For example, by downloading a slug, extracting it, and committing the results. + +set -euo pipefail + +exit 0 diff --git a/spec/fixtures/python_in_app_source/.python-version b/spec/fixtures/python_in_app_source/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/python_in_app_source/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/python_in_app_source/requirements.txt b/spec/fixtures/python_in_app_source/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_eol/.python-version b/spec/fixtures/python_version_eol/.python-version new file mode 100644 index 000000000..033763cdf --- /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.9 + diff --git a/spec/fixtures/python_version_eol/requirements.txt b/spec/fixtures/python_version_eol/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_eol_cached/bin/pre_compile b/spec/fixtures/python_version_eol_cached/bin/pre_compile new file mode 100755 index 000000000..63422ee0b --- /dev/null +++ b/spec/fixtures/python_version_eol_cached/bin/pre_compile @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Emulate the cache from a previous build that used a now-EOL Python version. +mkdir -p "${CACHE_DIR:?}/.heroku" +echo "python-3.9.25" >"${CACHE_DIR}/.heroku/python-version" diff --git a/spec/fixtures/python_version_eol_cached/requirements.txt b/spec/fixtures/python_version_eol_cached/requirements.txt new file mode 100644 index 000000000..e69de29bb 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..8641e5c56 --- /dev/null +++ b/spec/fixtures/python_version_file_invalid_version/.python-version @@ -0,0 +1,19 @@ +# Comments are supported. + # Even when indented +# +# So are empty lines, and leading/trailing whitespace. + + # So are tabs + + +# So are CRLFs (this whole file has CRLF line endings) + +# This version should be ignored, since it's commented out +# 2.7.18 + + # This version number has: + # - a leading zero width space character + # - a normal space and a non-breaking space in the middle + # - a trailing ESC control character (from an ANSI escape sequence) at the end + ​python -  3.12.0 + diff --git a/spec/fixtures/python_version_file_invalid_version/requirements.txt b/spec/fixtures/python_version_file_invalid_version/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_file_misspelled/.python-version b/spec/fixtures/python_version_file_misspelled/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/python_version_file_misspelled/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/python_version_file_misspelled/.python-version b/spec/fixtures/python_version_file_misspelled/.python-version new file mode 100644 index 000000000..f7549fdca --- /dev/null +++ b/spec/fixtures/python_version_file_misspelled/.python-version @@ -0,0 +1 @@ +# This file has a trailing space in its filename diff --git a/spec/fixtures/python_version_file_misspelled/requirements.txt b/spec/fixtures/python_version_file_misspelled/requirements.txt new file mode 100644 index 000000000..e69de29bb 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..b5bdebd50 --- /dev/null +++ b/spec/fixtures/python_version_file_multiple_versions/.python-version @@ -0,0 +1,5 @@ +# Valid comment +3.12 + + # Indentation should be stripped in the error message + 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_file_unsupported_encoding/.python-version b/spec/fixtures/python_version_file_unsupported_encoding/.python-version new file mode 100644 index 000000000..adc7314b0 --- /dev/null +++ b/spec/fixtures/python_version_file_unsupported_encoding/.python-version @@ -0,0 +1,2 @@ +# This file is saved with the encoding: UTF-8 with BOM +3.12 diff --git a/spec/fixtures/python_version_file_unsupported_encoding/requirements.txt b/spec/fixtures/python_version_file_unsupported_encoding/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_invalid/runtime.txt b/spec/fixtures/python_version_invalid/runtime.txt deleted file mode 100644 index 504402610..000000000 --- a/spec/fixtures/python_version_invalid/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.0invalid 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/requirements.txt b/spec/fixtures/python_version_non_existent_major/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_non_existent_major_cached/bin/pre_compile b/spec/fixtures/python_version_non_existent_major_cached/bin/pre_compile new file mode 100755 index 000000000..1075c4857 --- /dev/null +++ b/spec/fixtures/python_version_non_existent_major_cached/bin/pre_compile @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Emulate the cache from a previous build which used a newer default Python version +# than the version supported by this buildpack version. +mkdir -p "${CACHE_DIR:?}/.heroku" +echo "python-3.99.0" >"${CACHE_DIR}/.heroku/python-version" diff --git a/spec/fixtures/python_version_non_existent_major_cached/requirements.txt b/spec/fixtures/python_version_non_existent_major_cached/requirements.txt new file mode 100644 index 000000000..e69de29bb 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/requirements.txt b/spec/fixtures/python_version_non_existent_patch/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_outdated/.python-version b/spec/fixtures/python_version_outdated/.python-version new file mode 100644 index 000000000..f982feb41 --- /dev/null +++ b/spec/fixtures/python_version_outdated/.python-version @@ -0,0 +1 @@ +3.14.0 diff --git a/spec/fixtures/python_version_outdated/requirements.txt b/spec/fixtures/python_version_outdated/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/python_version_unspecified/requirements.txt b/spec/fixtures/python_version_unspecified/requirements.txt index a42590beb..f6e499a84 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.15.0 diff --git a/spec/fixtures/requirements_compiled/requirements.txt b/spec/fixtures/requirements_compiled/requirements.txt deleted file mode 100644 index 2331d3f79..000000000 --- a/spec/fixtures/requirements_compiled/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Packages that rely on headers from the stack image (depending on wheel availability). -cffi -mysqlclient -psycopg2 -pylibmc -pysqlite3 diff --git a/spec/fixtures/requirements_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py b/spec/fixtures/requirements_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py deleted file mode 100644 index b86d791d2..000000000 --- a/spec/fixtures/requirements_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def hello(): - print("Hello pyproject.toml!") diff --git a/spec/fixtures/requirements_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py b/spec/fixtures/requirements_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py deleted file mode 100644 index e98d42373..000000000 --- a/spec/fixtures/requirements_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def hello(): - print("Hello setup.py!") diff --git a/spec/fixtures/requirements_git/requirements.txt b/spec/fixtures/requirements_git/requirements.txt deleted file mode 100644 index 81e9dbfe0..000000000 --- a/spec/fixtures/requirements_git/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# This relies upon the VCS binaries from the stack image. -git+https://github.com/certifi/python-certifi diff --git a/spec/fixtures/requirements_txt_and_setup_py/requirements.txt b/spec/fixtures/requirements_txt_and_setup_py/requirements.txt deleted file mode 100644 index a42590beb..000000000 --- a/spec/fixtures/requirements_txt_and_setup_py/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -urllib3 diff --git a/spec/fixtures/requirements_txt_and_setup_py/setup.py b/spec/fixtures/requirements_txt_and_setup_py/setup.py deleted file mode 100644 index 8d636d69a..000000000 --- a/spec/fixtures/requirements_txt_and_setup_py/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -from setuptools import setup - -setup( - name='test', - install_requires=['six'], -) 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..354250f94 --- /dev/null +++ b/spec/fixtures/runtime_txt_and_python_version_file/runtime.txt @@ -0,0 +1,8 @@ +# Comments are supported + # Even when indented + +# This version should be ignored, since it's commented out +# 2.7.18 + + 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/runtime_txt_eol_version/runtime.txt b/spec/fixtures/runtime_txt_eol_version/runtime.txt new file mode 100644 index 000000000..586b67310 --- /dev/null +++ b/spec/fixtures/runtime_txt_eol_version/runtime.txt @@ -0,0 +1 @@ +python-2.7.18 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/runtime_txt_invalid_version/runtime.txt b/spec/fixtures/runtime_txt_invalid_version/runtime.txt new file mode 100644 index 000000000..75986f4ed Binary files /dev/null and b/spec/fixtures/runtime_txt_invalid_version/runtime.txt differ diff --git a/spec/fixtures/runtime_txt_only/runtime.txt b/spec/fixtures/runtime_txt_only/runtime.txt deleted file mode 100644 index 74d315a82..000000000 --- a/spec/fixtures/runtime_txt_only/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.4 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 eb7c2eafb..000000000 --- a/spec/fixtures/runtime_txt_with_stray_whitespace/runtime.txt +++ /dev/null @@ -1,3 +0,0 @@ - - python-3.12.4 - \ 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..6324d401a --- /dev/null +++ b/spec/fixtures/setup_py_only/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/setup_py_only/setup.py b/spec/fixtures/setup_py_only/setup.py index 8d636d69a..e69de29bb 100644 --- a/spec/fixtures/setup_py_only/setup.py +++ b/spec/fixtures/setup_py_only/setup.py @@ -1,6 +0,0 @@ -from setuptools import setup - -setup( - name='test', - install_requires=['six'], -) diff --git a/spec/fixtures/uv_basic/.python-version b/spec/fixtures/uv_basic/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/uv_basic/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/uv_basic/bin/compile b/spec/fixtures/uv_basic/bin/compile new file mode 100755 index 000000000..5a6fb09f4 --- /dev/null +++ b/spec/fixtures/uv_basic/bin/compile @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that the environment is +# configured as expected for buildpacks that run after the Python buildpack. + +set -euo pipefail + +BUILD_DIR="${1}" +CACHE_DIR="${2}" + +cd "${BUILD_DIR}" + +bin/print-env-vars.sh +echo + +python -c 'import pprint, sys; pprint.pp(sys.path)' +echo + +uv --version +uv pip list +echo + +python -c 'import typing_extensions; print(typing_extensions)' +echo + +jq --sort-keys '.' "${CACHE_DIR}/build-data/python.json" diff --git a/spec/fixtures/uv_basic/bin/detect b/spec/fixtures/uv_basic/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/uv_basic/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/uv_basic/bin/post_compile b/spec/fixtures/uv_basic/bin/post_compile new file mode 100644 index 000000000..15dc8c2f3 --- /dev/null +++ b/spec/fixtures/uv_basic/bin/post_compile @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This intentionally uses a relative path to test that the CWD is correct. +exec bin/print-env-vars.sh diff --git a/spec/fixtures/uv_basic/bin/print-env-vars.sh b/spec/fixtures/uv_basic/bin/print-env-vars.sh new file mode 100755 index 000000000..ebc9ad735 --- /dev/null +++ b/spec/fixtures/uv_basic/bin/print-env-vars.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PORT|PS1|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK|TERM)=' diff --git a/spec/fixtures/uv_basic/pyproject.toml b/spec/fixtures/uv_basic/pyproject.toml new file mode 100644 index 000000000..b0b1ffa60 --- /dev/null +++ b/spec/fixtures/uv_basic/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "uv-basic" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "typing-extensions", +] + +[dependency-groups] +# This group shouldn't be installed due to us passing `--no-default-groups`. +dev = [ + "pytest", +] diff --git a/spec/fixtures/uv_basic/uv.lock b/spec/fixtures/uv_basic/uv.lock new file mode 100644 index 000000000..163be6bca --- /dev/null +++ b/spec/fixtures/uv_basic/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uv-basic" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "typing-extensions" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] diff --git a/spec/fixtures/uv_editable/.python-version b/spec/fixtures/uv_editable/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/uv_editable/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/uv_editable/bin/compile b/spec/fixtures/uv_editable/bin/compile new file mode 100755 index 000000000..df17e9401 --- /dev/null +++ b/spec/fixtures/uv_editable/bin/compile @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack, and tests that editable requirements are +# usable by buildpacks that run after the Python buildpack during the build. + +set -euo pipefail + +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/uv_editable/bin/detect b/spec/fixtures/uv_editable/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/uv_editable/bin/detect @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file is run by the inline buildpack. + +set -euo pipefail + +echo "Inline" diff --git a/spec/fixtures/uv_editable/bin/post_compile b/spec/fixtures/uv_editable/bin/post_compile new file mode 100755 index 000000000..6e77d159a --- /dev/null +++ b/spec/fixtures/uv_editable/bin/post_compile @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +exec bin/test-entrypoints.sh diff --git a/spec/fixtures/pipenv_editable/bin/test-entrypoints b/spec/fixtures/uv_editable/bin/test-entrypoints.sh similarity index 60% rename from spec/fixtures/pipenv_editable/bin/test-entrypoints rename to spec/fixtures/uv_editable/bin/test-entrypoints.sh index 4c27ba0f4..2d7fab200 100755 --- a/spec/fixtures/pipenv_editable/bin/test-entrypoints +++ b/spec/fixtures/uv_editable/bin/test-entrypoints.sh @@ -4,10 +4,13 @@ set -euo pipefail cd .heroku/python/lib/python*/site-packages/ -# List any path like strings in .egg-link, .pth, and finder files in site-packages. -grep --extended-regexp --only-matching -- '/\S+' *.egg-link *.pth __editable___*_finder.py | sort +# List any path like strings in the .pth and finder files in site-packages. +grep --extended-regexp --only-matching -- '/\S+' *.pth __editable___*_finder.py | sort echo +echo -n "Running entrypoint for the current package: " +uv-editable + echo -n "Running entrypoint for the pyproject.toml-based local package: " local_package_pyproject_toml diff --git a/spec/fixtures/uv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py b/spec/fixtures/uv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py new file mode 100644 index 000000000..6ce02c341 --- /dev/null +++ b/spec/fixtures/uv_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py @@ -0,0 +1,2 @@ +def hello(): + print("Hello from pyproject.toml!") diff --git a/spec/fixtures/uv_editable/packages/local_package_pyproject_toml/pyproject.toml b/spec/fixtures/uv_editable/packages/local_package_pyproject_toml/pyproject.toml new file mode 100644 index 000000000..f567c3777 --- /dev/null +++ b/spec/fixtures/uv_editable/packages/local_package_pyproject_toml/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "local_package_pyproject_toml" +version = "0.0.1" + +[project.scripts] +local_package_pyproject_toml = "local_package_pyproject_toml:hello" diff --git a/spec/fixtures/uv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py b/spec/fixtures/uv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py new file mode 100644 index 000000000..8dccb8a0c --- /dev/null +++ b/spec/fixtures/uv_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py @@ -0,0 +1,2 @@ +def hello(): + print("Hello from setup.py!") diff --git a/spec/fixtures/uv_editable/packages/local_package_setup_py/setup.cfg b/spec/fixtures/uv_editable/packages/local_package_setup_py/setup.cfg new file mode 100644 index 000000000..eff513964 --- /dev/null +++ b/spec/fixtures/uv_editable/packages/local_package_setup_py/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = local_package_setup_py +version = 0.0.1 + +[options] +packages = local_package_setup_py + +[options.entry_points] +console_scripts = + local_package_setup_py = local_package_setup_py:hello diff --git a/spec/fixtures/uv_editable/packages/local_package_setup_py/setup.py b/spec/fixtures/uv_editable/packages/local_package_setup_py/setup.py new file mode 100644 index 000000000..606849326 --- /dev/null +++ b/spec/fixtures/uv_editable/packages/local_package_setup_py/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/spec/fixtures/uv_editable/pyproject.toml b/spec/fixtures/uv_editable/pyproject.toml new file mode 100644 index 000000000..15a7b2365 --- /dev/null +++ b/spec/fixtures/uv_editable/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "uv-editable" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "gunicorn", + "local-package-pyproject-toml", + "local-package-setup-py", +] + +[project.scripts] +uv-editable = "uv_editable:main" + +[build-system] +requires = ["uv_build"] +build-backend = "uv_build" + +[tool.uv.sources] +# uv doesn't support editable mode with VCS dependencies: +# https://github.com/astral-sh/uv/issues/5442 +# If that ever changes, we should switch to `editable = true` for gunicorn too. +# We still include the Git gunicorn dependency here to ensure we have VCS coverage. +gunicorn = { git = "https://github.com/benoitc/gunicorn" } +local-package-pyproject-toml = { path = "packages/local_package_pyproject_toml", editable = true } +local-package-setup-py = { path = "packages/local_package_setup_py", editable = true } diff --git a/spec/fixtures/uv_editable/src/uv_editable/__init__.py b/spec/fixtures/uv_editable/src/uv_editable/__init__.py new file mode 100644 index 000000000..ae2b5f267 --- /dev/null +++ b/spec/fixtures/uv_editable/src/uv_editable/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from uv-editable!") diff --git a/spec/fixtures/uv_editable/uv.lock b/spec/fixtures/uv_editable/uv.lock new file mode 100644 index 000000000..506143ee7 --- /dev/null +++ b/spec/fixtures/uv_editable/uv.lock @@ -0,0 +1,47 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { git = "https://github.com/benoitc/gunicorn#56b5ad87f8d72a674145c273ed8f547513c2b409" } +dependencies = [ + { name = "packaging" }, +] + +[[package]] +name = "local-package-pyproject-toml" +version = "0.0.1" +source = { editable = "packages/local_package_pyproject_toml" } + +[[package]] +name = "local-package-setup-py" +version = "0.0.1" +source = { editable = "packages/local_package_setup_py" } + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "uv-editable" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "gunicorn" }, + { name = "local-package-pyproject-toml" }, + { name = "local-package-setup-py" }, +] + +[package.metadata] +requires-dist = [ + { name = "gunicorn", git = "https://github.com/benoitc/gunicorn" }, + { name = "local-package-pyproject-toml", editable = "packages/local_package_pyproject_toml" }, + { name = "local-package-setup-py", editable = "packages/local_package_setup_py" }, +] diff --git a/spec/fixtures/uv_lockfile_out_of_sync/.python-version b/spec/fixtures/uv_lockfile_out_of_sync/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/uv_lockfile_out_of_sync/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/uv_lockfile_out_of_sync/pyproject.toml b/spec/fixtures/uv_lockfile_out_of_sync/pyproject.toml new file mode 100644 index 000000000..8bd3574b4 --- /dev/null +++ b/spec/fixtures/uv_lockfile_out_of_sync/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "uv-lockfile-out-of-sync" +version = "0.0.0" +requires-python = ">=3.14" + +# This dependency isn't in the lockfile. +dependencies = [ + "typing-extensions", +] diff --git a/spec/fixtures/uv_lockfile_out_of_sync/uv.lock b/spec/fixtures/uv_lockfile_out_of_sync/uv.lock new file mode 100644 index 000000000..50a8225c6 --- /dev/null +++ b/spec/fixtures/uv_lockfile_out_of_sync/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "uv-lockfile-out-of-sync" +version = "0.0.0" +source = { virtual = "." } diff --git a/spec/fixtures/uv_mismatched_python_version/.python-version b/spec/fixtures/uv_mismatched_python_version/.python-version new file mode 100644 index 000000000..88c9ebc82 --- /dev/null +++ b/spec/fixtures/uv_mismatched_python_version/.python-version @@ -0,0 +1,2 @@ +# The version here intentionally doesn't match requires-python in pyproject.toml +3.13 diff --git a/spec/fixtures/uv_mismatched_python_version/pyproject.toml b/spec/fixtures/uv_mismatched_python_version/pyproject.toml new file mode 100644 index 000000000..b93af5953 --- /dev/null +++ b/spec/fixtures/uv_mismatched_python_version/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "uv-mismatched-python-version" +version = "0.0.0" +# The Python version here intentionally: +# - Doesn't match the version in .python-version +# - Matches the distro Python version in Ubuntu 24.04 +# ...so we can test both the error message and that uv doesn't fall back to system Python. +requires-python = "==3.12.*" +dependencies = [] diff --git a/spec/fixtures/uv_mismatched_python_version/uv.lock b/spec/fixtures/uv_mismatched_python_version/uv.lock new file mode 100644 index 000000000..5982e7c5e --- /dev/null +++ b/spec/fixtures/uv_mismatched_python_version/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "uv-mismatched-python-version" +version = "0.0.0" +source = { virtual = "." } diff --git a/spec/fixtures/uv_no_python_version_file/pyproject.toml b/spec/fixtures/uv_no_python_version_file/pyproject.toml new file mode 100644 index 000000000..dce78d6ba --- /dev/null +++ b/spec/fixtures/uv_no_python_version_file/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "uv-no-python-version-file" +version = "0.0.0" +# The Python version here intentionally: +# - Doesn't match the buildpack's default Python version +# - Matches the distro Python version in Ubuntu 24.04 +# ...so we can test both the error message and that uv doesn't fall back to system Python. +requires-python = "==3.12.*" +dependencies = [] diff --git a/spec/fixtures/uv_no_python_version_file/uv.lock b/spec/fixtures/uv_no_python_version_file/uv.lock new file mode 100644 index 000000000..b1a4d90e0 --- /dev/null +++ b/spec/fixtures/uv_no_python_version_file/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "uv-no-python-version-file" +version = "0.0.0" +source = { virtual = "." } diff --git a/spec/fixtures/uv_oldest_python/.python-version b/spec/fixtures/uv_oldest_python/.python-version new file mode 100644 index 000000000..30291cba2 --- /dev/null +++ b/spec/fixtures/uv_oldest_python/.python-version @@ -0,0 +1 @@ +3.10.0 diff --git a/spec/fixtures/uv_oldest_python/pyproject.toml b/spec/fixtures/uv_oldest_python/pyproject.toml new file mode 100644 index 000000000..bf4f99549 --- /dev/null +++ b/spec/fixtures/uv_oldest_python/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "uv-oldest-python" +version = "0.0.0" +requires-python = "==3.10.*" +dependencies = [ + "typing-extensions", +] diff --git a/spec/fixtures/uv_oldest_python/uv.lock b/spec/fixtures/uv_oldest_python/uv.lock new file mode 100644 index 000000000..47873756d --- /dev/null +++ b/spec/fixtures/uv_oldest_python/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 2 +requires-python = "==3.10.*" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uv-oldest-python" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [{ name = "typing-extensions" }] diff --git a/spec/fixtures/uv_runtime_txt/.python-version b/spec/fixtures/uv_runtime_txt/.python-version new file mode 100644 index 000000000..1be10d256 --- /dev/null +++ b/spec/fixtures/uv_runtime_txt/.python-version @@ -0,0 +1,5 @@ +# The Python version here intentionally: +# - Doesn't match that in runtime.txt +# - Matches the distro Python version in Ubuntu 24.04 +# ...so we can test both the error message and that uv doesn't fall back to system Python. +3.12 diff --git a/spec/fixtures/uv_runtime_txt/pyproject.toml b/spec/fixtures/uv_runtime_txt/pyproject.toml new file mode 100644 index 000000000..c8f057a16 --- /dev/null +++ b/spec/fixtures/uv_runtime_txt/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "uv-runtime-txt" +version = "0.0.0" +# The Python version here intentionally: +# - Doesn't match the version in runtime.txt +# - Matches the distro Python version in Ubuntu 24.04 +# ...so we can test both the error message and that uv doesn't fall back to system Python. +requires-python = "==3.12.*" +dependencies = [] diff --git a/spec/fixtures/uv_runtime_txt/runtime.txt b/spec/fixtures/uv_runtime_txt/runtime.txt new file mode 100644 index 000000000..67ebc4e9a --- /dev/null +++ b/spec/fixtures/uv_runtime_txt/runtime.txt @@ -0,0 +1 @@ +python-3.11 diff --git a/spec/fixtures/uv_runtime_txt/uv.lock b/spec/fixtures/uv_runtime_txt/uv.lock new file mode 100644 index 000000000..8d1d51bcf --- /dev/null +++ b/spec/fixtures/uv_runtime_txt/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "uv-runtime-txt" +version = "0.0.0" +source = { virtual = "." } diff --git a/spec/fixtures/venv_in_app_source/.gitignore b/spec/fixtures/venv_in_app_source/.gitignore new file mode 100644 index 000000000..04e429ac0 --- /dev/null +++ b/spec/fixtures/venv_in_app_source/.gitignore @@ -0,0 +1,2 @@ +# Overrides the repo root's .gitignore, which includes `.venv/` +!* diff --git a/spec/fixtures/venv_in_app_source/.python-version b/spec/fixtures/venv_in_app_source/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/spec/fixtures/venv_in_app_source/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/spec/fixtures/venv_in_app_source/.venv/bin/python b/spec/fixtures/venv_in_app_source/.venv/bin/python new file mode 100644 index 000000000..03879ebe8 --- /dev/null +++ b/spec/fixtures/venv_in_app_source/.venv/bin/python @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This file emulates a Python venv having been committed to the app's Git repo. + +set -euo pipefail + +exit 0 diff --git a/spec/fixtures/venv_in_app_source/.venv/pyvenv.cfg b/spec/fixtures/venv_in_app_source/.venv/pyvenv.cfg new file mode 100644 index 000000000..2cefa6c36 --- /dev/null +++ b/spec/fixtures/venv_in_app_source/.venv/pyvenv.cfg @@ -0,0 +1 @@ +# This file emulates a Python venv having been committed to the app's Git repo. diff --git a/spec/fixtures/venv_in_app_source/requirements.txt b/spec/fixtures/venv_in_app_source/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/hatchet/checks_spec.rb b/spec/hatchet/checks_spec.rb new file mode 100644 index 000000000..bf8bbdc17 --- /dev/null +++ b/spec/hatchet/checks_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Buildpack validation checks' do + context 'when there are duplicate Python buildpacks set on the app' do + let(:buildpacks) { %i[default default] } + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified', buildpacks:, allow_failure: true) } + + it 'fails the build with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: The Python buildpack has already been run this build. + remote: ! + remote: ! An existing Python installation was found in the build directory + remote: ! from a buildpack run earlier in the build. + remote: ! + remote: ! This normally means there are duplicate Python buildpacks set + remote: ! on your app, which isn't supported, can cause errors and + remote: ! slow down builds. + remote: ! + remote: ! Check the buildpacks set on your app and remove any duplicate + remote: ! Python buildpack entries: + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#remove-classic-buildpacks + remote: ! + remote: ! Note: This error replaces the deprecation warning which was + remote: ! displayed in build logs starting 13th December 2024. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when the app source contains a broken Python install' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_in_app_source', allow_failure: true) } + + it 'fails the build with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Existing '.heroku/python/' directory found. + remote: ! + remote: ! Your app's source code contains an existing directory named + remote: ! '.heroku/python/', which is where the Python buildpack needs + remote: ! to install its files. This existing directory contains: + remote: ! + remote: ! .heroku/python/ + remote: ! .heroku/python/bin + remote: ! .heroku/python/bin/python + remote: ! + remote: ! Writing to internal locations used by the Python buildpack + remote: ! isn't supported and can cause unexpected errors. + remote: ! + remote: ! If you have committed a '.heroku/python/' directory to your + remote: ! Git repo, you must delete it or use a different location. + remote: ! + remote: ! Otherwise, check that an earlier buildpack or 'bin/pre_compile' + remote: ! hook hasn't created this directory. + remote: ! + remote: ! Note: This error replaces the deprecation warning which was + remote: ! displayed in build logs starting 13th December 2024. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when the app source contains a venv directory' do + let(:app) { Hatchet::Runner.new('spec/fixtures/venv_in_app_source', allow_failure: true) } + + it 'fails the build with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Existing '.venv/' directory found. + remote: ! + remote: ! Your app's source code contains an existing directory named + remote: ! '.venv/', which looks like a Python virtual environment: + remote: ! + remote: ! .venv/ + remote: ! .venv/bin + remote: ! .venv/bin/python + remote: ! .venv/pyvenv.cfg + remote: ! + remote: ! Including a virtual environment directory in your app source + remote: ! isn't supported since the files within it are specific to a + remote: ! single machine and so won't work when run somewhere else. + remote: ! + remote: ! If you've committed a '.venv/' directory to your Git repo, you + remote: ! must delete it and add the directory to your .gitignore file. + remote: ! + remote: ! To do this: + remote: ! 1. Run 'git rm --cached -r .venv/' to remove the directory + remote: ! from the Git index. + remote: ! 2. Create a '.gitignore' file in the root of your repository + remote: ! if it doesn't already exist. + remote: ! 3. Add the '.venv/' directory to the .gitignore file as a + remote: ! new entry on its own line (don't include the quotes). + remote: ! 4. Stage the change using 'git add .gitignore' and then + remote: ! 'git commit' all changes. + remote: ! + remote: ! For more information, see: + remote: ! https://docs.github.com/en/get-started/git-basics/ignoring-files + remote: ! + remote: ! If the directory was created by a 'bin/pre_compile' hook or + remote: ! an earlier buildpack, you must instead update them to create + remote: ! the virtual environment in a different location. + remote: ! + remote: ! Note: This error replaces the previous warning which had been + remote: ! displayed in build logs since 2nd September 2025. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end +end diff --git a/spec/hatchet/ci_spec.rb b/spec/hatchet/ci_spec.rb index eb0b2c5db..9b4ba8af9 100644 --- a/spec/hatchet/ci_spec.rb +++ b/spec/hatchet/ci_spec.rb @@ -3,46 +3,372 @@ require_relative '../spec_helper' RSpec.describe 'Heroku CI' do - it 'installs both normal and test dependencies and uses cache on subsequent runs' do - Hatchet::Runner.new('spec/fixtures/ci_requirements', allow_failure: true).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_VERSION} - To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - -----> Installing python-#{DEFAULT_PYTHON_VERSION} - -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - -----> Installing SQLite3 - -----> Installing requirements with pip - .* - Successfully installed urllib3-.* - -----> Installing test dependencies... - .* - Successfully installed .* pytest-.* - -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. - -----> No test-setup command provided. Skipping. - -----> Running test command `pytest --version`... - pytest .* - -----> test command `pytest --version` completed successfully - REGEX - - test_run.run_again - - 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_VERSION} - To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - -----> No change in requirements detected, installing from cache - -----> Using cached install of python-#{DEFAULT_PYTHON_VERSION} - -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - -----> Installing SQLite3 - -----> Installing requirements with pip - -----> Installing test dependencies... - -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. - -----> No test-setup command provided. Skipping. - -----> Running test command `pytest --version`... - pytest .* - -----> test command `pytest --version` completed successfully - REGEX + let(:buildpacks) { [:default, 'heroku-community/inline'] } + + context 'when using pip' do + let(:app) { Hatchet::Runner.new('spec/fixtures/ci_pip', buildpacks:) } + + it 'installs both normal and test dependencies and uses cache on subsequent runs' do + app.run_ci do |test_run| + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Installing pip #{PIP_VERSION} + -----> Installing dependencies using 'pip install -r requirements.txt -r requirements-test.txt' + .+ + Successfully installed .+ pytest-.+ typing-extensions-.+ + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + BUILD_DIR=/app + CACHE_DIR=/tmp/cache.+ + CI=true + CPLUS_INCLUDE_PATH=/app/.heroku/python/include + C_INCLUDE_PATH=/app/.heroku/python/include + DISABLE_COLLECTSTATIC=1 + ENV_DIR=/tmp/env.+ + INSTALL_TEST=1 + LANG=en_US.UTF-8 + LC_ALL=C.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PIP_DISABLE_PIP_VERSION_CHECK=1 + PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + PYTHONUNBUFFERED=1 + -----> Saving cache + + ! Note: We recently added support for the package manager uv: + ! https://devcenter.heroku.com/changelog-items/3238 + ! + ! It's now our recommended Python package manager, since it + ! supports lockfiles, is faster, gives more helpful error + ! messages, and is actively maintained by a full-time team. + ! + ! If you haven't tried it yet, we suggest you take a look! + ! https://docs.astral.sh/uv/ + + -----> Inline app detected + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PIP_DISABLE_PIP_VERSION_CHECK=1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + -----> No test-setup command provided. Skipping. + -----> Running test command `./bin/print-env-vars.sh && pytest --version`... + CI=true + DYNO_RAM=2560 + FORWARDED_ALLOW_IPS=\\* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/:/app/.sprettur/bin/ + PIP_DISABLE_PIP_VERSION_CHECK=1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=5 + WEB_CONCURRENCY_SET_BY=heroku/python + pytest .+ + -----> test command `./bin/print-env-vars.sh && pytest --version` completed successfully + REGEX + + test_run.run_again + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Restoring cache + -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Installing pip #{PIP_VERSION} + -----> Installing dependencies using 'pip install -r requirements.txt -r requirements-test.txt' + Requirement already satisfied: typing-extensions==.+ + Requirement already satisfied: pytest==.+ + .+ + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + REGEX + end + end + end + + context 'when using Pipenv' do + let(:app) { Hatchet::Runner.new('spec/fixtures/ci_pipenv', buildpacks:) } + + it 'installs both normal and test dependencies and uses cache on subsequent runs' do + app.run_ci do |test_run| + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Installing Pipenv #{PIPENV_VERSION} + -----> Installing dependencies using 'pipenv install --deploy --dev' + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + BUILD_DIR=/app + CACHE_DIR=/tmp/cache.+ + CI=true + CPLUS_INCLUDE_PATH=/app/.heroku/python/include + C_INCLUDE_PATH=/app/.heroku/python/include + DISABLE_COLLECTSTATIC=1 + ENV_DIR=/tmp/env.+ + INSTALL_TEST=1 + LANG=en_US.UTF-8 + LC_ALL=C.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/pipenv/bin:/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PIPENV_SYSTEM=1 + PIPENV_VERBOSITY=-1 + PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + PYTHONUNBUFFERED=1 + VIRTUAL_ENV=/app/.heroku/python + -----> Saving cache + + ! Note: We recently added support for the package manager uv: + ! https://devcenter.heroku.com/changelog-items/3238 + ! + ! It's now our recommended Python package manager, since it + ! supports lockfiles, is faster, gives more helpful error + ! messages, and is actively maintained by a full-time team. + ! + ! If you haven't tried it yet, we suggest you take a look! + ! https://docs.astral.sh/uv/ + + -----> Inline app detected + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/app/.heroku/python/pipenv/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PIPENV_SYSTEM=1 + PIPENV_VERBOSITY=-1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + VIRTUAL_ENV=/app/.heroku/python + -----> No test-setup command provided. Skipping. + -----> Running test command `./bin/print-env-vars.sh && pytest --version`... + CI=true + DYNO_RAM=2560 + FORWARDED_ALLOW_IPS=\\* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/app/.heroku/python/pipenv/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/:/app/.sprettur/bin/ + PIPENV_SYSTEM=1 + PIPENV_VERBOSITY=-1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + VIRTUAL_ENV=/app/.heroku/python + WEB_CONCURRENCY=5 + WEB_CONCURRENCY_SET_BY=heroku/python + pytest .+ + -----> test command `./bin/print-env-vars.sh && pytest --version` completed successfully + REGEX + + test_run.run_again + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Restoring cache + -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Using cached Pipenv #{PIPENV_VERSION} + -----> Installing dependencies using 'pipenv install --deploy --dev' + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + REGEX + end + end + end + + context 'when using Poetry' do + let(:app) { Hatchet::Runner.new('spec/fixtures/ci_poetry', buildpacks:) } + + it 'installs both normal and test dependencies and uses cache on subsequent runs' do + app.run_ci do |test_run| + # The Poetry install log output order is non-deterministic, hence the regex. + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Installing Poetry #{POETRY_VERSION} + -----> Installing dependencies using 'poetry sync' + Installing dependencies from lock file + + Package operations: 6 installs, 0 updates, 0 removals + + .+ + - Installing (pytest|typing-extensions) .+ + - Installing (pytest|typing-extensions) .+ + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + BUILD_DIR=/app + CACHE_DIR=/tmp/cache.+ + CI=true + CPLUS_INCLUDE_PATH=/app/.heroku/python/include + C_INCLUDE_PATH=/app/.heroku/python/include + DISABLE_COLLECTSTATIC=1 + ENV_DIR=/tmp/env.+ + INSTALL_TEST=1 + LANG=en_US.UTF-8 + LC_ALL=C.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/tmp/cache\\w+/.heroku/python-poetry/bin:/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + POETRY_VIRTUALENVS_CREATE=false + POETRY_VIRTUALENVS_USE_POETRY_PYTHON=true + PYTHONUNBUFFERED=1 + -----> Saving cache + + ! Note: We recently added support for the package manager uv: + ! https://devcenter.heroku.com/changelog-items/3238 + ! + ! It's now our recommended Python package manager, since it + ! supports lockfiles, is faster, gives more helpful error + ! messages, and is actively maintained by a full-time team. + ! + ! If you haven't tried it yet, we suggest you take a look! + ! https://docs.astral.sh/uv/ + + -----> Inline app detected + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/tmp/cache\\w+/.heroku/python-poetry/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + POETRY_VIRTUALENVS_CREATE=false + POETRY_VIRTUALENVS_USE_POETRY_PYTHON=true + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + -----> No test-setup command provided. Skipping. + -----> Running test command `./bin/print-env-vars.sh && pytest --version`... + CI=true + DYNO_RAM=2560 + FORWARDED_ALLOW_IPS=\\* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/:/app/.sprettur/bin/ + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=5 + WEB_CONCURRENCY_SET_BY=heroku/python + pytest .+ + -----> test command `./bin/print-env-vars.sh && pytest --version` completed successfully + REGEX + + test_run.run_again + expect(clean_output(test_run.output)).to include(<<~OUTPUT) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Restoring cache + -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Installing Poetry #{POETRY_VERSION} + -----> Installing dependencies using 'poetry sync' + Installing dependencies from lock file + + No dependencies to install or update + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + OUTPUT + end + end + end + + context 'when using uv' do + let(:app) { Hatchet::Runner.new('spec/fixtures/ci_uv', buildpacks:) } + + it 'installs both normal and test dependencies and uses cache on subsequent runs' do + app.run_ci do |test_run| + # The uv install log output order is non-deterministic, hence the regex. + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Installing uv #{UV_VERSION} + -----> Installing dependencies using 'uv sync --locked' + Resolved 8 packages in .+s + Prepared 6 packages in .+s + Installed 6 packages in .+s + Bytecode compiled .+ files in .+s + .+ + \\+ (pytest|typing-extensions)==.+ + \\+ (pytest|typing-extensions)==.+ + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + BUILD_DIR=/app + CACHE_DIR=/tmp/cache.+ + CI=true + CPLUS_INCLUDE_PATH=/app/.heroku/python/include + C_INCLUDE_PATH=/app/.heroku/python/include + DISABLE_COLLECTSTATIC=1 + ENV_DIR=/tmp/env.+ + INSTALL_TEST=1 + LANG=en_US.UTF-8 + LC_ALL=C.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/tmp/cache\\w+/.heroku/python-uv:/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + PYTHONUNBUFFERED=1 + UV_LINK_MODE=hardlink + UV_NO_MANAGED_PYTHON=1 + UV_PROJECT_ENVIRONMENT=/app/.heroku/python + UV_PYTHON_DOWNLOADS=never + -----> Saving cache + -----> Inline app detected + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/tmp/cache\\w+/.heroku/python-uv:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + UV_LINK_MODE=hardlink + UV_NO_MANAGED_PYTHON=1 + UV_PROJECT_ENVIRONMENT=/app/.heroku/python + UV_PYTHON_DOWNLOADS=never + -----> No test-setup command provided. Skipping. + -----> Running test command `./bin/print-env-vars.sh && pytest --version`... + CI=true + DYNO_RAM=2560 + FORWARDED_ALLOW_IPS=\\* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/:/app/.sprettur/bin/ + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=5 + WEB_CONCURRENCY_SET_BY=heroku/python + pytest .+ + -----> test command `./bin/print-env-vars.sh && pytest --version` completed successfully + REGEX + + test_run.run_again + expect(clean_output(test_run.output)).to match(Regexp.new(<<~REGEX)) + -----> Python app detected + -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + -----> Restoring cache + -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + -----> Using cached uv #{UV_VERSION} + -----> Installing dependencies using 'uv sync --locked' + Resolved 8 packages in .+s + Bytecode compiled .+ files in .+s + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + REGEX + end end end end diff --git a/spec/hatchet/detect_spec.rb b/spec/hatchet/detect_spec.rb index ceb25581f..4a93262ee 100644 --- a/spec/hatchet/detect_spec.rb +++ b/spec/hatchet/detect_spec.rb @@ -17,12 +17,13 @@ remote: ! Error: Your app is configured to use the Python buildpack, remote: ! but we couldn't find any supported Python project files. remote: ! - remote: ! A Python app on Heroku must have either a 'requirements.txt' or - remote: ! 'Pipfile' package manager file in the root directory of its - remote: ! source code. + remote: ! A Python app on Heroku must have either a 'requirements.txt', + remote: ! 'Pipfile.lock', 'poetry.lock' or 'uv.lock' package manager file + remote: ! in the root directory of its source code. remote: ! remote: ! Currently the root directory of your app contains: remote: ! + remote: ! .example-dotfile remote: ! README.md remote: ! subdir/ remote: ! @@ -31,15 +32,23 @@ remote: ! 1. Is in the top level directory (not a subdirectory). remote: ! 2. Has the correct spelling (the filenames are case-sensitive). remote: ! 3. Isn't listed in '.gitignore' or '.slugignore'. + remote: ! 4. Has been added to the Git repository using 'git add --all' + remote: ! and then committed using 'git commit'. remote: ! remote: ! Otherwise, add a package manager file to your app. If your app has remote: ! no dependencies, then create an empty 'requirements.txt' file. remote: ! + remote: ! If you aren't sure which package manager to use, we recommend + remote: ! trying uv, since it supports lockfiles, is extremely fast, and + remote: ! is actively maintained by a full-time team: + remote: ! https://docs.astral.sh/uv/ + remote: ! remote: ! For help with using Python on Heroku, see: remote: ! https://devcenter.heroku.com/articles/getting-started-with-python remote: ! https://devcenter.heroku.com/articles/python-support remote: - remote: More info: https://devcenter.heroku.com/articles/buildpacks#detection-failure + remote: + remote: More info: https://devcenter.heroku.com/articles/buildpacks#buildpack-auto-detection OUTPUT end end diff --git a/spec/hatchet/django_spec.rb b/spec/hatchet/django_spec.rb index 865d5b833..664410dd7 100644 --- a/spec/hatchet/django_spec.rb +++ b/spec/hatchet/django_spec.rb @@ -4,105 +4,257 @@ # Tests that broken user-provided env vars don't take precedence over those set by this buildpack # and break running Python. This is particularly important when using shared builds of Python, -# since they rely upon `LD_LIBRARY_PATH` being correct. This list of env vars is based on those -# that used to be set to different values by `bin/release` in very old versions of the buildpack: +# since they rely upon `LD_LIBRARY_PATH` being correct. Some of these are based on the env vars +# that used to be set by `bin/release` by very old versions of the buildpack: # https://github.com/heroku/heroku-buildpack-python/blob/27abdfe7d7ad104dabceb45641415251e965671c/bin/release#L11-L18 BROKEN_CONFIG_VARS = { + BUILD_DIR: '/invalid-path', + C_INCLUDE_PATH: '/invalid-path', + CACHE_DIR: '/invalid-path', + CPLUS_INCLUDE_PATH: '/invalid-path', + ENV_DIR: '/invalid-path', LD_LIBRARY_PATH: '/invalid-path', LIBRARY_PATH: '/invalid-path', PATH: '/invalid-path', + PKG_CONFIG_PATH: '/invalid-path', PYTHONHOME: '/invalid-path', PYTHONPATH: '/invalid-path', }.freeze RSpec.describe 'Django support' do - describe 'collectstatic' do - context 'when building a Django project' do - let(:app) { Hatchet::Runner.new('python-getting-started', config: BROKEN_CONFIG_VARS) } - - it 'runs collectstatic' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> \\$ python manage.py collectstatic --noinput - remote: WARNING:root:No DATABASE_URL environment variable set, and so no databases setup - remote: \\d+ static files? copied to '/tmp/build_.*/staticfiles', \\d+ post-processed. - REGEX - end + context 'when building latest Django with the app nested inside a subfolder' do + # Also tests that app config vars are passed to the 'manage.py' script invocations. + let(:config) { { EXPECTED_ENV_VAR: '1' } } + let(:app) { Hatchet::Runner.new('spec/fixtures/django_staticfiles_latest_django', config:) } + + it 'runs collectstatic' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: Successfully installed Django-.+ + remote: -----> \\$ python backend/manage.py collectstatic --noinput + remote: \\{'BUILD_DIR': '/tmp/build_\\w+', + remote: 'CACHE_DIR': '/tmp/codon/tmp/cache', + remote: 'CPLUS_INCLUDE_PATH': '/app/.heroku/python/include', + remote: 'C_INCLUDE_PATH': '/app/.heroku/python/include', + remote: 'DJANGO_SETTINGS_MODULE': 'testproject.settings', + remote: 'ENV_DIR': '/tmp/.+', + remote: 'EXPECTED_ENV_VAR': '1', + remote: 'LANG': 'en_US.UTF-8', + remote: 'LD_LIBRARY_PATH': '/app/.heroku/python/lib', + remote: 'LIBRARY_PATH': '/app/.heroku/python/lib', + remote: 'PATH': '/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + remote: 'PIP_DISABLE_PIP_VERSION_CHECK': '1', + remote: 'PKG_CONFIG_PATH': '/app/.heroku/python/lib/pkg-config', + remote: 'PWD': '/tmp/build_\\w+', + remote: 'PYTHONPATH': '\\.', + remote: 'PYTHONUNBUFFERED': '1'\\} + remote: + remote: \\['/tmp/build_\\w+/backend', + remote: '/tmp/build_\\w+', + remote: '/app/.heroku/python/lib/python314.zip', + remote: '/app/.heroku/python/lib/python3.14', + remote: '/app/.heroku/python/lib/python3.14/lib-dynload', + remote: '/app/.heroku/python/lib/python3.14/site-packages'\\] + remote: + remote: 1 static file copied to '/tmp/build_\\w+/backend/staticfiles'. + remote: + remote: -----> Saving cache + REGEX end end + end - context 'when Django is installed but manage.py does not exist' do - let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_django_latest') } + context 'when building legacy Django with broken env vars set' do + # Also tests that app config vars are passed to the 'manage.py' script invocations. + let(:config) { BROKEN_CONFIG_VARS.merge(EXPECTED_ENV_VAR: '1') } + let(:app) { Hatchet::Runner.new('spec/fixtures/django_staticfiles_legacy_django', config:) } - it 'skips collectstatic' do - app.deploy do |app| - expect(app.output).to include('Skipping Django collectstatic since no manage.py file found.') - expect(app.output).not_to include('manage.py collectstatic') - end + it 'runs collectstatic' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: Successfully installed Django-.+ + remote: -----> \\$ python manage.py collectstatic --noinput + remote: \\{'BUILD_DIR': '/invalid-path', + remote: 'CACHE_DIR': '/invalid-path', + remote: 'CPLUS_INCLUDE_PATH': '/invalid-path', + remote: 'C_INCLUDE_PATH': '/invalid-path', + remote: 'DJANGO_SETTINGS_MODULE': 'testproject.settings', + remote: 'ENV_DIR': '/invalid-path', + remote: 'EXPECTED_ENV_VAR': '1', + remote: 'LANG': 'en_US.UTF-8', + remote: 'LD_LIBRARY_PATH': '/app/.heroku/python/lib', + remote: 'LIBRARY_PATH': '/app/.heroku/python/lib', + remote: 'PATH': '/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + remote: 'PIP_DISABLE_PIP_VERSION_CHECK': '1', + remote: 'PKG_CONFIG_PATH': '/invalid-path', + remote: 'PWD': '/tmp/build_\\w+', + remote: 'PYTHONPATH': '/invalid-path', + remote: 'PYTHONUNBUFFERED': '1'\\} + remote: + remote: \\['/tmp/build_\\w+', + remote: '/invalid-path', + remote: '/app/.heroku/python/lib/python310.zip', + remote: '/app/.heroku/python/lib/python3.10', + remote: '/app/.heroku/python/lib/python3.10/lib-dynload', + remote: '/app/.heroku/python/lib/python3.10/site-packages'\\] + remote: + remote: 1 static file copied to '/tmp/build_\\w+/staticfiles'. + remote: + remote: -----> Saving cache + REGEX end end + end + + context 'when Django is installed but manage.py does not exist' do + let(:app) { Hatchet::Runner.new('spec/fixtures/django_no_manage_py') } - context 'when DISABLE_COLLECTSTATIC=1' do - let(:app) do - Hatchet::Runner.new('spec/fixtures/requirements_django_latest', config: { 'DISABLE_COLLECTSTATIC' => '1' }) + it 'skips collectstatic' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: Successfully installed Django-.+ + remote: -----> Skipping Django collectstatic since no manage.py file found. + remote: -----> Saving cache + REGEX end + end + end + + context 'when DISABLE_COLLECTSTATIC=1' do + let(:config) { { DISABLE_COLLECTSTATIC: '1' } } + let(:app) { Hatchet::Runner.new('spec/fixtures/django_invalid_settings_module', config:) } - it 'skips collectstatic' do - app.deploy do |app| - expect(app.output).to include('Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set.') - expect(app.output).not_to include('manage.py collectstatic') - end + it 'skips collectstatic' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: Successfully installed Django-.+ + remote: -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + remote: -----> Saving cache + REGEX end end + end - context 'when DISABLE_COLLECTSTATIC=0' do - let(:app) { Hatchet::Runner.new('python-getting-started', config: { 'DISABLE_COLLECTSTATIC' => '0' }) } + context 'when .heroku/collectstatic_disabled exists' do + let(:app) { Hatchet::Runner.new('spec/fixtures/django_collectstatic_disabled_file') } - it 'still runs collectstatic' do - app.deploy do |app| - expect(app.output).to include('manage.py collectstatic') - end + it 'skips collectstatic with a deprecation warning' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: Successfully installed Django-.+ + remote: -----> Skipping Django collectstatic since the file '.heroku/collectstatic_disabled' exists. + remote: + remote: ! Warning: The .heroku/collectstatic_disabled file is deprecated. + remote: ! + remote: ! Please remove the file and set the env var DISABLE_COLLECTSTATIC=1 instead. + remote: + remote: -----> Saving cache + REGEX end end + end - context 'when DISABLE_COLLECTSTATIC is null' do - let(:app) { Hatchet::Runner.new('python-getting-started', config: { 'DISABLE_COLLECTSTATIC' => '' }) } + # TODO: Backport the Python CNB implementation that allows skipping collectstatic automatically for this case. + context 'when Django and manage.py exist but the Django staticfiles app is not enabled' do + let(:app) { Hatchet::Runner.new('spec/fixtures/django_staticfiles_app_not_enabled', allow_failure: true) } - it 'still runs collectstatic' do - app.deploy do |app| - expect(app.output).to include('manage.py collectstatic') - end + it 'fails collectstatic' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: Successfully installed Django-.+ + remote: -----> \\$ python manage.py collectstatic --noinput + remote: Unknown command: 'collectstatic' + remote: Type 'manage.py help' for usage. + remote: + remote: + remote: ! Error: Unable to generate Django static files. + remote: ! + remote: ! The 'python manage.py collectstatic --noinput' Django + remote: ! management command to generate static files failed. + remote: ! + remote: ! See the traceback above for details. + remote: ! + remote: ! You may need to update application code to resolve this error. + remote: ! Or, you can disable collectstatic for this application: + remote: ! + remote: ! \\$ heroku config:set DISABLE_COLLECTSTATIC=1 + remote: ! + remote: ! https://devcenter.heroku.com/articles/django-assets + remote: + remote: ! Push rejected, failed to compile Python app. + REGEX end end + end - context 'when .heroku/collectstatic_disabled exists' do - let(:app) { Hatchet::Runner.new('spec/fixtures/django_collectstatic_disabled_file') } - - it 'skips collectstatic with a deprecation warning' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Skipping Django collectstatic since the file '.heroku/collectstatic_disabled' exists. - remote: ! This approach is deprecated, please set the env var DISABLE_COLLECTSTATIC=1 instead. - OUTPUT - expect(app.output).not_to include('manage.py collectstatic') - end + # For now this case produces the same error message as the one below, but once we backport the + # Python CNB implementation that will change, so we want a dedicated test for this case. + context 'when manage.py is configured with an invalid settings module' do + let(:app) { Hatchet::Runner.new('spec/fixtures/django_invalid_settings_module', allow_failure: true) } + + it 'fails collectstatic with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> \\$ python manage.py collectstatic --noinput + remote: Traceback \\(most recent call last\\): + remote: .+ + remote: ModuleNotFoundError: No module named 'nonexistent-module' + remote: + remote: + remote: ! Error: Unable to generate Django static files. + remote: ! + remote: ! The 'python manage.py collectstatic --noinput' Django + remote: ! management command to generate static files failed. + remote: ! + remote: ! See the traceback above for details. + remote: ! + remote: ! You may need to update application code to resolve this error. + remote: ! Or, you can disable collectstatic for this application: + remote: ! + remote: ! \\$ heroku config:set DISABLE_COLLECTSTATIC=1 + remote: ! + remote: ! https://devcenter.heroku.com/articles/django-assets + remote: + remote: ! Push rejected, failed to compile Python app. + REGEX end end + end + + context 'when the staticfiles app is misconfigured and DEBUG_COLLECTSTATIC=1' do + let(:config) { { DEBUG_COLLECTSTATIC: '1' } } + let(:app) { Hatchet::Runner.new('spec/fixtures/django_staticfiles_misconfigured', config:, allow_failure: true) } - context 'when building a broken Django project' do - let(:app) { Hatchet::Runner.new('spec/fixtures/django_broken_project', allow_failure: true) } - - it 'fails collectstatic with an informative error message' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) - remote: -----> \\$ python manage.py collectstatic --noinput - remote: Traceback \\(most recent call last\\): - remote: .* - remote: ModuleNotFoundError: No module named 'gettingstarted' - remote: - remote: ! Error while running '\\$ python manage.py collectstatic --noinput'. - REGEX - end + # TODO: Sort the displayed env vars to make the order deterministic and then we can test more of the output. + it 'fails collectstatic with an informative error message and prints env vars' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> \\$ python manage.py collectstatic --noinput + remote: Traceback \\(most recent call last\\): + remote: .+ + remote: django.core.exceptions.ImproperlyConfigured: You're using the staticfiles app without having set the required STATIC_URL setting. + remote: + remote: + remote: ! Error: Unable to generate Django static files. + remote: ! + remote: ! The 'python manage.py collectstatic --noinput' Django + remote: ! management command to generate static files failed. + remote: ! + remote: ! See the traceback above for details. + remote: ! + remote: ! You may need to update application code to resolve this error. + remote: ! Or, you can disable collectstatic for this application: + remote: ! + remote: ! \\$ heroku config:set DISABLE_COLLECTSTATIC=1 + remote: ! + remote: ! https://devcenter.heroku.com/articles/django-assets + remote: + remote: + remote: \\*\\*\\*\\*\\*\\* Collectstatic environment variables: + remote: + remote: .+ + remote: ! Push rejected, failed to compile Python app. + REGEX end end end diff --git a/spec/hatchet/hooks_spec.rb b/spec/hatchet/hooks_spec.rb index dd114a76b..8cf91df8d 100644 --- a/spec/hatchet/hooks_spec.rb +++ b/spec/hatchet/hooks_spec.rb @@ -3,49 +3,102 @@ require_relative '../spec_helper' RSpec.describe 'Compile hooks' do - context 'when an app has bin/pre_compile and bin/post_compile scripts' do - let(:app) { Hatchet::Runner.new('spec/fixtures/hooks', config: { 'SOME_APP_CONFIG_VAR' => '1' }) } - - it 'runs the hooks with the correct environment' do - expected_env_vars = %w[ - _ - BUILD_DIR - BUILDPACK_LOG_FILE - CACHE_DIR - C_INCLUDE_PATH - CPLUS_INCLUDE_PATH - DYNO - ENV_DIR - HOME - LANG - LD_LIBRARY_PATH - LIBRARY_PATH - OLDPWD - PATH - PIP_NO_PYTHON_VERSION_WARNING - PKG_CONFIG_PATH - PWD - PYTHONUNBUFFERED - REQUEST_ID - SHLVL - SOME_APP_CONFIG_VAR - SOURCE_VERSION - STACK - ] + # This spec skips testing the passing post_compile hook case, since that's already tested + # via the package manager and CI tests. + # Tests two of the four hooks result permutations in the same test to reduce end to end time. + context 'when an app has a passing bin/pre_compile and a failing bin/post_compile script' do + let(:config) { { SOME_APP_CONFIG_VAR: '1' } } + let(:app) { Hatchet::Runner.new('spec/fixtures/hooks_pre_compile_pass_post_compile_fail', config:, allow_failure: true) } + + it 'runs the pre_compile hook but aborts the build during the post_compile hook with a suitable error message' do app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + output = clean_output(app.output) + + expect(output).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> Running pre-compile hook - remote: pre_compile ran with env vars: - remote: #{expected_env_vars.join("\nremote: ")} - remote: -----> No Python version was specified. Using the buildpack default: python-#{DEFAULT_PYTHON_VERSION} - remote: .* - remote: -----> Installing requirements with pip - remote: -----> Running post-compile hook - remote: post_compile ran with env vars: - remote: #{expected_env_vars.join("\nremote: ")} + remote: -----> Running bin/pre_compile hook + remote: ~ pre_compile ran with env vars: + remote: BUILD_DIR=/tmp/build_.+ + remote: CACHE_DIR=/tmp/codon/tmp/cache + remote: C_INCLUDE_PATH=/app/.heroku/python/include + remote: CPLUS_INCLUDE_PATH=/app/.heroku/python/include + remote: ENV_DIR=/tmp/.+ + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + remote: PYTHONUNBUFFERED=1 + remote: SOME_APP_CONFIG_VAR=1 + remote: ~ pre_compile complete REGEX + + expect(output).to include(<<~OUTPUT) + remote: -----> Running bin/post_compile hook + remote: Some post_compile error! + remote: + remote: ! Error: Failed to run the bin/post_compile script. + remote: ! + remote: ! We found a 'bin/post_compile' script in your app source, so ran + remote: ! it to allow for customisation of the build process. + remote: ! + remote: ! However, this script exited with a non-zero exit status. + remote: ! + remote: ! Fix any errors output by your script above, or remove/rename + remote: ! the script to prevent it from being run during the build. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when an app has a failing bin/pre_compile script' do + let(:app) { Hatchet::Runner.new('spec/fixtures/hooks_pre_compile_fail', allow_failure: true) } + + it 'aborts the build with a suitable error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Running bin/pre_compile hook + remote: Some pre_compile error! + remote: + remote: ! Error: Failed to run the bin/pre_compile script. + remote: ! + remote: ! We found a 'bin/pre_compile' script in your app source, so ran + remote: ! it to allow for customisation of the build process. + remote: ! + remote: ! However, this script exited with a non-zero exit status. + remote: ! + remote: ! Fix any errors output by your script above, or remove/rename + remote: ! the script to prevent it from being run during the build. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when an app tries to delete the whole cache directory including the build data file' do + let(:app) { Hatchet::Runner.new('spec/fixtures/hooks_delete_cache_dir', allow_failure: true) } + + it 'aborts the build with a suitable error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Running bin/post_compile hook + remote: + rm -rf /tmp/codon/tmp/cache + remote: + remote: ! Error: Can't find the buildpack's build data file. + remote: ! + remote: ! The Python buildpack's internal build data file is missing: + remote: ! /tmp/codon/tmp/cache/build-data/python.json + remote: ! + remote: ! This file is required for the buildpack to work correctly, + remote: ! and so you must not delete it when removing files from the + remote: ! build cache or /tmp directories. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT end end end diff --git a/spec/hatchet/nltk_spec.rb b/spec/hatchet/nltk_spec.rb index 0ed2a167f..1c5a5a59d 100644 --- a/spec/hatchet/nltk_spec.rb +++ b/spec/hatchet/nltk_spec.rb @@ -11,13 +11,13 @@ expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Downloading NLTK corpora... remote: -----> Downloading NLTK packages: city_database stopwords - remote: .*: RuntimeWarning: 'nltk.downloader' found in sys.modules after import of package 'nltk', but prior to execution of 'nltk.downloader'; this may result in unpredictable behaviour - remote: \\[nltk_data\\] Downloading package city_database to - remote: \\[nltk_data\\] /tmp/build_.*/.heroku/python/nltk_data... - remote: \\[nltk_data\\] Unzipping corpora/city_database.zip. - remote: \\[nltk_data\\] Downloading package stopwords to - remote: \\[nltk_data\\] /tmp/build_.*/.heroku/python/nltk_data... - remote: \\[nltk_data\\] Unzipping corpora/stopwords.zip. + remote: .*: RuntimeWarning: 'nltk.downloader' found in sys.modules after import of package 'nltk', but prior to execution of 'nltk.downloader'; this may result in unpredictable behaviour + remote: \\[nltk_data\\] Downloading package city_database to + remote: \\[nltk_data\\] /tmp/build_.+/.heroku/python/nltk_data... + remote: \\[nltk_data\\] Unzipping corpora/city_database.zip. + remote: \\[nltk_data\\] Downloading package stopwords to + remote: \\[nltk_data\\] /tmp/build_.+/.heroku/python/nltk_data... + remote: \\[nltk_data\\] Unzipping corpora/stopwords.zip. REGEX # TODO: Add a test that the downloaded corpora can be found at runtime. @@ -32,8 +32,7 @@ app.deploy do |app| expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Downloading NLTK corpora... - remote: ! 'nltk.txt' not found, not downloading any corpora - remote: ! Learn more: https://devcenter.heroku.com/articles/python-nltk + remote: 'nltk.txt' not found, not downloading any corpora REGEX end end @@ -44,7 +43,37 @@ it 'does not try to install the specified NLTK corpora' do app.deploy do |app| - expect(app.output.downcase).not_to include('nltk') + expect(app.output).not_to include('NLTK') + expect(app.output).not_to include('nltk_data') + end + end + end + + context 'when nltk.txt contains invalid entries' do + let(:app) { Hatchet::Runner.new('spec/fixtures/nltk_txt_invalid', allow_failure: true) } + + it 'fails the build' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Downloading NLTK corpora... + remote: -----> Downloading NLTK packages: invalid! + remote: .+: RuntimeWarning: 'nltk.downloader' found in sys.modules after import of package 'nltk', but prior to execution of 'nltk.downloader'; this may result in unpredictable behaviour + remote: \\[nltk_data\\] Error loading invalid!: Package 'invalid!' not found in + remote: \\[nltk_data\\] index + remote: Error installing package. Retry\\? \\[n/y/e\\] + remote: Traceback \\(most recent call last\\): + remote: .+ + remote: EOFError: EOF when reading a line + remote: + remote: ! Error: Unable to download NLTK data. + remote: ! + remote: ! The 'python -m nltk.downloader' command to download NLTK + remote: ! data didn't exit successfully. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. + REGEX end end end diff --git a/spec/hatchet/package_manager_spec.rb b/spec/hatchet/package_manager_spec.rb index 10cff59b4..02f68b990 100644 --- a/spec/hatchet/package_manager_spec.rb +++ b/spec/hatchet/package_manager_spec.rb @@ -4,22 +4,23 @@ 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| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: ! + remote: remote: ! Error: Couldn't find any supported Python package manager files. remote: ! - remote: ! A Python app on Heroku must have either a 'requirements.txt' or - remote: ! 'Pipfile' package manager file in the root directory of its - remote: ! source code. + remote: ! A Python app on Heroku must have either a 'requirements.txt', + remote: ! 'Pipfile.lock', 'poetry.lock' or 'uv.lock' package manager file + remote: ! in the root directory of its source code. remote: ! remote: ! Currently the root directory of your app contains: remote: ! - remote: ! runtime.txt + remote: ! .example-dotfile + remote: ! pyproject.toml remote: ! subdir/ remote: ! remote: ! If your app already has a package manager file, check that it: @@ -27,16 +28,135 @@ remote: ! 1. Is in the top level directory (not a subdirectory). remote: ! 2. Has the correct spelling (the filenames are case-sensitive). remote: ! 3. Isn't listed in '.gitignore' or '.slugignore'. + remote: ! 4. Has been added to the Git repository using 'git add --all' + remote: ! and then committed using 'git commit'. remote: ! remote: ! Otherwise, add a package manager file to your app. If your app has remote: ! no dependencies, then create an empty 'requirements.txt' file. remote: ! + remote: ! If you aren't sure which package manager to use, we recommend + remote: ! trying uv, since it supports lockfiles, is extremely fast, and + remote: ! is actively maintained by a full-time team: + remote: ! https://docs.astral.sh/uv/ + remote: ! remote: ! For help with using Python on Heroku, see: remote: ! https://devcenter.heroku.com/articles/getting-started-with-python remote: ! https://devcenter.heroku.com/articles/python-support + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when there is only a setup.py' do + let(:app) { Hatchet::Runner.new('spec/fixtures/setup_py_only', allow_failure: true) } + + it 'fails the build with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Implicit setup.py file support has been sunset. + remote: ! + remote: ! Your app currently only has a setup.py file and no Python + remote: ! package manager files. This means that the buildpack can't + remote: ! tell which package manager you want to use, and whether to + remote: ! install your project in editable mode or not. + remote: ! + remote: ! Previously the buildpack guessed and used pip to install your + remote: ! dependencies in editable mode. However, this fallback was + remote: ! deprecated in September 2025 and has now been sunset. + remote: ! + remote: ! You must now add an explicit package manager file to your app, + remote: ! such as a requirements.txt, poetry.lock or uv.lock file. remote: ! + remote: ! To continue using your setup.py file with pip in editable + remote: ! mode, create a new file in the root directory of your app + remote: ! named 'requirements.txt' containing the requirement + remote: ! '--editable .' (without quotes). + remote: ! + remote: ! Alternatively, if you wish to switch to another package + remote: ! manager, we recommend uv, since it supports lockfiles, is + remote: ! faster, and is actively maintained by a full-time team: + remote: ! https://docs.astral.sh/uv/ + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT end end end + + context 'when there are multiple package manager files' do + let(:app) { Hatchet::Runner.new('spec/fixtures/multiple_package_managers', allow_failure: true) } + + it 'fails the build with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Multiple Python package manager files were found. + remote: ! + remote: ! Exactly one package manager file should be present in your app's + remote: ! source code, however, several were found: + remote: ! + remote: ! Pipfile.lock (Pipenv) + remote: ! requirements.txt (pip) + remote: ! poetry.lock (Poetry) + remote: ! uv.lock (uv) + remote: ! + remote: ! Previously, the buildpack guessed which package manager to use + remote: ! and installed your dependencies with the first package manager + remote: ! listed above. However, this implicit behaviour was deprecated + remote: ! in November 2024 and is now no longer supported. + remote: ! + remote: ! You must decide which package manager you want to use with your + remote: ! app, and then delete the file(s) and any config from the others. + remote: ! + remote: ! If you aren't sure which package manager to use, we recommend + remote: ! trying uv, since it supports lockfiles, is extremely fast, and + remote: ! is actively maintained by a full-time team: + remote: ! https://docs.astral.sh/uv/ + remote: ! + remote: ! Note: If you use a third-party uv or Poetry buildpack, you must + remote: ! remove it from your app, since it's no longer required and the + remote: ! requirements.txt file it generates will trigger this error. See: + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#remove-classic-buildpacks + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when the package manager has changed since the last build' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_basic') } + + it 'clears the cache before installing with the new package manager' do + app.deploy do |app| + FileUtils.rm('bin/post_compile') + FileUtils.rm('requirements.txt') + FileUtils.cp(FIXTURE_DIR.join('uv_basic/pyproject.toml'), '.') + FileUtils.cp(FIXTURE_DIR.join('uv_basic/uv.lock'), '.') + 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 .python-version + remote: -----> Discarding cache since: + remote: - The package manager has changed from pip to uv + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing uv #{UV_VERSION} + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved .+ packages in .+s + remote: Prepared 1 package in .+s + remote: Installed 1 package in .+s + remote: Bytecode compiled 1 file in .+s + remote: \\+ typing-extensions==4.15.0 + remote: -----> Saving cache + remote: -----> Discovering process types + REGEX + end + end + end end diff --git a/spec/hatchet/pip_spec.rb b/spec/hatchet/pip_spec.rb index 704a65c4b..07e53f55c 100644 --- a/spec/hatchet/pip_spec.rb +++ b/spec/hatchet/pip_spec.rb @@ -2,199 +2,454 @@ require_relative '../spec_helper' -RSpec.shared_examples 'installs successfully using pip' do - it 'installs successfully using pip' do - app.deploy do |app| - expect(app.output).to include('Installing requirements with pip') - expect(app.output).to include('Successfully installed') - end - end -end - -RSpec.describe 'Pip support' do +RSpec.describe 'pip support' do context 'when requirements.txt is unchanged since the last build' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified') } + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_basic', buildpacks:) } - it 're-uses packages from the cache' do + it 'installs successfully using pip and on rebuilds uses the cache' do app.deploy do |app| 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Installing python-#{DEFAULT_PYTHON_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: Downloading urllib3-.* - remote: Downloading urllib3-.* - remote: Installing collected packages: urllib3 - remote: Successfully installed urllib3-.* + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 \\(from -r requirements.txt \\(line 5\\)\\) + remote: Downloading typing_extensions-4.15.0-py3-none-any.whl.metadata \\(3.3 kB\\) + remote: Downloading typing_extensions-4.15.0-py3-none-any.whl \\(44 kB\\) + remote: Installing collected packages: typing-extensions + remote: Successfully installed typing-extensions-4.15.0 + remote: -----> Running bin/post_compile hook + remote: BUILD_DIR=/tmp/build_.+ + remote: CACHE_DIR=/tmp/codon/tmp/cache + remote: C_INCLUDE_PATH=/app/.heroku/python/include + remote: CPLUS_INCLUDE_PATH=/app/.heroku/python/include + remote: ENV_DIR=/tmp/.+ + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PIP_DISABLE_PIP_VERSION_CHECK=1 + remote: PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + remote: PYTHONUNBUFFERED=1 + remote: -----> Saving cache + remote: + remote: ! Note: We recently added support for the package manager uv: + remote: ! https://devcenter.heroku.com/changelog-items/3238 + remote: ! + remote: ! It's now our recommended Python package manager, since it + remote: ! supports lockfiles, is faster, gives more helpful error + remote: ! messages, and is actively maintained by a full-time team. + remote: ! + remote: ! If you haven't tried it yet, we suggest you take a look! + remote: ! https://docs.astral.sh/uv/ + remote: + remote: -----> Inline app detected + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PIP_DISABLE_PIP_VERSION_CHECK=1 + remote: PYTHONHOME=/app/.heroku/python + remote: PYTHONPATH=/app + remote: PYTHONUNBUFFERED=true + remote: + remote: \\['', + remote: '/app', + remote: '/app/.heroku/python/lib/python314.zip', + remote: '/app/.heroku/python/lib/python3.14', + remote: '/app/.heroku/python/lib/python3.14/lib-dynload', + remote: '/app/.heroku/python/lib/python3.14/site-packages'\\] + remote: + remote: pip #{PIP_VERSION} from /app/.heroku/python/lib/python3.14/site-packages/pip \\(python 3.14\\) + remote: Package Version + remote: ----------------- ------- + remote: pip #{PIP_VERSION} + remote: typing_extensions 4.15.0 + remote: + remote: + remote: + remote: \\{ + remote: "cache_restore_duration": [0-9.]+, + remote: "cache_save_duration": [0-9.]+, + remote: "cache_status": "empty", + remote: "dependencies_install_duration": [0-9.]+, + remote: "django_collectstatic_duration": [0-9.]+, + remote: "nltk_downloader_duration": [0-9.]+, + remote: "package_manager": "pip", + remote: "package_manager_install_duration": [0-9.]+, + remote: "pip_version": "#{PIP_VERSION}", + remote: "post_compile_hook": true, + remote: "post_compile_hook_duration": [0-9.]+, + remote: "pre_compile_hook": false, + remote: "python_install_duration": [0-9.]+, + remote: "python_version": "#{DEFAULT_PYTHON_FULL_VERSION}", + remote: "python_version_major": "3.14", + remote: "python_version_origin": ".python-version", + remote: "python_version_outdated": false, + remote: "python_version_pinned": false, + remote: "python_version_requested": "3.14", + remote: "total_duration": [0-9.]+ + remote: \\} REGEX + app.commit! app.push! - expect(clean_output(app.output)).to include(<<~OUTPUT) + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) remote: -----> Python app detected - remote: -----> No Python version was specified. Using the same version as the last build: python-#{DEFAULT_PYTHON_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> No change in requirements detected, installing from cache - remote: -----> Using cached install of python-#{DEFAULT_PYTHON_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing SQLite3 - remote: -----> Installing requirements with pip - remote: -----> Discovering process types + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Restoring cache + remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Requirement already satisfied: typing-extensions==4.15.0 \\(from -r requirements.txt \\(line 5\\)\\) \\(4.15.0\\) + remote: -----> Running bin/post_compile hook + remote: .+ + remote: -----> Saving cache + REGEX + + # For historical reasons pip is made available at run-time too, unlike some of the other package managers. + expect(normalize_trailing_newlines(app.run('bin/print-env-vars.sh && pip --version'))).to eq(<<~OUTPUT) + DYNO_RAM=512 + FORWARDED_ALLOW_IPS=* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin + PIP_DISABLE_PIP_VERSION_CHECK=1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=2 + WEB_CONCURRENCY_SET_BY=heroku/python + pip #{PIP_VERSION} from /app/.heroku/python/lib/python3.14/site-packages/pip (python 3.14) OUTPUT + expect($CHILD_STATUS.exitstatus).to eq(0) end end end context 'when requirements.txt has changed since the last build' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified') } + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_basic') } it 'clears the cache before installing the packages again' do app.deploy do |app| - File.write('requirements.txt', 'six', mode: 'a') + # The test fixture's requirements.txt is a symlink to a requirements file in a subdirectory in + # order to test that symlinked requirements files work in general and with cache invalidation. + File.write('requirements/prod.txt', 'six==1.17.0', mode: 'a') + FileUtils.rm('bin/post_compile') app.commit! app.push! - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Requirements file has been changed, clearing cached dependencies - remote: -----> Installing python-#{DEFAULT_PYTHON_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: Downloading urllib3-.* - remote: Collecting six \\(from -r requirements.txt \\(line 2\\)\\) - remote: Downloading six-.* - remote: Downloading urllib3-.* - remote: Downloading six-.* - remote: Installing collected packages: urllib3, six - remote: Successfully installed six-.* urllib3-.* - REGEX + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Discarding cache since: + remote: - The contents of requirements.txt changed + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 5)) + remote: Downloading typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB) + remote: Collecting six==1.17.0 (from -r requirements.txt (line 6)) + remote: Downloading six-1.17.0-py2.py3-none-any.whl.metadata (1.7 kB) + remote: Downloading typing_extensions-4.15.0-py3-none-any.whl (44 kB) + remote: Downloading six-1.17.0-py2.py3-none-any.whl (11 kB) + remote: Installing collected packages: typing-extensions, six + remote: Successfully installed six-1.17.0 typing-extensions-4.15.0 + remote: -----> Saving cache + OUTPUT end end end - context 'when requirements.txt contains popular compiled packages' do - let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_compiled') } - - include_examples 'installs successfully using pip' - end - - context 'when requirements.txt contains Git requirements URLs' do - let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_git') } - - include_examples 'installs successfully using pip' - end - - context 'when requirements.txt contains editable requirements' do + context 'when requirements.txt contains editable requirements (both VCS and local package)' do let(:buildpacks) { [:default, 'heroku-community/inline'] } - let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_editable', buildpacks:) } + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_editable', buildpacks:) } - it 'rewrites .pth, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do + it 'rewrites .pth 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)) - remote: -----> Running post-compile hook - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py - remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + .+ remote: -----> Inline app detected - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py - remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) REGEX # Test rewritten paths work at runtime. - expect(app.run('bin/test-entrypoints')).to include(<<~OUTPUT) - easy-install.pth:/app/.heroku/src/gunicorn - easy-install.pth:/app/packages/local_package_setup_py + expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT) + __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} __editable___local_package_pyproject_toml_0_0_1_finder.py:/app/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - gunicorn.egg-link:/app/.heroku/src/gunicorn - local-package-setup-py.egg-link:/app/packages/local_package_setup_py + __editable___local_package_setup_py_0_0_1_finder.py:/app/packages/local_package_setup_py/local_package_setup_py'} - Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - Running entrypoint for the setup.py-based local package: Hello setup.py! - Running entrypoint for the VCS package: gunicorn (version 20.1.0) + Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + Running entrypoint for the setup.py-based local package: Hello from setup.py! + Running entrypoint for the VCS package: gunicorn (version 23.0.0) OUTPUT # Test that the cached .pth files work correctly. app.commit! app.push! expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) - remote: -----> Running post-compile hook - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py - remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + .+ remote: -----> Inline app detected - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py - remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) REGEX + # Test that the VCS repo checkout was cached correctly. + expect(app.output).to include(<<~OUTPUT) + remote: Obtaining gunicorn from git+https://github.com/benoitc/gunicorn@56b5ad87f8d72a674145c273ed8f547513c2b409#egg=gunicorn (from -r requirements.txt (line 5)) + remote: Skipping because already up-to-date. + OUTPUT end end end - context 'when there is only a setup.py' do - let(:app) { Hatchet::Runner.new('spec/fixtures/setup_py_only') } + # This checks that the pip bootstrap works even with older bundled pip, and that our + # chosen Pip version also supports our oldest supported Python version. + context 'when using our oldest supported Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_oldest_python') } - it 'installs packages from setup.py' do + it 'installs successfully' do app.deploy do |app| - expect(app.output).to include('Running setup.py develop for test') - expect(app.output).to include('Successfully installed six') + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.10.0 specified in runtime.txt + remote: + remote: ! Warning: The runtime.txt file is deprecated. + remote: ! + remote: ! The runtime.txt file is deprecated since it has been replaced + remote: ! by the more widely supported .python-version file: + remote: ! https://devcenter.heroku.com/changelog-items/3141 + remote: ! + remote: ! Please switch to using a .python-version file instead. + remote: ! + remote: ! Delete your runtime.txt file and create a new file in the + remote: ! root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python 3.10, + remote: ! update your .python-version file so it contains exactly: + remote: ! 3.10 + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! In the future support for runtime.txt will be removed and + remote: ! this warning will be made an error. + remote: + remote: + remote: ! Warning: Support for Python 3.10 is deprecated! + remote: ! + remote: ! Python 3.10 will reach its upstream end-of-life in October 2026, + remote: ! at which point it will no longer receive security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, support for Python 3.10 will be removed from this + remote: ! buildpack on 6th January 2027. + remote: ! + remote: ! Upgrade to a newer Python version as soon as possible, by + remote: ! changing the version in your runtime.txt file. + remote: ! + remote: ! For more information, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: + remote: + remote: ! Warning: A Python patch update is available! + remote: ! + remote: ! Your app is using Python 3.10.0, however, there is a newer + remote: ! patch release of Python 3.10 available: #{LATEST_PYTHON_3_10} + remote: ! + remote: ! It is important to always use the latest patch version of + remote: ! Python to keep your app secure. + remote: ! + remote: ! Update your runtime.txt file to use the new version. + remote: ! + remote: ! We strongly recommend that you don't pin your app to an + remote: ! exact Python version such as 3.10.0, and instead only specify + remote: ! the major Python version of 3.10 in your runtime.txt file. + remote: ! This will allow your app to receive the latest available Python + remote: ! patch version automatically and prevent this warning. + remote: + remote: -----> Installing Python 3.10.0 + remote: -----> Installing pip #{PIP_VERSION} and setuptools #{SETUPTOOLS_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 2)) + remote: Downloading typing_extensions-4.15.0-py3-none-any.whl.metadata (3.3 kB) + remote: Downloading typing_extensions-4.15.0-py3-none-any.whl (44 kB) + remote: Installing collected packages: typing-extensions + remote: Successfully installed typing-extensions-4.15.0 + remote: -----> Saving cache + OUTPUT + app.commit! + app.push! + # Test that our regex for cleaning up the "Requirement already satisfied" lines also works + # with the relative paths output when pip is run with Python 3.10 and older. This and the + # regex variant can be removed once support for Python 3.10 and older is dropped. + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Requirement already satisfied: typing-extensions==4.15.0 (from -r requirements.txt (line 2)) (4.15.0) + remote: -----> Saving cache + OUTPUT + end + end + end + + context 'when requirements.txt contains a package that needs compiling against the Python headers' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_compiled') } + + it 'installs successfully using pip' do + app.deploy do |app| + expect(app.output).to include('Building wheel for extension.dist') end end end - context 'when there is both a requirements.txt and setup.py' do - let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_txt_and_setup_py') } + context 'when requirements.txt contains an invalid requirement' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_invalid_requirement', allow_failure: true) } - it 'installs packages only from requirements.txt' do + it 'aborts the build and displays the pip error' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: ERROR: Invalid requirement: 'an-invalid-requirement!': Expected semicolon (after name with no version specifier) or end + remote: an-invalid-requirement! + remote: ^ (from line 1 of requirements.txt) + remote: + remote: ! Error: Unable to install dependencies using pip. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT - expect(app.output).not_to include('Running setup.py develop') end end end context 'when requirements.txt contains GDAL but the GDAL C++ library is missing' do - let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_gdal', allow_failure: true) } + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_gdal', allow_failure: true) } it 'outputs instructions for how to resolve the build failure' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: ! Hello! Package installation failed since the GDAL library was not found. + remote: ERROR: Failed to build 'GDAL' when getting requirements to build wheel + remote: + remote: ! Error: Package installation failed since the GDAL library wasn't found. + remote: ! remote: ! For GDAL, GEOS and PROJ support, use the Geo buildpack alongside the Python buildpack: remote: ! https://github.com/heroku/heroku-geo-buildpack + remote: + remote: + remote: ! Error: Unable to install dependencies using pip. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when requirements.txt contains an old version of Celery with invalid metadata' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_legacy_celery', allow_failure: true) } + + it 'outputs instructions for how to resolve the build failure' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: ERROR: No matching distribution found for celery==5.2.0 + remote: + remote: ! Error: One of your dependencies contains broken metadata. + remote: ! + remote: ! Newer versions of pip reject packages that use invalid versions + remote: ! in their metadata (such as Celery older than v5.2.1). + remote: ! + remote: ! Try upgrading to a newer version of the affected package. + remote: ! + remote: ! For more help, see: + remote: ! https://devcenter.heroku.com/changelog-items/3073 + remote: + remote: + remote: ! Error: Unable to install dependencies using pip. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when requirements.txt contains pysqlite3' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_pysqlite3', allow_failure: true) } + + it 'outputs instructions for how to resolve the build failure' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: × Failed to build installable wheels for some pyproject.toml based projects + remote: ╰─> pysqlite3 + remote: + remote: ! Error: Package installation failed since SQLite headers weren't found. + remote: ! + remote: ! The Python buildpack no longer installs the SQLite headers + remote: ! package since most apps don't require it. + remote: ! + remote: ! If you're trying to install the `pysqlite3` package, we + remote: ! recommend using the virtually identical `sqlite3` module in + remote: ! Python's standard library instead: + remote: ! https://docs.python.org/3/library/sqlite3.html + remote: ! + remote: ! To do this: + remote: ! 1. Remove the `pysqlite3` package from your dependencies. + remote: ! 2. Replace any `pysqlite3` imports in your app with `sqlite3`. + remote: ! + remote: ! Alternatively, if you can't use the `sqlite3` stdlib module, + remote: ! update your `pysqlite3` package to 0.6.0 or newer, since the + remote: ! newer versions are published with pre-compiled wheels and so + remote: ! don't need the SQLite headers to be installed. + remote: + remote: + remote: ! Error: Unable to install dependencies using pip. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT end end diff --git a/spec/hatchet/pipenv_spec.rb b/spec/hatchet/pipenv_spec.rb index ff5b5ba1c..c7080e98e 100644 --- a/spec/hatchet/pipenv_spec.rb +++ b/spec/hatchet/pipenv_spec.rb @@ -2,245 +2,385 @@ require_relative '../spec_helper' -RSpec.shared_examples 'builds using Pipenv with the requested Python version' do |python_version| - it "builds with Python #{python_version}" do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> Python app detected - remote: -----> Using Python version specified in Pipfile.lock - remote: -----> Installing python-#{python_version} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 - REGEX - end - end -end - -RSpec.shared_examples 'aborts the build with a runtime not available message (Pipenv)' do |requested_version| - it 'aborts the build with a runtime not available message' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python version specified in Pipfile.lock - remote: ! - remote: ! Requested runtime 'python-#{requested_version}' 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: ! - OUTPUT - end - end -end - RSpec.describe 'Pipenv support' do - context 'without a Pipfile.lock' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_no_lockfile') } + 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_basic', buildpacks:) } - it 'builds with the default Python version using just the Pipfile' do + it 'installs successfully using Pipenv and on rebuilds uses the cache' do app.deploy do |app| + 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: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Running bin/post_compile hook + remote: BUILD_DIR=/tmp/build_.+ + remote: CACHE_DIR=/tmp/codon/tmp/cache + remote: C_INCLUDE_PATH=/app/.heroku/python/include + remote: CPLUS_INCLUDE_PATH=/app/.heroku/python/include + remote: ENV_DIR=/tmp/.+ + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/pipenv/bin:/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PIPENV_SYSTEM=1 + remote: PIPENV_VERBOSITY=-1 + remote: PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + remote: PYTHONUNBUFFERED=1 + remote: VIRTUAL_ENV=/app/.heroku/python + remote: -----> Saving cache + remote: + remote: ! Note: We recently added support for the package manager uv: + remote: ! https://devcenter.heroku.com/changelog-items/3238 + remote: ! + remote: ! It's now our recommended Python package manager, since it + remote: ! supports lockfiles, is faster, gives more helpful error + remote: ! messages, and is actively maintained by a full-time team. + remote: ! + remote: ! If you haven't tried it yet, we suggest you take a look! + remote: ! https://docs.astral.sh/uv/ + remote: + remote: -----> Inline app detected + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/bin:/app/.heroku/python/pipenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PIPENV_SYSTEM=1 + remote: PIPENV_VERBOSITY=-1 + remote: PYTHONHOME=/app/.heroku/python + remote: PYTHONPATH=/app + remote: PYTHONUNBUFFERED=true + remote: VIRTUAL_ENV=/app/.heroku/python + remote: + remote: \\['', + remote: '/app', + remote: '/app/.heroku/python/lib/python314.zip', + remote: '/app/.heroku/python/lib/python3.14', + remote: '/app/.heroku/python/lib/python3.14/lib-dynload', + remote: '/app/.heroku/python/lib/python3.14/site-packages'\\] + remote: + remote: pipenv, version #{PIPENV_VERSION} + remote: Package Version + remote: ----------------- -+ + remote: certifi 2026.1.4 + remote: packaging 25.0 + remote: typing_extensions 4.15.0 + remote: + remote: + remote: + remote: + remote: + remote: \\{ + remote: "cache_restore_duration": [0-9.]+, + remote: "cache_save_duration": [0-9.]+, + remote: "cache_status": "empty", + remote: "dependencies_install_duration": [0-9.]+, + remote: "django_collectstatic_duration": [0-9.]+, + remote: "nltk_downloader_duration": [0-9.]+, + remote: "package_manager": "pipenv", + remote: "package_manager_install_duration": [0-9.]+, + remote: "pipenv_version": "#{PIPENV_VERSION}", + remote: "post_compile_hook": true, + remote: "post_compile_hook_duration": [0-9.]+, + remote: "pre_compile_hook": false, + remote: "python_install_duration": [0-9.]+, + remote: "python_version": "#{DEFAULT_PYTHON_FULL_VERSION}", + remote: "python_version_major": "3.14", + remote: "python_version_origin": "Pipfile.lock", + remote: "python_version_outdated": false, + remote: "python_version_pinned": false, + remote: "python_version_requested": "3.14", + remote: "total_duration": [0-9.]+ + remote: \\} + REGEX + + app.commit! + app.push! 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Installing python-#{DEFAULT_PYTHON_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - 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... - remote: -----> Installing SQLite3 + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in Pipfile.lock + remote: -----> Restoring cache + remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Using cached Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Running bin/post_compile hook + remote: .+ + remote: -----> Saving cache REGEX + + # For historical reasons Pipenv is made available at run-time too, unlike some of the other package managers. + expect(normalize_trailing_newlines(app.run('bin/print-env-vars.sh && pipenv --version'))).to eq(<<~OUTPUT) + DYNO_RAM=512 + FORWARDED_ALLOW_IPS=* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/app/.heroku/python/pipenv/bin:/usr/local/bin:/usr/bin:/bin + PIPENV_SYSTEM=1 + PIPENV_VERBOSITY=-1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + VIRTUAL_ENV=/app/.heroku/python + WEB_CONCURRENCY=2 + WEB_CONCURRENCY_SET_BY=heroku/python + pipenv, version #{PIPENV_VERSION} + OUTPUT + expect($CHILD_STATUS.exitstatus).to eq(0) end end end - context 'with a Pipfile.lock but no Python version specified' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_unspecified') } + context 'when Pipfile.lock has changed since the last build' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_basic') } - it 'builds with the default Python version' do + it 'clears the cache before installing the packages again' do app.deploy do |app| + File.write('Pipfile.lock', "\n", mode: 'a') + FileUtils.rm('bin/post_compile') + 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 buildpack default: python-#{DEFAULT_PYTHON_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Installing python-#{DEFAULT_PYTHON_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in Pipfile.lock + remote: -----> Discarding cache since: + remote: - The contents of Pipfile.lock changed + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Saving cache REGEX end end end - context 'with a Pipfile.lock containing python_version 3.6' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.6', 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.10.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 version specified in Pipfile.lock + remote: -----> Using Python 3.10.0 specified in Pipfile.lock + remote: + remote: ! Warning: Support for Python 3.10 is deprecated! remote: ! - remote: ! Python 3.6 reached upstream end-of-life on December 23rd, 2021, and is - remote: ! therefore no longer receiving security updates: + remote: ! Python 3.10 will reach its upstream end-of-life in October 2026, + remote: ! at which point it will no longer receive security updates: remote: ! https://devguide.python.org/versions/#supported-versions remote: ! - remote: ! As such, it is no longer supported by this buildpack. + remote: ! As such, support for Python 3.10 will be removed from this + remote: ! buildpack on 6th January 2027. remote: ! - remote: ! Please upgrade to a newer Python version. + remote: ! Upgrade to a newer Python version as soon as possible, by + remote: ! changing the version in your 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: ! For more information, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: + remote: + remote: ! Warning: A Python patch update is available! remote: ! - OUTPUT + remote: ! Your app is using Python 3.10.0, however, there is a newer + remote: ! patch release of Python 3.10 available: #{LATEST_PYTHON_3_10} + remote: ! + remote: ! It is important to always use the latest patch version of + remote: ! Python to keep your app secure. + remote: ! + remote: ! Update your Pipfile.lock file to use the new version. + remote: ! + remote: ! We strongly recommend that you don't pin your app to an + remote: ! exact Python version such as 3.10.0, and instead only specify + remote: ! the major Python version of 3.10 in your Pipfile.lock file. + remote: ! This will allow your app to receive the latest available Python + remote: ! patch version automatically and prevent this warning. + remote: + remote: -----> Installing Python 3.10.0 + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Saving cache + REGEX end end end - context 'with a Pipfile.lock containing python_version 3.7' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.7', allow_failure: true) } + # TODO: Delete this test once pipenv_mismatched_python_version is re-enabled, + # since they mostly duplicate each other. + context 'when there is 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') } - it 'aborts the build with an EOL message' do + 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(<<~OUTPUT)) + 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 Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Saving cache + REGEX + end + end + end + + 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 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 version specified in Pipfile.lock + remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Warning: No Python version was specified. remote: ! - remote: ! Python 3.7 reached upstream end-of-life on June 27th, 2023, and is - remote: ! therefore no longer receiving security updates: - remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! Your app doesn't specify a Python version and so the buildpack + remote: ! picked a default version for you. remote: ! - remote: ! As such, it is no longer supported by this buildpack. + remote: ! Relying on this default version isn't recommended, since it + remote: ! can change over time and may not be consistent with your local + remote: ! development environment, CI or other instances of your app. remote: ! - remote: ! Please upgrade to a newer Python version. + remote: ! Please configure an explicit Python version for your app. remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: ! Create a new file in the root directory of your app named: + remote: ! .python-version remote: ! - OUTPUT - 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 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 version 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 dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 - REGEX - end + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update your .python-version file so it contains exactly: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! If your app already has a .python-version file, check that it: + remote: ! + remote: ! 1. Is in the top level directory \\(not a subdirectory\\). + remote: ! 2. Is named exactly '.python-version' in all lowercase. + remote: ! 3. Isn't listed in '.gitignore' or '.slugignore'. + remote: ! 4. Has been added to the Git repository using 'git add --all' + remote: ! and then committed using 'git commit'. + remote: ! + remote: ! In the future we will require the use of a .python-version + remote: ! file and this warning will be made an error. + remote: + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Saving cache + REGEX 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. - include_examples 'aborts the build with a runtime not available message (Pipenv)', LATEST_PYTHON_3_8 - end end - context 'with a Pipfile.lock containing python_version 3.9' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_3.9', allow_failure:) } - - context 'when using Heroku-22 or older', stacks: %w[heroku-20 heroku-22] do - include_examples 'builds using Pipenv with the requested Python version', LATEST_PYTHON_3_9 - end - - context 'when using Heroku-24', stacks: %w[heroku-24] do - let(:allow_failure) { true } + context 'without a Pipfile.lock' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_no_lockfile', allow_failure: true) } - # We only support Python 3.9 on Heroku-22 and older. - include_examples 'aborts the build with a runtime not available message (Pipenv)', LATEST_PYTHON_3_9 + it 'fails the build with an informative error message' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Python app detected + remote: + remote: ! Error: No 'Pipfile.lock' found! + remote: ! + remote: ! A 'Pipfile' file was found, however, the associated 'Pipfile.lock' + remote: ! Pipenv lockfile wasn't. This means your app dependency versions + remote: ! aren't pinned, which means the package versions used on Heroku + remote: ! might not match those installed in other environments. + remote: ! + remote: ! Using Pipenv in this way is unsafe and no longer supported. + remote: ! + remote: ! Run 'pipenv lock' locally to generate the lockfile, and make sure + remote: ! that 'Pipfile.lock' isn't listed in '.gitignore' or '.slugignore'. + remote: ! + remote: ! Alternatively, if you wish to switch to another package manager, + remote: ! delete your 'Pipfile' and then add either a 'requirements.txt', + remote: ! 'poetry.lock' or 'uv.lock' file. + remote: ! + remote: ! If you aren't sure which package manager to use, we recommend + remote: ! trying uv, since it supports lockfiles, is extremely fast, and + remote: ! is actively maintained by a full-time team: + remote: ! https://docs.astral.sh/uv/ + remote: + remote: ! Push rejected, failed to compile Python app. + REGEX + end end 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', 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', 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', LATEST_PYTHON_3_12 - end - - context 'with a Pipfile.lock containing python_full_version 3.12.2' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_full_version', allow_failure:) } + context 'with a Pipfile.lock containing invalid JSON' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_lockfile_invalid_json', allow_failure: true) } - it 'builds with the outdated Python version specified' do + it 'fails the build' do app.deploy do |app| + # The exact JQ error message varies between JQ versions, and thus across stacks. expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) remote: -----> Python app detected - remote: -----> Using Python version specified in Pipfile.lock + remote: + remote: ! Error: Can't parse Pipfile.lock. remote: ! - remote: ! A Python security update is available! Upgrade as soon as possible to: python-#{LATEST_PYTHON_3_12} - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes + 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: -----> Installing python-3.12.2 - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 + remote: ! This is likely due to it not being valid JSON. + remote: ! + remote: ! Run 'pipenv lock' to regenerate/fix the lockfile. + remote: + remote: ! Push rejected, failed to compile Python app. REGEX end end end - context 'with a Pipfile.lock containing an invalid python_version', - skip: 'unknown python_version values are currently ignored (W-8104668)' do + context 'with a Pipfile.lock containing an invalid python_version' do let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_invalid', allow_failure: true) } it 'fails the build' do app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python version specified in Pipfile.lock + remote: + remote: ! Error: Invalid Python version in Pipfile.lock. + remote: ! + remote: ! The Python version specified in your Pipfile.lock file by the + remote: ! 'python_version' or 'python_full_version' fields isn't valid. + remote: ! + remote: ! The following version was found: + remote: ! ^3.12 + remote: ! + remote: ! However, the Python version must be specified as either: + remote: ! 1. The major version only, for example: #{DEFAULT_PYTHON_MAJOR_VERSION} (recommended) + remote: ! 2. An exact patch version, for example: #{DEFAULT_PYTHON_MAJOR_VERSION}.999 remote: ! - remote: ! Requested runtime '^3.12' is not available for this stack (#{app.stack}). + remote: ! Wildcards aren't supported. remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: ! Please update your Pipfile to use a valid Python version and + remote: ! then run 'pipenv lock' to regenerate Pipfile.lock. remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! For more information, see: + remote: ! https://pipenv.pypa.io/en/stable/specifiers.html#specifying-versions-of-python + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT end end @@ -253,91 +393,91 @@ app.deploy do |app| expect(clean_output(app.output)).to include(<<~OUTPUT) remote: -----> Python app detected - remote: -----> Using Python version specified in Pipfile.lock + remote: + remote: ! Error: Invalid Python version in Pipfile.lock. + remote: ! + remote: ! The Python version specified in your Pipfile.lock file by the + remote: ! 'python_version' or 'python_full_version' fields isn't valid. + remote: ! + remote: ! The following version was found: + remote: ! 3.9.* + remote: ! + remote: ! However, the Python version must be specified as either: + remote: ! 1. The major version only, for example: #{DEFAULT_PYTHON_MAJOR_VERSION} (recommended) + remote: ! 2. An exact patch version, for example: #{DEFAULT_PYTHON_MAJOR_VERSION}.999 remote: ! - remote: ! Requested runtime 'python-X.Y.Z' is not available for this stack (#{app.stack}). + remote: ! Wildcards aren't supported. remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: ! Please update your Pipfile to use a valid Python version and + remote: ! then run 'pipenv lock' to regenerate Pipfile.lock. remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! For more information, see: + remote: ! https://pipenv.pypa.io/en/stable/specifiers.html#specifying-versions-of-python + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT end 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') } + 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 'builds with the Python version from runtime.txt' do + it 'aborts the build with an EOL message' do app.deploy do |app| - 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: -----> Using Python version 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 dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 - REGEX + remote: -----> Using Python 3.8 specified in Pipfile.lock + remote: + remote: ! Error: The requested Python version has reached end-of-life. + remote: ! + remote: ! Python 3.8 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's no longer supported by this buildpack: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: ! + remote: ! Please upgrade to at least Python 3.10 by changing the + remote: ! version in your Pipfile.lock file. + remote: ! + remote: ! If possible, we recommend upgrading all the way to Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! since it contains many performance and usability improvements. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT 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') } + # TODO: Enable on Heroku-26 after the Pipenv and default Python versions next change, + # since for now there isn't a historic buildpack version we can use in this test whose + # stack check permits Heroku-26 and that also uses older Pipenv/Python versions. + context 'when the Pipenv and Python versions have changed since the last build', stacks: %w[heroku-22 heroku-24] do + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v313'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_basic', buildpacks:) } - it 're-uses packages from the cache' do + it 'clears the cache before installing' do app.deploy do |app| + update_buildpacks(app, [:default]) + FileUtils.rm('bin/post_compile') 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Using cached install of python-#{DEFAULT_PYTHON_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 - remote: -----> Discovering process types - REGEX - end - end - end - - context 'when there is both a Pipfile.lock and a requirements.txt' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_and_requirements_txt') } - - it 'builds with Pipenv rather than pip' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> Python app detected - remote: -----> Using Python version specified in Pipfile.lock - remote: -----> Installing python-#{LATEST_PYTHON_3_12} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - remote: Installing dependencies from Pipfile.lock \\(.+\\)... - remote: -----> Installing SQLite3 - REGEX - end - end - end - - context 'when the Pipfile.lock is out of sync with Pipfile' do - let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_lockfile_out_of_sync', allow_failure: true) } - - it 'fails the build' do - 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Installing python-#{DEFAULT_PYTHON_VERSION} - remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} - remote: -----> Installing dependencies with Pipenv #{PIPENV_VERSION} - remote: Your Pipfile.lock \\(.+\\) is out of date. Expected: \\(.+\\). - remote: .+ - remote: ERROR:: Aborting deploy + remote: -----> Using Python 3.14 specified in Pipfile.lock + remote: -----> Discarding cache since: + remote: - The Python version has changed from 3.14.0 to #{LATEST_PYTHON_3_14} + remote: - The Pipenv version has changed from 2025.0.4 to #{PIPENV_VERSION} + remote: -----> Installing Python #{LATEST_PYTHON_3_14} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Saving cache REGEX end end @@ -347,68 +487,132 @@ let(:buildpacks) { [:default, 'heroku-community/inline'] } let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_editable', buildpacks:) } - it 'rewrites .pth, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do + it 'rewrites .pth 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)) - remote: -----> Running post-compile hook - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py - remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: _editable_impl_pipenv_editable.pth:/tmp/build_.+ + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the current package: Hello from pipenv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + .+ remote: -----> Inline app detected - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: _editable_impl_pipenv_editable.pth:/tmp/build_.+ + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: Running entrypoint for the current package: Hello from pipenv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) REGEX # Test rewritten paths work at runtime. - expect(app.run('bin/test-entrypoints')).to include(<<~OUTPUT) - easy-install.pth:/app/.heroku/src/gunicorn - easy-install.pth:/app/packages/local_package_setup_py + expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT) + __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + _editable_impl_pipenv_editable.pth:/app __editable___local_package_pyproject_toml_0_0_1_finder.py:/app/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - gunicorn.egg-link:/app/.heroku/src/gunicorn - local-package-setup-py.egg-link:/app/packages/local_package_setup_py + __editable___local_package_setup_py_0_0_1_finder.py:/app/packages/local_package_setup_py/local_package_setup_py'} - Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - Running entrypoint for the setup.py-based local package: Hello setup.py! - Running entrypoint for the VCS package: gunicorn (version 20.1.0) + Running entrypoint for the current package: Hello from pipenv-editable! + Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + Running entrypoint for the setup.py-based local package: Hello from setup.py! + Running entrypoint for the VCS package: gunicorn (version 23.0.0) OUTPUT # Test that the cached .pth files work correctly. app.commit! app.push! expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) - remote: -----> Running post-compile hook - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py - remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: _editable_impl_pipenv_editable.pth:/tmp/build_.+ + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the current package: Hello from pipenv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + .+ remote: -----> Inline app detected - remote: easy-install.pth:/app/.heroku/src/gunicorn - remote: easy-install.pth:/tmp/build_.*/packages/local_package_setup_py - remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.*/packages/local_package_pyproject_toml/local_package_pyproject_toml'} - remote: gunicorn.egg-link:/app/.heroku/src/gunicorn - remote: local-package-setup-py.egg-link:/tmp/build_.*/packages/local_package_setup_py + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: _editable_impl_pipenv_editable.pth:/tmp/build_.+ + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: + remote: Running entrypoint for the current package: Hello from pipenv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + REGEX + end + end + end + + # This tests that Pipenv doesn't fall back to system Python if the Python version in + # .python-version doesn't match that in Pipfile / Pipfile.lock. It also tests which of + # .python-version and Pipfile.lock take precedence for the installed Python version. + context 'when python_version in Pipfile.lock is incompatible with .python-version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_mismatched_python_version', allow_failure: true) } + + it 'fails the build', skip: 'https://github.com/pypa/pipenv/issues/6514' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python #{LATEST_PYTHON_3_13} specified in .python-version + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: Warning: Your Pipfile requires "python_version" 3.12, but you are using #{LATEST_PYTHON_3_13} + remote: from //app/./python/bin/python3. + remote: Usage: pipenv install [OPTIONS] [PACKAGES]... + remote: + remote: ERROR:: Aborting deploy + remote: + remote: ! Error: Unable to install dependencies using Pipenv. + remote: ! + remote: ! See the log output above for more information. + OUTPUT + end + end + end + + # This tests not only our handling of failing dependency installation, but also that we're running + # Pipenv in such a way that it errors if the lockfile is out of sync, rather than simply updating it. + context 'when the Pipfile.lock is out of sync with Pipfile' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_lockfile_out_of_sync', allow_failure: true) } + + it 'fails the build' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: Your Pipfile.lock + remote: \\(.+\\) is out of + remote: date. Expected: + remote: \\(.+\\). + remote: .+ + remote: ERROR:: Aborting deploy + remote: + remote: ! Error: Unable to install dependencies using Pipenv. + remote: ! + remote: ! See the log output above for more information. remote: - remote: Running entrypoint for the pyproject.toml-based local package: Hello pyproject.toml! - remote: Running entrypoint for the setup.py-based local package: Hello setup.py! - remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: ! Push rejected, failed to compile Python app. REGEX end end diff --git a/spec/hatchet/poetry_spec.rb b/spec/hatchet/poetry_spec.rb new file mode 100644 index 000000000..03b980ddf --- /dev/null +++ b/spec/hatchet/poetry_spec.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Poetry support' do + context 'with a poetry.lock' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_basic', buildpacks:) } + + it 'installs successfully using Poetry and on rebuilds uses the cache' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: Package operations: 1 install, 0 updates, 0 removals + remote: + remote: - Installing typing-extensions \\(4.15.0\\) + remote: -----> Running bin/post_compile hook + remote: BUILD_DIR=/tmp/build_.+ + remote: CACHE_DIR=/tmp/codon/tmp/cache + remote: C_INCLUDE_PATH=/app/.heroku/python/include + remote: CPLUS_INCLUDE_PATH=/app/.heroku/python/include + remote: ENV_DIR=/tmp/.+ + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/tmp/codon/tmp/cache/.heroku/python-poetry/bin:/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + remote: POETRY_VIRTUALENVS_CREATE=false + remote: POETRY_VIRTUALENVS_USE_POETRY_PYTHON=true + remote: PYTHONUNBUFFERED=1 + remote: -----> Saving cache + remote: + remote: ! Note: We recently added support for the package manager uv: + remote: ! https://devcenter.heroku.com/changelog-items/3238 + remote: ! + remote: ! It's now our recommended Python package manager, since it + remote: ! supports lockfiles, is faster, gives more helpful error + remote: ! messages, and is actively maintained by a full-time team. + remote: ! + remote: ! If you haven't tried it yet, we suggest you take a look! + remote: ! https://docs.astral.sh/uv/ + remote: + remote: -----> Inline app detected + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/bin:/tmp/codon/tmp/cache/.heroku/python-poetry/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: POETRY_VIRTUALENVS_CREATE=false + remote: POETRY_VIRTUALENVS_USE_POETRY_PYTHON=true + remote: PYTHONHOME=/app/.heroku/python + remote: PYTHONPATH=/app + remote: PYTHONUNBUFFERED=true + remote: + remote: \\['', + remote: '/app', + remote: '/app/.heroku/python/lib/python314.zip', + remote: '/app/.heroku/python/lib/python3.14', + remote: '/app/.heroku/python/lib/python3.14/lib-dynload', + remote: '/app/.heroku/python/lib/python3.14/site-packages'\\] + remote: + remote: Poetry \\(version #{POETRY_VERSION}\\) + remote: Skipping virtualenv creation, as specified in config file. + remote: typing-extensions 4.15.0 Backported and Experimental Type Hints for Python ... + remote: + remote: + remote: + remote: \\{ + remote: "cache_restore_duration": [0-9.]+, + remote: "cache_save_duration": [0-9.]+, + remote: "cache_status": "empty", + remote: "dependencies_install_duration": [0-9.]+, + remote: "django_collectstatic_duration": [0-9.]+, + remote: "nltk_downloader_duration": [0-9.]+, + remote: "package_manager": "poetry", + remote: "package_manager_install_duration": [0-9.]+, + remote: "poetry_version": "#{POETRY_VERSION}", + remote: "post_compile_hook": true, + remote: "post_compile_hook_duration": [0-9.]+, + remote: "pre_compile_hook": false, + remote: "python_install_duration": [0-9.]+, + remote: "python_version": "#{DEFAULT_PYTHON_FULL_VERSION}", + remote: "python_version_major": "3.14", + remote: "python_version_origin": ".python-version", + remote: "python_version_outdated": false, + remote: "python_version_pinned": false, + remote: "python_version_requested": "3.14", + remote: "total_duration": [0-9.]+ + remote: \\} + REGEX + + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Restoring cache + remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Using cached Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: No dependencies to install or update + remote: -----> Running bin/post_compile hook + remote: .+ + remote: -----> Saving cache + REGEX + + command = 'bin/print-env-vars.sh && if command -v poetry; then echo "Poetry unexpectedly found!" && exit 1; fi' + expect(normalize_trailing_newlines(app.run(command))).to eq(<<~OUTPUT) + DYNO_RAM=512 + FORWARDED_ALLOW_IPS=* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=2 + WEB_CONCURRENCY_SET_BY=heroku/python + OUTPUT + expect($CHILD_STATUS.exitstatus).to eq(0) + end + end + end + + # TODO: Enable on Heroku-26 after the Poetry and default Python versions next change, + # since for now there isn't a historic buildpack version we can use in this test whose + # stack check permits Heroku-26 and that also uses older Poetry/Python versions. + context 'when the Poetry and Python versions have changed since the last build', stacks: %w[heroku-22 heroku-24] do + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v313'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_basic', buildpacks:) } + + it 'clears the cache before installing' do + app.deploy do |app| + update_buildpacks(app, [:default]) + FileUtils.rm('bin/post_compile') + app.commit! + app.push! + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.14 specified in .python-version + remote: -----> Discarding cache since: + remote: - The Python version has changed from 3.14.0 to #{LATEST_PYTHON_3_14} + remote: - The Poetry version has changed from 2.2.1 to #{POETRY_VERSION} + remote: -----> Installing Python #{LATEST_PYTHON_3_14} + remote: -----> Installing Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: Package operations: 1 install, 0 updates, 0 removals + remote: + remote: - Installing typing-extensions (4.15.0) + remote: -----> Saving cache + OUTPUT + end + end + end + + context 'when poetry.lock contains editable requirements (both VCS and local package)' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_editable', buildpacks:) } + + it 'rewrites .pth 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)) + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: Package operations: 4 installs, 0 updates, 0 removals + remote: + remote: - Installing packaging \\(25.0\\) + remote: - Installing gunicorn \\(23.0.0 56b5ad8\\) + remote: - Installing local-package-pyproject-toml \\(0.0.1 /tmp/build_.+/packages/local_package_pyproject_toml\\) + remote: - Installing local-package-setup-py \\(0.0.1 /tmp/build_.+/packages/local_package_setup_py\\) + remote: + remote: Installing the current project: poetry-editable \\(0.0.1\\) + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: poetry_editable.pth:/tmp/build_.+ + remote: + remote: Running entrypoint for the current package: Hello from poetry-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + .+ + remote: -----> Inline app detected + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: poetry_editable.pth:/tmp/build_.+ + remote: + remote: Running entrypoint for the current package: Hello from poetry-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + REGEX + + # Test rewritten paths work at runtime. + expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT) + __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + __editable___local_package_pyproject_toml_0_0_1_finder.py:/app/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + __editable___local_package_setup_py_0_0_1_finder.py:/app/packages/local_package_setup_py/local_package_setup_py'} + poetry_editable.pth:/app + + Running entrypoint for the current package: Hello from poetry-editable! + Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + Running entrypoint for the setup.py-based local package: Hello from setup.py! + Running entrypoint for the VCS package: gunicorn (version 23.0.0) + OUTPUT + + # Test that the cached .pth files work correctly. + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: Package operations: 0 installs, 3 updates, 0 removals + remote: + remote: - Updating gunicorn \\(23.0.0 /app/.heroku/python/src/gunicorn -> 23.0.0 56b5ad8\\) + remote: - Updating local-package-pyproject-toml \\(0.0.1 /tmp/build_.+/packages/local_package_pyproject_toml -> 0.0.1 /tmp/build_.+/packages/local_package_pyproject_toml\\) + remote: - Updating local-package-setup-py \\(0.0.1 /tmp/build_.+/packages/local_package_setup_py -> 0.0.1 /tmp/build_.+/packages/local_package_setup_py\\) + remote: + remote: Installing the current project: poetry-editable \\(0.0.1\\) + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: poetry_editable.pth:/tmp/build_.+ + remote: + remote: Running entrypoint for the current package: Hello from poetry-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + .+ + remote: -----> Inline app detected + remote: __editable___gunicorn_23_0_0_finder.py:/app/.heroku/python/src/gunicorn/gunicorn'} + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: poetry_editable.pth:/tmp/build_.+ + remote: + remote: Running entrypoint for the current package: Hello from poetry-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + REGEX + end + end + end + + # This checks that the Poetry bootstrap works even with older bundled pip, and that our + # chosen Poetry version also supports our oldest supported Python version. The fixture + # also includes a `brotli` directory to test the workaround for an `ensurepip` bug in + # older Python versions: https://github.com/heroku/heroku-buildpack-python/issues/1697 + context 'when using our oldest supported Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_oldest_python') } + + it 'installs successfully' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.10.0 specified in .python-version + remote: + remote: ! Warning: Support for Python 3.10 is deprecated! + remote: ! + remote: ! Python 3.10 will reach its upstream end-of-life in October 2026, + remote: ! at which point it will no longer receive security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, support for Python 3.10 will be removed from this + remote: ! buildpack on 6th January 2027. + remote: ! + remote: ! Upgrade to a newer Python version as soon as possible, by + remote: ! changing the version in your .python-version file. + remote: ! + remote: ! For more information, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: + remote: + remote: ! Warning: A Python patch update is available! + remote: ! + remote: ! Your app is using Python 3.10.0, however, there is a newer + remote: ! patch release of Python 3.10 available: #{LATEST_PYTHON_3_10} + remote: ! + remote: ! It is important to always use the latest patch version of + remote: ! Python to keep your app secure. + remote: ! + remote: ! Update your .python-version file to use the new version. + remote: ! + remote: ! We strongly recommend that you don't pin your app to an + remote: ! exact Python version such as 3.10.0, and instead only specify + remote: ! the major Python version of 3.10 in your .python-version file. + remote: ! This will allow your app to receive the latest available Python + remote: ! patch version automatically and prevent this warning. + remote: + remote: -----> Installing Python 3.10.0 + remote: -----> Installing Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: Package operations: 1 install, 0 updates, 0 removals + remote: + remote: - Installing typing-extensions (4.15.0) + remote: -----> Saving cache + OUTPUT + end + end + end + + # This is disabled since it's currently broken upstream: https://github.com/python-poetry/poetry/issues/10226 + # This tests that Poetry doesn't download its own Python or fall back to system Python + # if the Python version in pyproject.toml doesn't match that in .python-version. + # context 'when requires-python in pyproject.toml is incompatible with .python-version' do + # let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_mismatched_python_version', allow_failure: true) } + # + # it 'fails the build' do + # app.deploy do |app| + # expect(clean_output(app.output)).to include(<<~OUTPUT) + # remote: -----> Installing dependencies using 'poetry sync --only main' + # remote: + # OUTPUT + # end + # end + # end + + # This tests not only our handling of failing dependency installation, but also that we're running + # Poetry in such a way that it errors if the lockfile is out of sync, rather than simply updating it. + context 'when poetry.lock is out of sync with pyproject.toml' do + let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_lockfile_out_of_sync', allow_failure: true) } + + it 'fails the build' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Installing dependencies using 'poetry sync --only main' + remote: Installing dependencies from lock file + remote: + remote: pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock` to fix the lock file. + remote: + remote: ! Error: Unable to install dependencies using Poetry. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end +end diff --git a/spec/hatchet/profile_d_scripts_spec.rb b/spec/hatchet/profile_d_scripts_spec.rb index 434d6ccad..20b90418f 100644 --- a/spec/hatchet/profile_d_scripts_spec.rb +++ b/spec/hatchet/profile_d_scripts_spec.rb @@ -5,61 +5,37 @@ RSpec.describe '.profile.d/ scripts' do it 'sets the required run-time env vars' do Hatchet::Runner.new('spec/fixtures/procfile', run_multi: true).deploy do |app| - # These are written as a single test to reduce end to end test time. This repo uses parallel_split_test, - # so we can't perform app setup in a `before(:all)` and have multiple tests run against the single app. - - list_envs_cmd = 'env | sort | grep -vE "^(_|DYNO|PORT|PS1|SHLVL|TERM)="' - - # Check all env vars are set correctly when there are no user-provided env vars. - # Also checks that the WEB_CONCURRENCY related log output is not shown for one-off dynos. - app.run_multi(list_envs_cmd) do |output, _| - expect(output).to eq(<<~OUTPUT) - DYNO_RAM=512 - FORWARDED_ALLOW_IPS=* - GUNICORN_CMD_ARGS=--access-logfile - - HOME=/app - LANG=en_US.UTF-8 - LD_LIBRARY_PATH=/app/.heroku/vendor/lib:/app/.heroku/python/lib: - LIBRARY_PATH=/app/.heroku/vendor/lib:/app/.heroku/python/lib: - PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin - PWD=/app - PYTHONHASHSEED=random - PYTHONHOME=/app/.heroku/python - PYTHONPATH=/app - PYTHONUNBUFFERED=true - WEB_CONCURRENCY=2 - OUTPUT - end + # These are written as a single test to reduce end to end test time. (This repo uses parallel_split_test, + # so we can't perform app setup in a `before(:all)` and have multiple tests run against the single app.) + # These tests supplement the run-time env var tests performed for each package manager, so intentionally + # don't duplicate those. # Check user-provided env var values are preserved/overridden as appropriate. # Also checks that the WEB_CONCURRENCY related log output is not shown for worker dynos. + list_envs_cmd = 'printenv | sort | grep -vE "^(_|DYNO|HOME|PORT|PS1|PWD|SHLVL|TERM)="' user_env_vars = [ 'DYNO_RAM=this-should-be-overridden', 'FORWARDED_ALLOW_IPS=this-should-be-overridden', 'GUNICORN_CMD_ARGS=this-should-be-preserved', - 'HOME=this-should-be-overridden', 'LANG=this-should-be-overridden', 'LD_LIBRARY_PATH=/this-should-be-preserved', 'LIBRARY_PATH=/this-should-be-preserved', 'PATH=/this-should-be-preserved:/usr/local/bin:/usr/bin:/bin', - 'PYTHONHASHSEED=this-should-be-preserved', 'PYTHONHOME=/this-should-be-overridden', 'PYTHONPATH=/this-should-be-preserved', 'PYTHONUNBUFFERED=this-should-be-overridden', 'WEB_CONCURRENCY=this-should-be-preserved', ] app.run_multi(list_envs_cmd, heroku: { env: user_env_vars.join(';'), type: 'example-worker' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) DYNO_RAM=512 FORWARDED_ALLOW_IPS=* GUNICORN_CMD_ARGS=this-should-be-preserved - HOME=/app LANG=C.UTF-8 - LD_LIBRARY_PATH=/app/.heroku/vendor/lib:/app/.heroku/python/lib:/this-should-be-preserved - LIBRARY_PATH=/app/.heroku/vendor/lib:/app/.heroku/python/lib:/this-should-be-preserved + LD_LIBRARY_PATH=/app/.heroku/python/lib:/this-should-be-preserved + LIBRARY_PATH=/app/.heroku/python/lib:/this-should-be-preserved PATH=/app/.heroku/python/bin:/this-should-be-preserved:/usr/local/bin:/usr/bin:/bin - PWD=/app - PYTHONHASHSEED=this-should-be-preserved + PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONHOME=/app/.heroku/python PYTHONPATH=/this-should-be-preserved PYTHONUNBUFFERED=true @@ -67,82 +43,89 @@ OUTPUT end - list_concurrency_envs_cmd = 'env | sort | grep -E "^(DYNO_RAM|WEB_CONCURRENCY)="' + list_concurrency_envs_cmd = 'printenv | sort | grep -E "^(DYNO_RAM|WEB_CONCURRENCY.*)="' # Check WEB_CONCURRENCY support when using a Standard-1X dyno. # We set the process type to `web` so that we can test the web-dyno-only log output. app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'standard-1x', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 512 MB available memory and 8 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 2 based on the available memory. DYNO_RAM=512 WEB_CONCURRENCY=2 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Standard-2X app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'standard-2x', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 1024 MB available memory and 8 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 4 based on the available memory. DYNO_RAM=1024 WEB_CONCURRENCY=4 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Performance-M app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'performance-m', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 2560 MB available memory and 2 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 5 based on the number of CPU cores. DYNO_RAM=2560 WEB_CONCURRENCY=5 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Performance-L app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'performance-l', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 14336 MB available memory and 8 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 17 based on the number of CPU cores. DYNO_RAM=14336 WEB_CONCURRENCY=17 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Performance-L-RAM app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'performance-l-ram', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 30720 MB available memory and 4 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 9 based on the number of CPU cores. DYNO_RAM=30720 WEB_CONCURRENCY=9 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Performance-XL app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'performance-xl', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 63488 MB available memory and 8 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 17 based on the number of CPU cores. DYNO_RAM=63488 WEB_CONCURRENCY=17 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Performance-2XL app.run_multi(list_concurrency_envs_cmd, heroku: { size: 'performance-2xl', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 129024 MB available memory and 16 CPU cores. Python buildpack: Defaulting WEB_CONCURRENCY to 33 based on the number of CPU cores. DYNO_RAM=129024 WEB_CONCURRENCY=33 + WEB_CONCURRENCY_SET_BY=heroku/python OUTPUT end # Check that WEB_CONCURRENCY is preserved if set, but that we still set DYNO_RAM. app.run_multi(list_concurrency_envs_cmd, heroku: { env: 'WEB_CONCURRENCY=999', type: 'web' }) do |output, _| - expect(output).to eq(<<~OUTPUT) + expect(normalize_trailing_newlines(output)).to eq(<<~OUTPUT) Python buildpack: Detected 512 MB available memory and 8 CPU cores. Python buildpack: Skipping automatic configuration of WEB_CONCURRENCY since it's already set. DYNO_RAM=512 diff --git a/spec/hatchet/python_update_warning_spec.rb b/spec/hatchet/python_update_warning_spec.rb deleted file mode 100644 index 6c7f66d62..000000000 --- a/spec/hatchet/python_update_warning_spec.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -require_relative '../spec_helper' - -RSpec.shared_examples 'warns there is a Python update available' do |requested_version, latest_version| - 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 version specified in runtime.txt - remote: ! - remote: ! A Python security update is available! Upgrade as soon as possible to: python-#{latest_version} - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes - remote: ! - remote: -----> Installing python-#{requested_version} - OUTPUT - end - end -end - -RSpec.shared_examples 'aborts the build without showing an update warning' do |requested_version| - it 'aborts the build without showing an update warning' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python version specified in runtime.txt - remote: ! - remote: ! Requested runtime 'python-#{requested_version}' 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: ! - OUTPUT - end - end -end - -RSpec.describe 'Python update warnings' do - context 'with a runtime.txt containing python-3.8.12' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.8_outdated', allow_failure:) } - - context 'when using Heroku-20', stacks: %w[heroku-20] do - it 'warns about both the deprecated major version and the patch update' do - app.deploy do |app| - expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) - remote: -----> Python app detected - remote: -----> Using Python version specified in runtime.txt - 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: ! - remote: ! A Python security update is available! Upgrade as soon as possible to: python-#{LATEST_PYTHON_3_8} - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes - remote: ! - remote: -----> Installing python-3.8.12 - 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. - include_examples 'aborts the build without showing an update warning', '3.8.12' - end - end - - context 'with a runtime.txt containing python-3.9.12' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.9_outdated', allow_failure:) } - - context 'when using Heroku-22 or older', stacks: %w[heroku-20 heroku-22] do - include_examples 'warns there is a Python update available', '3.9.12', LATEST_PYTHON_3_9 - end - - context 'when using Heroku-24', stacks: %w[heroku-24] do - let(:allow_failure) { true } - - # We only support Python 3.9 on Heroku-22 and older. - include_examples 'aborts the build without showing an update warning', '3.9.12' - end - end - - context 'with a runtime.txt containing python-3.10.5' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.10_outdated', allow_failure:) } - - include_examples 'warns there is a Python update available', '3.10.5', LATEST_PYTHON_3_10 - end - - context 'with a runtime.txt containing python-3.11.0' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.11_outdated', allow_failure:) } - - include_examples 'warns there is a Python update available', '3.11.0', LATEST_PYTHON_3_11 - end - - context 'with a runtime.txt containing python-3.12.0' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.12_outdated', allow_failure:) } - - include_examples 'warns there is a Python update available', '3.12.0', LATEST_PYTHON_3_12 - end -end diff --git a/spec/hatchet/python_version_spec.rb b/spec/hatchet/python_version_spec.rb index 7ae02287b..24a083e04 100644 --- a/spec/hatchet/python_version_spec.rb +++ b/spec/hatchet/python_version_spec.rb @@ -2,36 +2,30 @@ require_relative '../spec_helper' -RSpec.shared_examples 'builds with the requested Python version' do |python_version| - it "builds with Python #{python_version}" do +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| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python version specified in runtime.txt - remote: -----> Installing python-#{python_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)) - OUTPUT - expect(app.run('python -V')).to include("Python #{python_version}") - end - end -end - -RSpec.shared_examples 'aborts the build with a runtime not available message' do |requested_runtime| - it 'aborts the build with a runtime not available message' do - app.deploy do |app| - expect(clean_output(app.output)).to include(<<~OUTPUT) - remote: -----> Python app detected - remote: -----> Using Python version specified in runtime.txt - remote: ! - remote: ! Requested runtime '#{requested_runtime}' 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: ! - OUTPUT + if ['3.10', '3.11', '3.12'].include?(requested_version) + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python #{requested_version} specified in .python-version + remote: -----> Installing Python #{resolved_version} + remote: -----> Installing pip #{PIP_VERSION} and setuptools #{SETUPTOOLS_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (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 .python-version + remote: -----> Installing Python #{resolved_version} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 2)) + OUTPUT + end + expect(normalize_trailing_newlines(app.run('python -V'))).to eq("Python #{resolved_version}\n") + expect($CHILD_STATUS.exitstatus).to eq(0) end end end @@ -46,9 +40,49 @@ 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Installing python-#{DEFAULT_PYTHON_VERSION} + remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Warning: No Python version was specified. + remote: ! + remote: ! Your app doesn't specify a Python version and so the buildpack + remote: ! picked a default version for you. + remote: ! + remote: ! Relying on this default version isn't recommended, since it + remote: ! can change over time and may not be consistent with your local + remote: ! development environment, CI or other instances of your app. + remote: ! + remote: ! Please configure an explicit Python version for your app. + remote: ! + remote: ! Create a new file in the root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update your .python-version file so it contains exactly: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! If your app already has a .python-version file, check that it: + remote: ! + remote: ! 1. Is in the top level directory (not a subdirectory). + remote: ! 2. Is named exactly '.python-version' in all lowercase. + remote: ! 3. Isn't listed in '.gitignore' or '.slugignore'. + remote: ! 4. Has been added to the Git repository using 'git add --all' + remote: ! and then committed using 'git commit'. + remote: ! + remote: ! In the future we will require the use of a .python-version + remote: ! file and this warning will be made an error. + remote: + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} OUTPUT end end @@ -56,186 +90,647 @@ context 'with an app last built using an older default Python version' do # This test performs an initial build using an older buildpack version, followed - # by a build using the current version. This ensures that the current buildpack - # can successfully read the version metadata written to the build cache in the past. - let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v250'] } + # by a build using the current version. This ensures that: + # - The current buildpack can successfully read the version metadata + # written to the build cache by older buildpack versions. + # - If no Python version is specified, the same major version as the + # last build is used (sticky major versioning). + # - Changes in the pip version are handled correctly. + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v342'] } - it 'builds with the same Python version as the last build' do + it 'builds with the same major Python version as the last build' do app.deploy do |app| update_buildpacks(app, [:default]) app.commit! 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-3.12.3 - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> No Python version was specified. Using the same major version as the last build: Python 3.14 + remote: + remote: ! Warning: No Python version was specified. + remote: ! + remote: ! Your app doesn't specify a Python version and so the buildpack + remote: ! picked a default version for you. + remote: ! + remote: ! Relying on this default version isn't recommended, since it + remote: ! can change over time and may not be consistent with your local + remote: ! development environment, CI or other instances of your app. + remote: ! + remote: ! Please configure an explicit Python version for your app. + remote: ! + remote: ! Create a new file in the root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. remote: ! - remote: ! A Python security update is available! Upgrade as soon as possible to: python-#{LATEST_PYTHON_3_12} - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes + remote: ! For example, to request the latest version of Python 3.14, + remote: ! update your .python-version file so it contains exactly: + remote: ! 3.14 remote: ! - remote: -----> No change in requirements detected, installing from cache - remote: -----> Using cached install of python-3.12.3 + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! If your app already has a .python-version file, check that it: + remote: ! + remote: ! 1. Is in the top level directory (not a subdirectory). + remote: ! 2. Is named exactly '.python-version' in all lowercase. + remote: ! 3. Isn't listed in '.gitignore' or '.slugignore'. + remote: ! 4. Has been added to the Git repository using 'git add --all' + remote: ! and then committed using 'git commit'. + remote: ! + remote: ! In the future we will require the use of a .python-version + remote: ! file and this warning will be made an error. + remote: + remote: -----> Discarding cache since: + remote: - The Python version has changed from 3.14.4 to #{LATEST_PYTHON_3_14} + remote: - The pip version has changed from 26.0.1 to #{PIP_VERSION} + remote: -----> Installing Python #{LATEST_PYTHON_3_14} + remote: -----> Installing pip #{PIP_VERSION} + OUTPUT + expect(normalize_trailing_newlines(app.run('python -V'))).to eq("Python #{LATEST_PYTHON_3_14}\n") + expect($CHILD_STATUS.exitstatus).to eq(0) + end + end + end + + context 'with an app last built using an EOL Python version' do + # This fixture emulates the cache from a previous build that used a now-EOL Python version. + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_eol_cached', 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: -----> Running bin/pre_compile hook + remote: -----> No Python version was specified. Using the same major version as the last build: Python 3.9 + remote: + remote: ! Error: The cached Python version has reached end-of-life. + remote: ! + remote: ! Your app doesn't specify a Python version, and so normally + remote: ! would use the version cached from the last build (3.9). + remote: ! + remote: ! However, Python 3.9 has reached its upstream end-of-life, + remote: ! and is therefore no longer receiving security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, it's no longer supported by this buildpack: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: ! + remote: ! Please upgrade to at least Python 3.10 by configuring an + remote: ! explicit Python version for your app. + remote: ! + remote: ! Create a new file in the root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python 3.10, + remote: ! update your .python-version file so it contains exactly: + remote: ! 3.10 + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'with an app last built using an unrecognised Python version' do + # This fixture emulates the cache from a previous build which used a newer default + # Python version than the version supported by this buildpack version. + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_non_existent_major_cached', allow_failure: true) } + + it 'aborts the build with an unrecognised Python version message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Running bin/pre_compile hook + remote: -----> No Python version was specified. Using the same major version as the last build: Python 3.99 + remote: + remote: ! Error: The cached Python version isn't recognised. + remote: ! + remote: ! Your app doesn't specify a Python version, and so normally + remote: ! would use the version cached from the last build (3.99). + remote: ! + remote: ! However, Python 3.99 isn't recognised by this version + remote: ! of the buildpack. + remote: ! + remote: ! This can occur if you have downgraded the version of the + remote: ! buildpack to an older version. + remote: ! + remote: ! Please switch back to a newer version of this buildpack: + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + remote: ! + remote: ! Alternatively, request an older Python version by creating + remote: ! a .python-version file in the root directory of your app, + remote: ! that contains a Python version like: + remote: ! 3.14 + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT - expect(app.run('python -V')).to include('Python 3.12.3') end end end end - context 'when runtime.txt contains python-3.6.15' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.6', allow_failure: true) } + context 'when .python-version contains Python 3.10' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.10') } - it 'aborts the build with an EOL message' do + it 'builds with Python 3.10 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 version specified in runtime.txt + remote: -----> Using Python 3.10 specified in .python-version + remote: + remote: ! Warning: Support for Python 3.10 is deprecated! remote: ! - remote: ! Python 3.6 reached upstream end-of-life on December 23rd, 2021, and is - remote: ! therefore no longer receiving security updates: + remote: ! Python 3.10 will reach its upstream end-of-life in October 2026, + remote: ! at which point it will no longer receive security updates: remote: ! https://devguide.python.org/versions/#supported-versions remote: ! - remote: ! As such, it is no longer supported by this buildpack. + remote: ! As such, support for Python 3.10 will be removed from this + remote: ! buildpack on 6th January 2027. + remote: ! + remote: ! Upgrade to a newer Python version as soon as possible, by + remote: ! changing the version in your .python-version file. + remote: ! + remote: ! For more information, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: + remote: -----> Installing Python #{LATEST_PYTHON_3_10} + remote: -----> Installing pip #{PIP_VERSION} and setuptools #{SETUPTOOLS_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 2)) + OUTPUT + expect(normalize_trailing_newlines(app.run('python -V'))).to eq("Python #{LATEST_PYTHON_3_10}\n") + expect($CHILD_STATUS.exitstatus).to eq(0) + end + end + end + + context 'when .python-version contains Python 3.11' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.11') } + + it_behaves_like 'builds with the requested Python version', '3.11', LATEST_PYTHON_3_11 + end + + context 'when .python-version contains Python 3.12' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.12') } + + it_behaves_like 'builds with the requested Python version', '3.12', LATEST_PYTHON_3_12 + end + + context 'when .python-version contains Python 3.13' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.13') } + + it_behaves_like 'builds with the requested Python version', '3.13', LATEST_PYTHON_3_13 + end + + context 'when .python-version contains Python 3.14' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.14') } + + it_behaves_like 'builds with the requested Python version', '3.14', LATEST_PYTHON_3_14 + end + + context 'when .python-version is misspelled' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_misspelled', allow_failure: true) } + + it 'aborts the build with a misspelled .python-version file message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Your .python-version file is spelled incorrectly. remote: ! - remote: ! Please upgrade to a newer Python version. + remote: ! Your app's .python-version file currently has the filename: + remote: ! '.python-version ' remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: ! However, the correct spelling is (without quotes): + remote: ! '.python-version' remote: ! + remote: ! You must rename your file to the correct name. + 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) } + 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 EOL 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 version specified in runtime.txt + remote: + remote: ! Error: Invalid Python version in .python-version. remote: ! - remote: ! Python 3.7 reached upstream end-of-life on June 27th, 2023, and is - remote: ! therefore no longer receiving security updates: - remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! The Python version specified in your .python-version file + remote: ! isn't in the correct format. remote: ! - remote: ! As such, it is no longer supported by this buildpack. + remote: ! The following version was found: + remote: ! ���python - ��3.12.0�[0m remote: ! - remote: ! Please upgrade to a newer Python version. + remote: ! However, the Python version must be specified as either: + remote: ! 1. The major version only, for example: #{DEFAULT_PYTHON_MAJOR_VERSION} (recommended) + remote: ! 2. An exact patch version, for example: #{DEFAULT_PYTHON_MAJOR_VERSION}.999 remote: ! - remote: ! For a list of the supported Python versions, see: - remote: ! https://devcenter.heroku.com/articles/python-support#supported-runtimes + remote: ! Don't include quotes, a 'python-' prefix or wildcards. Any + remote: ! code comments must be on a separate line prefixed with '#'. remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update your .python-version file so it contains exactly: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: + remote: ! Push rejected, failed to compile Python app. OUTPUT end end end - context 'when runtime.txt contains python-3.8.19' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.8', allow_failure:) } + context 'when .python-version is saved using an unsupported file encoding' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_unsupported_encoding', allow_failure: true) } - context 'when using Heroku-20', stacks: %w[heroku-20] do - it 'builds with Python 3.8.19 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 version specified in runtime.txt - 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 SQLite3 - remote: -----> Installing requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) - OUTPUT - expect(app.run('python -V')).to include("Python #{LATEST_PYTHON_3_8}") - end + it 'aborts the build with an invalid file encoding message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Unable to read .python-version. + remote: ! + remote: ! Your .python-version file couldn't be read because it's using + remote: ! an unsupported text encoding: + remote: ! Unicode text, UTF-8 (with BOM) text, with CRLF line terminators + remote: ! + remote: ! Configure your editor to save files as UTF-8, without a BOM, + remote: ! then delete and recreate the file using the correct encoding. + remote: ! + remote: ! If that doesn't work, make sure you don't have a .gitattributes + remote: ! file that's overriding the text encoding. + remote: ! + remote: ! Note: On Windows, if you pipe or redirect output to a file + remote: ! it can result in the file being encoded in UTF-16 LE when + remote: ! using certain terminals and Windows settings. We recommend + remote: ! you create the file using a text editor instead. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT end end + end - context 'when using Heroku-22 or newer', stacks: %w[heroku-22 heroku-24] do - let(:allow_failure) { true } + 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) } - # We only support Python 3.8 on Heroku-20 and older. - include_examples 'aborts the build with a runtime not available message', "python-#{LATEST_PYTHON_3_8}" + 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: No Python version found in .python-version. + remote: ! + remote: ! No Python version was found in your .python-version file. + remote: ! + remote: ! Update the file so that it contains your app's major Python + remote: ! version number. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update your .python-version file so it contains exactly: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + 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 runtime.txt contains python-3.9.19' do - let(:allow_failure) { false } - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.9', allow_failure:) } + context 'when .python-version contains multiple Python versions' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_multiple_versions', allow_failure: true) } - context 'when using Heroku-22 or older', stacks: %w[heroku-20 heroku-22] do - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_9 + 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: Multiple Python versions found in .python-version. + remote: ! + remote: ! Multiple versions were found in your .python-version file: + remote: ! + remote: ! 3.12 + remote: ! 2.7 + remote: ! + remote: ! Update the file so it contains only one Python version. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update your .python-version file so it contains exactly: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end end + end - context 'when using Heroku-24', stacks: %w[heroku-24] do - let(:allow_failure) { true } + 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) } - # We only support Python 3.9 on Heroku-22 and older. - include_examples 'aborts the build with a runtime not available message', "python-#{LATEST_PYTHON_3_9}" + 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.9 specified in .python-version + remote: + remote: ! Error: The requested Python version has reached end-of-life. + remote: ! + remote: ! Python 3.9 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's no longer supported by this buildpack: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: ! + remote: ! Please upgrade to at least Python 3.10 by changing the + remote: ! version in your .python-version file. + remote: ! + remote: ! If possible, we recommend upgrading all the way to Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! since it contains many performance and usability improvements. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end end end - context 'when runtime.txt contains python-3.10.14' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.10') } + context 'when .python-version contains a non-existent Python major version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_non_existent_major', allow_failure: true) } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_10 + it 'aborts the build with an unrecognised 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 specified in .python-version + remote: + remote: ! Error: The requested Python version isn't recognised. + remote: ! + 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: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: ! + remote: ! If it has, make sure that you are using the latest version + remote: ! of this buildpack, and haven't pinned to an older release: + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + remote: ! + remote: ! Otherwise, switch to a supported version (such as Python 3.14) + remote: ! by changing the version in your .python-version file. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end end - context 'when runtime.txt contains python-3.11.9' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.11') } + 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) } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_11 + it 'aborts the build with a version not available message' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python 3.12.999 specified in .python-version + remote: -----> Installing Python 3.12.999 + remote: curl: \\(22\\) The requested URL returned error: .+ + remote: zstd: /\\*stdin\\*\\\\: unexpected end of file + remote: tar: Child returned status 1 + remote: tar: Error is not recoverable: exiting now + remote: + remote: ! Error: The requested Python version isn't available. + remote: ! + remote: ! Your app's .python-version file specifies a Python version + remote: ! of 3.12.999, however, we couldn't find that version on S3. + remote: ! + remote: ! Check that this Python version has been released upstream, + remote: ! and that the Python buildpack has added support for it: + remote: ! https://www.python.org/downloads/ + remote: ! https://github.com/heroku/heroku-buildpack-python/blob/main/CHANGELOG.md + remote: ! + remote: ! If it has, make sure that you are using the latest version + remote: ! of this buildpack, and haven't pinned to an older release: + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + remote: ! https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + remote: ! + remote: ! We also strongly recommend that you don't pin your app to an + remote: ! exact Python version such as 3.12.999, and instead only specify + remote: ! the major Python version of 3.12 in your .python-version file. + remote: ! This will allow your app to receive the latest available Python + remote: ! patch version automatically, and prevent this type of error. + remote: + remote: ! Push rejected, failed to compile Python app. + REGEX + end + end end - context 'when runtime.txt contains python-3.12.4' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.12') } + context 'when .python-version contains an outdated Python patch version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_outdated') } - include_examples 'builds with the requested Python version', LATEST_PYTHON_3_12 + 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.14.0 specified in .python-version + remote: + remote: ! Warning: A Python patch update is available! + remote: ! + remote: ! Your app is using Python 3.14.0, however, there is a newer + remote: ! patch release of Python 3.14 available: #{LATEST_PYTHON_3_14} + remote: ! + remote: ! It is important to always use the latest patch version of + remote: ! Python to keep your app secure. + remote: ! + remote: ! Update your .python-version file to use the new version. + remote: ! + remote: ! We strongly recommend that you don't pin your app to an + remote: ! exact Python version such as 3.14.0, and instead only specify + remote: ! the major Python version of 3.14 in your .python-version file. + remote: ! This will allow your app to receive the latest available Python + remote: ! patch version automatically and prevent this warning. + remote: + remote: -----> Installing Python 3.14.0 + remote: -----> Installing pip #{PIP_VERSION} + 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: Invalid Python version in runtime.txt. + remote: ! + remote: ! The Python version specified in your runtime.txt file isn't + remote: ! in the correct format. + remote: ! + remote: ! However, the runtime.txt file is deprecated since it has been + remote: ! replaced by the more widely supported .python-version file: + remote: ! https://devcenter.heroku.com/changelog-items/3141 + remote: ! + remote: ! As such, we recommend that you switch to using .python-version + remote: ! instead of fixing your runtime.txt file. + remote: ! + remote: ! Delete your runtime.txt file and create a new file in the + remote: ! root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! update your .python-version file so it contains exactly: + remote: ! #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + 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/python_version_invalid', allow_failure: true) } + 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) } - include_examples 'aborts the build with a runtime not available message', 'python-3.12.0invalid' + 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's no longer supported by this buildpack: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: ! + remote: ! Please upgrade to at least Python 3.10 by changing the + remote: ! version in your runtime.txt file. + remote: ! + remote: ! If possible, we recommend upgrading all the way to Python #{DEFAULT_PYTHON_MAJOR_VERSION}, + remote: ! since it contains many performance and usability improvements. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + 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: + remote: ! Warning: The runtime.txt file is deprecated. + remote: ! + remote: ! The runtime.txt file is deprecated since it has been replaced + remote: ! by the more widely supported .python-version file: + remote: ! https://devcenter.heroku.com/changelog-items/3141 + remote: ! + remote: ! Please switch to using a .python-version file instead. + remote: ! + remote: ! Delete your runtime.txt file and create a new file in the + remote: ! root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. + remote: ! + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. + remote: ! + remote: ! For example, to request the latest version of Python 3.13, + remote: ! update your .python-version file so it contains exactly: + remote: ! 3.13 + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! In the future support for runtime.txt will be removed and + remote: ! this warning will be made an error. + remote: + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + OUTPUT + end + end end context 'when the requested Python version has changed since the last build' do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.11') } + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.10') } 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 version specified in runtime.txt - remote: -----> Python version has changed from python-#{LATEST_PYTHON_3_11} to python-#{LATEST_PYTHON_3_12}, 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 requirements with pip - remote: Collecting urllib3 (from -r requirements.txt (line 1)) + remote: -----> Using Python 3.13 specified in .python-version + remote: -----> Discarding cache since: + remote: - The Python version has changed from #{LATEST_PYTHON_3_10} to #{LATEST_PYTHON_3_13} + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 2)) OUTPUT end end diff --git a/spec/hatchet/stack_spec.rb b/spec/hatchet/stack_spec.rb index 35f05dfbf..0aebe7179 100644 --- a/spec/hatchet/stack_spec.rb +++ b/spec/hatchet/stack_spec.rb @@ -3,63 +3,98 @@ require_relative '../spec_helper' RSpec.describe 'Stack changes' do - context 'when the stack is upgraded from Heroku-22 to Heroku-24', stacks: %w[heroku-22] do - # This test performs an initial build using an older buildpack version, followed - # by a build using the current version. This ensures that the current buildpack - # can successfully read the stack metadata written to the build cache in the past. - # The buildpack version chosen is one which had an older default Python version, so - # we can also prove that clearing the cache didn't lose the Python version metadata. - let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v250'] } + context 'when the stack is upgraded from Heroku-24 to Heroku-26', stacks: %w[heroku-24] do + # This test performs an initial build using an older buildpack version, followed by a build + # using the current version. This ensures that the current buildpack can successfully read + # the stack metadata written to the build cache in the past. The buildpack version chosen is + # the oldest to support Heroku-24, and which had an older default Python version so we can + # also prove that clearing the cache didn't lose the sticky Python version metadata. + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#archive/v250'] } let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified', buildpacks:) } it 'clears the cache before installing again whilst preserving the sticky Python version' do app.deploy do |app| - expect(app.output).to include('Building on the Heroku-22 stack') - app.update_stack('heroku-24') + expect(app.output).to include('Building on the Heroku-24 stack') + app.update_stack('heroku-26') update_buildpacks(app, [:default]) app.commit! app.push! - # TODO: The requirements output shouldn't say "installing from cache", since it's not. 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-3.12.3 - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes + remote: -----> No Python version was specified. Using the same major version as the last build: Python 3.12 + remote: + remote: ! Warning: No Python version was specified. + remote: ! + remote: ! Your app doesn't specify a Python version and so the buildpack + remote: ! picked a default version for you. + remote: ! + remote: ! Relying on this default version isn't recommended, since it + remote: ! can change over time and may not be consistent with your local + remote: ! development environment, CI or other instances of your app. + remote: ! + remote: ! Please configure an explicit Python version for your app. + remote: ! + remote: ! Create a new file in the root directory of your app named: + remote: ! .python-version + remote: ! + remote: ! Make sure to include the '.' character at the start of the + remote: ! filename. Don't add a file extension such as '.txt'. remote: ! - remote: ! A Python security update is available! Upgrade as soon as possible to: python-#{LATEST_PYTHON_3_12} - remote: ! See: https://devcenter.heroku.com/articles/python-runtimes + remote: ! In the new file, specify your app's major Python version number + remote: ! only. Don't include quotes or a 'python-' prefix. remote: ! - remote: -----> Stack has changed from heroku-22 to heroku-24, clearing cache - remote: -----> No change in requirements detected, installing from cache - remote: -----> Installing python-3.12.3 - 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: ! For example, to request the latest version of Python 3.12, + remote: ! update your .python-version file so it contains exactly: + remote: ! 3.12 + remote: ! + remote: ! We strongly recommend that you don't specify the Python patch + remote: ! version number, since it will pin your app to an exact Python + remote: ! version and so stop your app from receiving security updates + remote: ! each time it builds. + remote: ! + remote: ! If your app already has a .python-version file, check that it: + remote: ! + remote: ! 1. Is in the top level directory (not a subdirectory). + remote: ! 2. Is named exactly '.python-version' in all lowercase. + remote: ! 3. Isn't listed in '.gitignore' or '.slugignore'. + remote: ! 4. Has been added to the Git repository using 'git add --all' + remote: ! and then committed using 'git commit'. + remote: ! + remote: ! In the future we will require the use of a .python-version + remote: ! file and this warning will be made an error. + remote: + remote: -----> Discarding cache since: + remote: - The stack has changed from heroku-24 to heroku-26 + remote: - The Python version has changed from 3.12.3 to #{LATEST_PYTHON_3_12} + remote: - The buildpack cache format has changed + remote: - The legacy SQLite3 headers and CLI binary need to be uninstalled + remote: -----> Installing Python #{LATEST_PYTHON_3_12} + remote: -----> Installing pip #{PIP_VERSION} and setuptools #{SETUPTOOLS_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 2)) OUTPUT end end end - context 'when the stack is downgraded from Heroku-24 to Heroku-22', stacks: %w[heroku-24] do - let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified') } + context 'when the stack is downgraded from Heroku-26 to Heroku-24', stacks: %w[heroku-26] do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.14') } it 'clears the cache before installing again' do app.deploy do |app| - expect(app.output).to include('Building on the Heroku-24 stack') - app.update_stack('heroku-22') + expect(app.output).to include('Building on the Heroku-26 stack') + app.update_stack('heroku-24') app.commit! 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_VERSION} - remote: To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes - remote: -----> Stack has changed from heroku-24 to heroku-22, clearing cache - remote: -----> No change in requirements detected, installing from cache - remote: -----> Installing python-#{DEFAULT_PYTHON_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: -----> Using Python 3.14 specified in .python-version + remote: -----> Discarding cache since: + remote: - The stack has changed from heroku-26 to heroku-24 + remote: -----> Installing Python #{LATEST_PYTHON_3_14} + remote: -----> Installing pip #{PIP_VERSION} + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: Collecting typing-extensions==4.15.0 (from -r requirements.txt (line 2)) OUTPUT end end diff --git a/spec/hatchet/uv_spec.rb b/spec/hatchet/uv_spec.rb new file mode 100644 index 000000000..a3b3e2f6f --- /dev/null +++ b/spec/hatchet/uv_spec.rb @@ -0,0 +1,479 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'uv support' do + context 'with a uv.lock' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_basic', buildpacks:) } + + it 'installs successfully using uv and on rebuilds uses the cache' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing uv #{UV_VERSION} + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved .+ packages in .+s + remote: Prepared 1 package in .+s + remote: Installed 1 package in .+s + remote: Bytecode compiled 1 file in .+s + remote: \\+ typing-extensions==4.15.0 + remote: -----> Running bin/post_compile hook + remote: BUILD_DIR=/tmp/build_.+ + remote: CACHE_DIR=/tmp/codon/tmp/cache + remote: C_INCLUDE_PATH=/app/.heroku/python/include + remote: CPLUS_INCLUDE_PATH=/app/.heroku/python/include + remote: ENV_DIR=/tmp/.+ + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/tmp/codon/tmp/cache/.heroku/python-uv:/app/.heroku/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + remote: PYTHONUNBUFFERED=1 + remote: UV_CACHE_DIR=/tmp/uv-cache + remote: UV_LINK_MODE=hardlink + remote: UV_NO_MANAGED_PYTHON=1 + remote: UV_PROJECT_ENVIRONMENT=/app/.heroku/python + remote: UV_PYTHON_DOWNLOADS=never + remote: -----> Saving cache + remote: -----> Inline app detected + remote: LANG=en_US.UTF-8 + remote: LD_LIBRARY_PATH=/app/.heroku/python/lib + remote: LIBRARY_PATH=/app/.heroku/python/lib + remote: PATH=/app/.heroku/python/bin:/tmp/codon/tmp/cache/.heroku/python-uv:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + remote: PYTHONHOME=/app/.heroku/python + remote: PYTHONPATH=/app + remote: PYTHONUNBUFFERED=true + remote: UV_CACHE_DIR=/tmp/uv-cache + remote: UV_LINK_MODE=hardlink + remote: UV_NO_MANAGED_PYTHON=1 + remote: UV_PROJECT_ENVIRONMENT=/app/.heroku/python + remote: UV_PYTHON_DOWNLOADS=never + remote: + remote: \\['', + remote: '/app', + remote: '/app/.heroku/python/lib/python314.zip', + remote: '/app/.heroku/python/lib/python3.14', + remote: '/app/.heroku/python/lib/python3.14/lib-dynload', + remote: '/app/.heroku/python/lib/python3.14/site-packages'\\] + remote: + remote: uv #{UV_VERSION} \\(x86_64-unknown-linux-gnu\\) + remote: Using Python #{DEFAULT_PYTHON_FULL_VERSION} environment at: /app/.heroku/python + remote: Package Version + remote: ----------------- ------- + remote: typing-extensions 4.15.0 + remote: + remote: + remote: + remote: \\{ + remote: "cache_restore_duration": [0-9.]+, + remote: "cache_save_duration": [0-9.]+, + remote: "cache_status": "empty", + remote: "dependencies_install_duration": [0-9.]+, + remote: "django_collectstatic_duration": [0-9.]+, + remote: "nltk_downloader_duration": [0-9.]+, + remote: "package_manager": "uv", + remote: "package_manager_install_duration": [0-9.]+, + remote: "post_compile_hook": true, + remote: "post_compile_hook_duration": [0-9.]+, + remote: "pre_compile_hook": false, + remote: "python_install_duration": [0-9.]+, + remote: "python_version": "#{DEFAULT_PYTHON_FULL_VERSION}", + remote: "python_version_major": "3.14", + remote: "python_version_origin": ".python-version", + remote: "python_version_outdated": false, + remote: "python_version_pinned": false, + remote: "python_version_requested": "3.14", + remote: "total_duration": [0-9.]+, + remote: "uv_version": "#{UV_VERSION}" + remote: \\} + REGEX + + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Restoring cache + remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Using cached uv #{UV_VERSION} + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved .+ packages in .+s + remote: Bytecode compiled 1 file in .+s + remote: -----> Running bin/post_compile hook + remote: .+ + remote: -----> Saving cache + remote: -----> Inline app detected + REGEX + + command = 'bin/print-env-vars.sh && if command -v uv; then echo "uv unexpectedly found!" && exit 1; fi' + expect(normalize_trailing_newlines(app.run(command))).to eq(<<~OUTPUT) + DYNO_RAM=512 + FORWARDED_ALLOW_IPS=* + GUNICORN_CMD_ARGS=--access-logfile - + LANG=en_US.UTF-8 + LD_LIBRARY_PATH=/app/.heroku/python/lib + LIBRARY_PATH=/app/.heroku/python/lib + PATH=/app/.heroku/python/bin:/usr/local/bin:/usr/bin:/bin + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=2 + WEB_CONCURRENCY_SET_BY=heroku/python + OUTPUT + expect($CHILD_STATUS.exitstatus).to eq(0) + end + end + end + + # TODO: Enable on Heroku-26 after the uv and default Python versions next change, + # since for now there isn't a historic buildpack version we can use in this test + # whose stack check permits Heroku-26 and that also uses older uv/Python versions. + context 'when the uv and Python versions have changed since the last build', stacks: %w[heroku-22 heroku-24] do + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v313'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_basic', buildpacks:) } + + it 'clears the cache before installing' do + app.deploy do |app| + update_buildpacks(app, [:default]) + FileUtils.rm('bin/post_compile') + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python 3.14 specified in .python-version + remote: -----> Discarding cache since: + remote: - The Python version has changed from 3.14.0 to #{LATEST_PYTHON_3_14} + remote: - The uv version has changed from 0.8.23 to #{UV_VERSION} + remote: -----> Installing Python #{LATEST_PYTHON_3_14} + remote: -----> Installing uv #{UV_VERSION} + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved .+ packages in .+s + remote: Prepared 1 package in .+s + remote: Installed 1 package in .+s + remote: Bytecode compiled 1 file in .+s + remote: \\+ typing-extensions==4.15.0 + remote: -----> Saving cache + remote: -----> Discovering process types + REGEX + end + end + end + + # uv doesn't support editable mode with VCS dependencies, so unlike the editable tests for the other + # package managers the gunicorn dependency isn't editable. However, we still include it to ensure we + # have VCS coverage. See: https://github.com/astral-sh/uv/issues/5442 + context 'when uv.lock contains editable requirements and a VCS dependency' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_editable', buildpacks:) } + + it 'rewrites .pth 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)) + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved 5 packages in .+s + remote: .+ + remote: Prepared 5 packages in .+s + remote: Installed 5 packages in .+s + remote: Bytecode compiled .+ files in .+s + remote: \\+ gunicorn==23.0.0 \\(from git\\+https://github.com/benoitc/gunicorn@56b5ad87f8d72a674145c273ed8f547513c2b409\\) + remote: \\+ local-package-pyproject-toml==0.0.1 \\(from file:///tmp/build_.+/packages/local_package_pyproject_toml\\) + remote: \\+ local-package-setup-py==0.0.1 \\(from file:///tmp/build_.+/packages/local_package_setup_py\\) + remote: \\+ packaging==25.0 + remote: \\+ uv-editable==0.0.0 \\(from file:///tmp/build_.+\\) + remote: -----> Running bin/post_compile hook + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: uv_editable.pth:/tmp/build_.+/src + remote: + remote: Running entrypoint for the current package: Hello from uv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + remote: -----> Inline app detected + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: uv_editable.pth:/tmp/build_.+/src + remote: + remote: Running entrypoint for the current package: Hello from uv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + REGEX + + # Test rewritten paths work at runtime. + expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT) + __editable___local_package_pyproject_toml_0_0_1_finder.py:/app/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + __editable___local_package_setup_py_0_0_1_finder.py:/app/packages/local_package_setup_py/local_package_setup_py'} + uv_editable.pth:/app/src + + Running entrypoint for the current package: Hello from uv-editable! + Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + Running entrypoint for the setup.py-based local package: Hello from setup.py! + Running entrypoint for the VCS package: gunicorn (version 23.0.0) + OUTPUT + + # Test that the cached .pth files work correctly. + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX, Regexp::MULTILINE)) + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved 5 packages in .+ + remote: .+ + remote: Prepared 3 packages in .+s + remote: Uninstalled 3 packages in .+s + remote: Installed 3 packages in .+s + remote: Bytecode compiled .+ files in .+s + remote: - local-package-pyproject-toml==0.0.1 \\(from file:///tmp/build_.+/packages/local_package_pyproject_toml\\) + remote: \\+ local-package-pyproject-toml==0.0.1 \\(from file:///tmp/build_.+/packages/local_package_pyproject_toml\\) + remote: - local-package-setup-py==0.0.1 \\(from file:///tmp/build_.+/packages/local_package_setup_py\\) + remote: \\+ local-package-setup-py==0.0.1 \\(from file:///tmp/build_.+/packages/local_package_setup_py\\) + remote: - uv-editable==0.0.0 \\(from file:///tmp/build_.+\\) + remote: \\+ uv-editable==0.0.0 \\(from file:///tmp/build_.+\\) + remote: -----> Running bin/post_compile hook + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: uv_editable.pth:/tmp/build_.+/src + remote: + remote: Running entrypoint for the current package: Hello from uv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + remote: -----> Saving cache + remote: -----> Inline app detected + remote: __editable___local_package_pyproject_toml_0_0_1_finder.py:/tmp/build_.+/packages/local_package_pyproject_toml/local_package_pyproject_toml'} + remote: __editable___local_package_setup_py_0_0_1_finder.py:/tmp/build_.+/packages/local_package_setup_py/local_package_setup_py'} + remote: uv_editable.pth:/tmp/build_.+/src + remote: + remote: Running entrypoint for the current package: Hello from uv-editable! + remote: Running entrypoint for the pyproject.toml-based local package: Hello from pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello from setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 23.0.0\\) + REGEX + end + end + end + + context 'when using our oldest supported Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_oldest_python') } + + it 'installs successfully' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python 3.10.0 specified in .python-version + remote: + remote: ! Warning: Support for Python 3.10 is deprecated! + remote: ! + remote: ! Python 3.10 will reach its upstream end-of-life in October 2026, + remote: ! at which point it will no longer receive security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, support for Python 3.10 will be removed from this + remote: ! buildpack on 6th January 2027. + remote: ! + remote: ! Upgrade to a newer Python version as soon as possible, by + remote: ! changing the version in your .python-version file. + remote: ! + remote: ! For more information, see: + remote: ! https://devcenter.heroku.com/articles/python-support#supported-python-versions + remote: + remote: + remote: ! Warning: A Python patch update is available! + remote: ! + remote: ! Your app is using Python 3.10.0, however, there is a newer + remote: ! patch release of Python 3.10 available: #{LATEST_PYTHON_3_10} + remote: ! + remote: ! It is important to always use the latest patch version of + remote: ! Python to keep your app secure. + remote: ! + remote: ! Update your .python-version file to use the new version. + remote: ! + remote: ! We strongly recommend that you don't pin your app to an + remote: ! exact Python version such as 3.10.0, and instead only specify + remote: ! the major Python version of 3.10 in your .python-version file. + remote: ! This will allow your app to receive the latest available Python + remote: ! patch version automatically and prevent this warning. + remote: + remote: -----> Installing Python 3.10.0 + remote: -----> Installing uv #{UV_VERSION} + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved 2 packages in .+s + remote: Prepared 1 package in .+s + remote: Installed 1 package in .+s + remote: Bytecode compiled 1 file in .+s + remote: \\+ typing-extensions==4.15.0 + remote: -----> Saving cache + REGEX + end + end + end + + # This tests the error message when there is no .python-version file, and in particular the case where + # the buildpack's default Python version is not compatible with `requires-python` in pyproject.toml. + # (Since we must prevent uv from downloading its own Python or using system Python, and also + # want a clearer error message than using `--python` or `UV_PYTHON` would give us). + context 'when there is no .python-version file' do + context 'when there is no cached Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_no_python_version_file', allow_failure: true) } + + it 'fails the build with .python-version instructions' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Error: No Python version was specified. + remote: ! + remote: ! When using the package manager uv on Heroku, you must specify + remote: ! your app's Python version with a .python-version file. + remote: ! + remote: ! To add a .python-version file: + remote: ! + remote: ! 1. Make sure you are in the root directory of your app + remote: ! and not a subdirectory. + remote: ! 2. Run 'uv python pin #{DEFAULT_PYTHON_MAJOR_VERSION}' + remote: ! (adjust to match your app's major Python version). + remote: ! 3. Commit the changes to your Git repository using + remote: ! 'git add --all' and then 'git commit'. + remote: ! + remote: ! Note: We strongly recommend that you don't specify the Python + remote: ! patch version number in your .python-version file, since it will + remote: ! pin your app to an exact Python version and so stop your app from + remote: ! receiving security updates each time it builds. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + context 'when there is a cached Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified', allow_failure: true) } + + it 'fails the build with .python-version instructions' do + app.deploy do |app| + FileUtils.rm('requirements.txt') + FileUtils.cp(FIXTURE_DIR.join('uv_no_python_version_file/pyproject.toml'), '.') + FileUtils.cp(FIXTURE_DIR.join('uv_no_python_version_file/uv.lock'), '.') + app.commit! + app.push! + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> No Python version was specified. Using the same major version as the last build: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + remote: ! Error: No Python version was specified. + remote: ! + remote: ! When using the package manager uv on Heroku, you must specify + remote: ! your app's Python version with a .python-version file. + remote: ! + remote: ! To add a .python-version file: + remote: ! + remote: ! 1. Make sure you are in the root directory of your app + remote: ! and not a subdirectory. + remote: ! 2. Run 'uv python pin #{DEFAULT_PYTHON_MAJOR_VERSION}' + remote: ! (adjust to match your app's major Python version). + remote: ! 3. Commit the changes to your Git repository using + remote: ! 'git add --all' and then 'git commit'. + remote: ! + remote: ! Note: We strongly recommend that you don't specify the Python + remote: ! patch version number in your .python-version file, since it will + remote: ! pin your app to an exact Python version and so stop your app from + remote: ! receiving security updates each time it builds. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + end + + # This tests the error message when a runtime.txt is present, and in particular the case where + # the runtime.txt version is not compatible with `requires-python` in pyproject.toml. + # (Since we must prevent uv from downloading its own Python or using system Python, and also + # want a clearer error message than using `--python` or `UV_PYTHON` would give us). + context 'when there is a runtime.txt file' do + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_runtime_txt', allow_failure: true) } + + it 'fails the build with runtime.txt migration instructions' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Using Python 3.11 specified in runtime.txt + remote: + remote: ! Error: The runtime.txt file isn't supported when using uv. + remote: ! + remote: ! When using the package manager uv on Heroku, you must specify + remote: ! your app's Python version with a .python-version file and not + remote: ! a runtime.txt file. + remote: ! + remote: ! To switch to a .python-version file: + remote: ! + remote: ! 1. Make sure you are in the root directory of your app + remote: ! and not a subdirectory. + remote: ! 2. Delete your runtime.txt file. + remote: ! 3. Run 'uv python pin 3.11' + remote: ! (adjust to match your app's major Python version). + remote: ! 4. Commit the changes to your Git repository using + remote: ! 'git add --all' and then 'git commit'. + remote: ! + remote: ! Note: We strongly recommend that you don't specify the Python + remote: ! patch version number in your .python-version file, since it will + remote: ! pin your app to an exact Python version and so stop your app from + remote: ! receiving security updates each time it builds. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + # This tests the error message when `requires-python` in pyproject.toml isn't compatible with + # the version in .python-version. This might seem unnecessary since it's testing something uv + # validates itself, however, the quality of the error message here depends on what uv options + # we use (for example, using `--python` or `UV_PYTHON` results in a worse error message). + context 'when requires-python in pyproject.toml is incompatible with .python-version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_mismatched_python_version', allow_failure: true) } + + it 'fails the build' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Using CPython #{LATEST_PYTHON_3_13} interpreter at: /app/.heroku/python/bin/python3.13 + remote: error: The Python request from `.python-version` resolved to Python #{LATEST_PYTHON_3_13}, which is incompatible with the project's Python requirement: `==3.12.*` (from `project.requires-python`) + remote: Use `uv python pin` to update the `.python-version` file to a compatible version + remote: + remote: ! Error: Unable to install dependencies using uv. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. + OUTPUT + end + end + end + + # This tests not only our handling of failing dependency installation, but also that we're running + # uv in such a way that it errors if the lockfile is out of sync, rather than simply updating it. + context 'when uv.lock is out of sync with pyproject.toml' do + let(:app) { Hatchet::Runner.new('spec/fixtures/uv_lockfile_out_of_sync', allow_failure: true) } + + it 'fails the build' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Installing dependencies using 'uv sync --locked --no-default-groups' + remote: Resolved 2 packages in .+s + remote: error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + remote: + remote: hint: To update the lockfile, run `uv lock`. + remote: + remote: ! Error: Unable to install dependencies using uv. + remote: ! + remote: ! See the log output above for more information. + remote: + remote: ! Push rejected, failed to compile Python app. + REGEX + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3efda0c20..fa6deb277 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,18 +1,22 @@ # frozen_string_literal: true ENV['HATCHET_BUILDPACK_BASE'] ||= 'https://github.com/heroku/heroku-buildpack-python.git' -ENV['HATCHET_DEFAULT_STACK'] ||= 'heroku-24' +ENV['HATCHET_DEFAULT_STACK'] ||= 'heroku-26' +require 'English' # for $CHILD_STATUS require 'rspec/core' require 'rspec/retry' require 'hatchet' -LATEST_PYTHON_3_8 = '3.8.19' -LATEST_PYTHON_3_9 = '3.9.19' -LATEST_PYTHON_3_10 = '3.10.14' -LATEST_PYTHON_3_11 = '3.11.9' -LATEST_PYTHON_3_12 = '3.12.4' -DEFAULT_PYTHON_VERSION = LATEST_PYTHON_3_12 +FIXTURE_DIR = Pathname.new(__FILE__).parent.join('fixtures') + +LATEST_PYTHON_3_10 = '3.10.20' +LATEST_PYTHON_3_11 = '3.11.15' +LATEST_PYTHON_3_12 = '3.12.13' +LATEST_PYTHON_3_13 = '3.13.14' +LATEST_PYTHON_3_14 = '3.14.6' +DEFAULT_PYTHON_FULL_VERSION = LATEST_PYTHON_3_14 +DEFAULT_PYTHON_MAJOR_VERSION = DEFAULT_PYTHON_FULL_VERSION.gsub(/\.\d+$/, '') # The requirement versions are effectively buildpack constants, however, we want # Dependabot to be able to update them, which requires that they be in requirements @@ -24,9 +28,12 @@ def get_requirement_version(package_name) end PIP_VERSION = get_requirement_version('pip') -SETUPTOOLS_VERSION = get_requirement_version('setuptools') -WHEEL_VERSION = get_requirement_version('wheel') PIPENV_VERSION = get_requirement_version('pipenv') +POETRY_VERSION = get_requirement_version('poetry') +UV_VERSION = get_requirement_version('uv') +# This is hardcoded rather than managed by Dependabot since we can't update to newer versions due to: +# https://github.com/heroku/heroku-buildpack-python/pull/1630#issuecomment-2324236653 +SETUPTOOLS_VERSION = '70.3.0' # Work around the return value for `default_buildpack` changing after deploy: # https://github.com/heroku/hatchet/issues/180 @@ -44,14 +51,23 @@ def get_requirement_version(package_name) config.filter_run_when_matching :focus # Allows declaring on which stacks a test/group should run by tagging it with `stacks`. config.filter_run_excluding stacks: ->(stacks) { !stacks.include?(ENV.fetch('HATCHET_DEFAULT_STACK')) } - # Make rspec-retry output a retry message when its had to retry a test. + # Make rspec-retry output a retry message when it's had to retry a test. config.verbose_retry = true end def clean_output(output) - # Remove trailing whitespace characters added by Git: - # https://github.com/heroku/hatchet/issues/162 - output.gsub(/ {8}(?=\R)/, '') + output + # Remove trailing whitespace characters added by Git: + # https://github.com/heroku/hatchet/issues/162 + .gsub(/ {8}(?=\R)/, '') + # Remove ANSI colour codes used in buildpack output (e.g. error messages). + .gsub(/\e\[[0-9;]+m/, '') +end + +def normalize_trailing_newlines(output) + # The platform log stream that captures `run`/`run_multi` command output occasionally appends + # an extra trailing newline, which otherwise intermittently breaks exact output assertions. + output.sub(/\n+\z/, "\n") end def update_buildpacks(app, buildpacks) diff --git a/vendor/WEB_CONCURRENCY.sh b/vendor/WEB_CONCURRENCY.sh index d6cc1a234..803b7217f 100755 --- a/vendor/WEB_CONCURRENCY.sh +++ b/vendor/WEB_CONCURRENCY.sh @@ -25,43 +25,43 @@ # exit on error, have to use return not exit, and returning non-zero doesn't have an effect. function detect_memory_limit_in_mb() { - local memory_limit_file='/sys/fs/cgroup/memory/memory.limit_in_bytes' + local memory_limit_file='/sys/fs/cgroup/memory/memory.limit_in_bytes' - # This memory limits file only exists on Heroku, or when using cgroups v1 (Docker < 20.10). - if [[ -f "${memory_limit_file}" ]]; then - local memory_limit_in_mb=$(($(cat "${memory_limit_file}") / 1048576)) + # This memory limits file only exists on Heroku, or when using cgroups v1 (Docker < 20.10). + if [[ -f "${memory_limit_file}" ]]; then + local memory_limit_in_mb=$(($(cat "${memory_limit_file}") / 1048576)) - # Ignore values above 1TB RAM, since when using cgroups v1 the limits file reports a - # bogus value of thousands of TB RAM when there is no container memory limit set. - if ((memory_limit_in_mb <= 1048576)); then - echo "${memory_limit_in_mb}" - return 0 - fi - fi + # Ignore values above 1TB RAM, since when using cgroups v1 the limits file reports a + # bogus value of thousands of TB RAM when there is no container memory limit set. + if ((memory_limit_in_mb <= 1048576)); then + echo "${memory_limit_in_mb}" + return 0 + fi + fi - return 1 + return 1 } function output() { - # Only display log output for web dynos, to prevent breaking one-off dyno scripting use-cases, - # and to prevent confusion from messages about WEB_CONCURRENCY in the logs of non-web workers. - # (We still actually set the env vars for all dyno types for consistency and easier debugging.) - if [[ "${DYNO:-}" == web.* ]]; then - echo "Python buildpack: $*" >&2 - fi + # Only display log output for web dynos, to prevent breaking one-off dyno scripting use-cases, + # and to prevent confusion from messages about WEB_CONCURRENCY in the logs of non-web workers. + # (We still actually set the env vars for all dyno types for consistency and easier debugging.) + if [[ "${DYNO:-}" == web.* ]]; then + echo "Python buildpack: $*" >&2 + fi } if ! available_memory_in_mb=$(detect_memory_limit_in_mb); then - # This should never occur on Heroku, but will be common for non-Heroku environments such as Dokku. - output "Couldn't determine available memory. Skipping automatic configuration of WEB_CONCURRENCY." - return 0 + # This should never occur on Heroku, but will be common for non-Heroku environments such as Dokku. + output "Couldn't determine available memory. Skipping automatic configuration of WEB_CONCURRENCY." + return 0 fi if ! cpu_cores=$(nproc); then - # This should never occur in practice, since this buildpack only supports being run on our base - # images, and nproc is installed in all of them. - output "Couldn't determine number of CPU cores. Skipping automatic configuration of WEB_CONCURRENCY." - return 0 + # This should never occur in practice, since this buildpack only supports being run on our base + # images, and nproc is installed in all of them. + output "Couldn't determine number of CPU cores. Skipping automatic configuration of WEB_CONCURRENCY." + return 0 fi output "Detected ${available_memory_in_mb} MB available memory and ${cpu_cores} CPU cores." @@ -71,25 +71,28 @@ output "Detected ${available_memory_in_mb} MB available memory and ${cpu_cores} export DYNO_RAM="${available_memory_in_mb}" if [[ -v WEB_CONCURRENCY ]]; then - output "Skipping automatic configuration of WEB_CONCURRENCY since it's already set." - return 0 + output "Skipping automatic configuration of WEB_CONCURRENCY since it's already set." + return 0 fi +# Make it possible to differentiate between user and buildpack set WEB_CONCURRENCY values. +export WEB_CONCURRENCY_SET_BY="heroku/python" + minimum_memory_per_process_in_mb=256 # Prevents WEB_CONCURRENCY being set to zero if the environment is extremely memory constrained. if ((available_memory_in_mb < minimum_memory_per_process_in_mb)); then - max_concurrency_for_available_memory=1 + max_concurrency_for_available_memory=1 else - max_concurrency_for_available_memory=$((available_memory_in_mb / minimum_memory_per_process_in_mb)) + max_concurrency_for_available_memory=$((available_memory_in_mb / minimum_memory_per_process_in_mb)) fi max_concurrency_for_cpu_cores=$((cpu_cores * 2 + 1)) if ((max_concurrency_for_available_memory < max_concurrency_for_cpu_cores)); then - export WEB_CONCURRENCY="${max_concurrency_for_available_memory}" - output "Defaulting WEB_CONCURRENCY to ${WEB_CONCURRENCY} based on the available memory." + export WEB_CONCURRENCY="${max_concurrency_for_available_memory}" + output "Defaulting WEB_CONCURRENCY to ${WEB_CONCURRENCY} based on the available memory." else - export WEB_CONCURRENCY="${max_concurrency_for_cpu_cores}" - output "Defaulting WEB_CONCURRENCY to ${WEB_CONCURRENCY} based on the number of CPU cores." + export WEB_CONCURRENCY="${max_concurrency_for_cpu_cores}" + output "Defaulting WEB_CONCURRENCY to ${WEB_CONCURRENCY} based on the number of CPU cores." fi diff --git a/vendor/buildpack-stdlib_v8.sh b/vendor/buildpack-stdlib_v8.sh index 69e0e7036..8f82fc152 100755 --- a/vendor/buildpack-stdlib_v8.sh +++ b/vendor/buildpack-stdlib_v8.sh @@ -1,114 +1,62 @@ #!/usr/bin/env bash +# shellcheck disable=SC2154 # TODO: Env var is referenced but not assigned. + +set -euo pipefail # Based on: # https://raw.githubusercontent.com/heroku/buildpack-stdlib/v8/stdlib.sh -# Standard Output -# --------------- - -# Buildpack Steps. -puts_step() { - if [[ "$*" == "-" ]]; then - read -r output - else - output=$* - fi - echo -e "\\e[1m\\e[36m=== $output\\e[0m" - unset output -} - -# Buildpack Error. -puts_error() { - if [[ "$*" == "-" ]]; then - read -r output - else - output=$* - fi - echo -e "\\e[1m\\e[31m=!= $output\\e[0m" -} - -# Buildpack Warning. -puts_warn() { - if [[ "$*" == "-" ]]; then - read -r output - else - output=$* - fi - echo -e "\\e[1m\\e[33m=!= $output\\e[0m" -} - -# Is verbose set? -is_verbose() { - if [[ -n $BUILDPACK_VERBOSE ]]; then - return 0 - else - return 1 - fi -} - -# Buildpack Verbose. -puts_verbose() { - if is_verbose; then - if [[ "$*" == "-" ]]; then - read -r output - else - output=$* - fi - echo "$output" - unset output - fi -} - # Buildpack Utilities # ------------------- # Usage: $ set-env key value # NOTICE: Expects PROFILE_PATH & EXPORT_PATH to be set! set_env() { - # TODO: automatically create profile path directory if it doesn't exist. - echo "export $1=$2" >> "$PROFILE_PATH" - echo "export $1=$2" >> "$EXPORT_PATH" + # TODO: automatically create profile path directory if it doesn't exist. + echo "export $1=$2" >>"${PROFILE_PATH}" + echo "export $1=$2" >>"${EXPORT_PATH}" } # Usage: $ set-default-env key value # NOTICE: Expects PROFILE_PATH & EXPORT_PATH to be set! set_default_env() { - echo "export $1=\${$1:-$2}" >> "$PROFILE_PATH" - echo "export $1=\${$1:-$2}" >> "$EXPORT_PATH" + echo "export $1=\${$1:-$2}" >>"${PROFILE_PATH}" + echo "export $1=\${$1:-$2}" >>"${EXPORT_PATH}" } # Usage: $ un-set-env key # NOTICE: Expects PROFILE_PATH to be set! un_set_env() { - echo "unset $1" >> "$PROFILE_PATH" + echo "unset $1" >>"${PROFILE_PATH}" } # Usage: $ _env-blacklist pattern # Outputs a regex of default blacklist env vars. _env_blacklist() { - local regex=${1:-''} - if [[ -n "$regex" ]]; then - regex="|$regex" - fi - echo "^(PATH|CPATH|CPPATH|LD_PRELOAD|LIBRARY_PATH|LD_LIBRARY_PATH|PYTHONHOME$regex)$" + local regex="${1:-}" + if [[ -n "${regex}" ]]; then + regex="|${regex}" + fi + echo "^(PATH|CPATH|CPPATH|LD_PRELOAD|LIBRARY_PATH|LD_LIBRARY_PATH|PYTHONHOME${regex})$" } # Usage: $ export-env ENV_DIR WHITELIST BLACKLIST # Exports the environment variables defined in the given directory. export_env() { - local env_dir=${1:-$ENV_DIR} - local whitelist=${2:-''} - local blacklist - blacklist="$(_env_blacklist "$3")" - if [[ -d "$env_dir" ]]; then - # Environment variable names won't contain characters affected by: - # shellcheck disable=SC2045 - for e in $(ls "$env_dir"); do - echo "$e" | grep -E "$whitelist" | grep -qvE "$blacklist" && - export "$e=$(cat "$env_dir/$e")" - : - done - fi + local env_dir="${1:-${ENV_DIR}}" + local whitelist="${2:-}" + local blacklist + blacklist="$(_env_blacklist "${3:-}")" + if [[ -d "${env_dir}" ]]; then + local e + # Environment variable names won't contain characters affected by: + # shellcheck disable=SC2045 + for e in $(ls "${env_dir}"); do + echo "${e}" | grep -E "${whitelist}" | grep -qvE "${blacklist}" \ + && export "${e}=$(cat "${env_dir}/${e}")" + : + done + fi } # Usage: $ sub-env command @@ -118,16 +66,10 @@ export_env() { # WHITELIST=${2:-''} # BLACKLIST=${3:-'^(GIT_DIR|PYTHONHOME|LD_LIBRARY_PATH|LIBRARY_PATH|PATH)$'} sub_env() { - ( - # TODO: Fix https://github.com/heroku/buildpack-stdlib/issues/37 - # shellcheck disable=SC2153 - export_env "$ENV_DIR" "$WHITELIST" "$BLACKLIST" - - "$@" - ) -} + ( + # TODO: Fix https://github.com/heroku/buildpack-stdlib/issues/37 + export_env "${ENV_DIR}" "${WHITELIST:-}" "${BLACKLIST:-}" -# Returns the current time, in milliseconds. -nowms() { - date +%s%3N + "${@}" + ) }