diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..0d78a23d9 --- /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 +# its 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 new file mode 100644 index 000000000..fa79aa89b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Default to requesting pull request reviews from the Heroku Languages team. +#ECCN:Open Source +#GUSINFO: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 +/requirements/ @edmorley diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..e18fe56a5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..04310cda2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,44 @@ +version: 2 +updates: + - package-ecosystem: "bundler" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "ruby" + - "skip changelog" + groups: + ruby-dependencies: + update-types: + - "minor" + - "patch" + - package-ecosystem: "docker" + directory: "/builds" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "docker" + - "skip changelog" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "github actions" + - "skip changelog" + - package-ecosystem: "pip" + directory: "/" + schedule: + # We set this to a more frequent interval than the above since uv is still under rapid + # iteration, and we'll generally want to be on a recent (if not the latest) version. + interval: "weekly" + ignore: + # We're not updating to setuptools v71+ due to its new approach to vendored dependencies: + # https://github.com/heroku/heroku-buildpack-python/pull/1630#issuecomment-2324236653 + - dependency-name: "setuptools" + labels: + - "dependencies" + - "python" diff --git a/.github/workflows/build_python_runtime.yml b/.github/workflows/build_python_runtime.yml new file mode 100644 index 000000000..fa87c1fbf --- /dev/null +++ b/.github/workflows/build_python_runtime.yml @@ -0,0 +1,82 @@ +name: Build and upload Python runtime +run-name: "Build and upload Python ${{ inputs.python_version }}${{ inputs.dry_run && ' (dry run)' || '' }}" + +on: + workflow_dispatch: + inputs: + python_version: + description: "Python version (eg: 3.13.0)" + type: string + required: true + stack: + description: "Stack(s)" + type: choice + options: + - auto + - heroku-22 + - heroku-24 + default: auto + required: false + dry_run: + description: "Skip uploading to S3 (dry run)" + type: boolean + default: false + required: false + +permissions: + contents: read + +env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: "us-east-1" + S3_BUCKET: "heroku-buildpack-python" + +# 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-22: + if: inputs.stack == 'heroku-22' || inputs.stack == 'auto' + runs-on: pub-hk-ubuntu-24.04-xlarge + env: + STACK_VERSION: "22" + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Build Docker image + run: docker build --platform="linux/amd64" --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' + 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: + 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: "24" + steps: + - name: Checkout + uses: actions/checkout@v6 + - 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 + 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-${{ 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}" diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml new file mode 100644 index 000000000..419c9fa6a --- /dev/null +++ b/.github/workflows/check_changelog.yml @@ -0,0 +1,20 @@ +name: Check Changelog + +on: + pull_request: + types: [opened, reopened, labeled, unlabeled, synchronize] + +permissions: + contents: read + +jobs: + check-changelog: + runs-on: ubuntu-24.04 + if: (!contains(github.event.pull_request.labels.*.name, 'skip changelog')) + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Check that CHANGELOG is touched + run: | + git fetch origin ${{ github.base_ref }} --depth 1 && \ + git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..f32322a11 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + # Avoid duplicate builds on PRs. + branches: + - main + pull_request: + +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@v6 + - name: Install Ruby and dependencies + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: "3.4" + - 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 + + integration-test: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + stack: ["heroku-22", "heroku-24"] + env: + 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: 90 + RSPEC_RETRY_RETRY_COUNT: 1 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Ruby and dependencies + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: "3.4" + - name: Hatchet setup + run: bundle exec hatchet ci:setup + - name: Run Hatchet integration tests + # parallel_split_test runs rspec in parallel, with concurrency equal to PARALLEL_SPLIT_TEST_PROCESSES. + run: bundle exec parallel_split_test spec/hatchet/ + + container-test: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + # 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_file_invalid_version/ COMPILE_FAILURE_EXIT_CODE=0 diff --git a/.github/workflows/hatchet_app_cleaner.yml b/.github/workflows/hatchet_app_cleaner.yml new file mode 100644 index 000000000..caf9388d9 --- /dev/null +++ b/.github/workflows/hatchet_app_cleaner.yml @@ -0,0 +1,31 @@ +name: Hatchet App Cleaner + +on: + schedule: + # Daily at 6am UTC. + - cron: "0 6 * * *" + # Allow the workflow to be manually triggered too. + workflow_dispatch: + +permissions: + contents: read + +jobs: + hatchet-app-cleaner: + runs-on: ubuntu-24.04 + env: + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + HEROKU_API_USER: ${{ secrets.HEROKU_API_USER }} + HEROKU_DISABLE_AUTOUPDATE: 1 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Ruby and dependencies + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: "3.4" + - name: Run Hatchet destroy + # Only apps older than 10 minutes are destroyed, to ensure that any + # in progress CI runs are not interrupted. + run: bundle exec hatchet destroy --older-than 10 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..c77602ea4 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,12 @@ +name: Prepare Release + +on: + workflow_dispatch: + +# Disable all GITHUB_TOKEN permissions, since the GitHub App token is used instead. +permissions: {} + +jobs: + prepare-release: + uses: heroku/languages-github-actions/.github/workflows/_classic-buildpack-prepare-release.yml@latest + secrets: inherit diff --git a/.gitignore b/.gitignore index 8d0d1105a..4cecc5fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ -*.pyc -site +__pycache__/ +.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`. +vendor/bundle/ +.DS_Store +.rspec_status diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..2edf1e039 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,34 @@ +plugins: + - rubocop-rspec + +AllCops: + NewCops: enable + +Layout/TrailingWhitespace: + # Required since we use heredocs to assert against Hatchet output, and sometimes that output + # contains trailing newlines which we must match against. The alternative is to end the lines + # with the unsightly `#{' '}` workaround. + AllowInHeredoc: true + +Metrics/BlockLength: + Enabled: false + +RSpec/DescribeClass: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/Focus: + # Disable auto-correct otherwise format-on-save will remove the annotation + # whilst developing locally. + AutoCorrect: false + +RSpec/MultipleExpectations: + Enabled: false + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: consistent_comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 000000000..da9f6a163 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +# Enable all checks, including the optional ones that are off by default. +enable=all diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..c08d57b1c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1619 @@ +# Changelog + +## [Unreleased] + +- 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 contain 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 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)) +- Updated pipenv from 2023.12.1 to 2024.0.1. ([#1601](https://github.com/heroku/heroku-buildpack-python/pull/1601)) + +## [v253] - 2024-07-01 + +- Improved the error messages shown when an app is missing the necessary Python package manager files. ([#1608](https://github.com/heroku/heroku-buildpack-python/pull/1608)) + +## [v252] - 2024-06-17 + +- Removed export of `Pipfile.lock` to `requirements.txt` during the build. ([#1593](https://github.com/heroku/heroku-buildpack-python/pull/1593)) +- Removed internal `pipenv-to-pip` script that was unintentionally exposed onto `PATH`. ([#1593](https://github.com/heroku/heroku-buildpack-python/pull/1593)) +- Stopped exposing the internal `BIN_DIR`, `BPLOG_PREFIX`, `EXPORT_PATH` and `PROFILE_PATH` environment variables to `bin/{pre,post}_compile` and other subprocesses. ([#1595](https://github.com/heroku/heroku-buildpack-python/pull/1595) and [#1597](https://github.com/heroku/heroku-buildpack-python/pull/1597)) +- Implemented the `bin/report` build report API and removed log based metrics. ([#1597](https://github.com/heroku/heroku-buildpack-python/pull/1597)) + +## [v251] - 2024-06-07 + +- Added support for Python 3.12.4. ([#1591](https://github.com/heroku/heroku-buildpack-python/pull/1591)) +- Changed the default Python version for new apps from 3.12.3 to 3.12.4. ([#1591](https://github.com/heroku/heroku-buildpack-python/pull/1591)) + +## [v250] - 2024-04-26 + +- Added support for Heroku-24. ([#1575](https://github.com/heroku/heroku-buildpack-python/pull/1575)) + +## [v249] - 2024-04-18 + +- Improved the error message shown for EOL Python versions when using a stack for which those versions were never built. ([#1570](https://github.com/heroku/heroku-buildpack-python/pull/1570)) +- Fixed the "Python security update is available" warning being shown when the requested version is newer than the latest version known to the buildpack. ([#1569](https://github.com/heroku/heroku-buildpack-python/pull/1569)) +- Fixed glibc warnings seen when downgrading the stack version. ([#1568](https://github.com/heroku/heroku-buildpack-python/pull/1568)) +- Changed compression format and S3 URL for Python runtime archives. ([#1567](https://github.com/heroku/heroku-buildpack-python/pull/1567)) +- Adjusted compiler options used to build Python for improved parity with the Docker Hub Python images. ([#1566](https://github.com/heroku/heroku-buildpack-python/pull/1566)) +- Excluded `LD_LIBRARY_PATH` and `PYTHONHOME` app config vars when invoking subprocesses during the build. ([#1565](https://github.com/heroku/heroku-buildpack-python/pull/1565)) + +## [v248] - 2024-04-09 + +- Added support for Python 3.12.3. ([#1560](https://github.com/heroku/heroku-buildpack-python/pull/1560)) +- Changed the default Python version for new apps from 3.12.2 to 3.12.3. ([#1560](https://github.com/heroku/heroku-buildpack-python/pull/1560)) + +## [v247] - 2024-04-08 + +- Added support for Python 3.11.9. ([#1558](https://github.com/heroku/heroku-buildpack-python/pull/1558)) + +## [v246] - 2024-03-25 + +- Updated pip from 23.3.2 to 24.0. ([#1541](https://github.com/heroku/heroku-buildpack-python/pull/1541)) +- Updated setuptools from 68.2.2 to 69.2.0. ([#1553](https://github.com/heroku/heroku-buildpack-python/pull/1553)) +- Updated wheel from 0.42.0 to 0.43.0. ([#1550](https://github.com/heroku/heroku-buildpack-python/pull/1550)) +- Updated pipenv from 2023.11.15 to 2023.12.1. ([#1540](https://github.com/heroku/heroku-buildpack-python/pull/1540)) + +## [v245] - 2024-03-21 + +- Added support for Python 3.8.19, 3.9.19 and 3.10.14. ([#1551](https://github.com/heroku/heroku-buildpack-python/pull/1551)) + +## [v244] - 2024-03-13 + +- Improved the automatic `WEB_CONCURRENCY` feature: ([#1547](https://github.com/heroku/heroku-buildpack-python/pull/1547)) + - Switched to a dynamic calculation based on dyno CPU cores and memory instead of a hardcoded mapping. + - Decreased default concurrency on `performance-m` / `private-m` / `shield-m` dynos from `8` to `5`. + - Increased default concurrency on `performance-l` / `private-l` / `shield-l` dynos from `11` to `17`. + - Added logging of memory/CPU/concurrency information to the app logs (for web dynos only). + +## [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)). +- 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 + +- Updated pip from 23.3.1 to 23.3.2. ([#1524](https://github.com/heroku/heroku-buildpack-python/pull/1524)) +- Fixed repeat/cached Pipenv builds of local `file =` dependencies. ([#1526](https://github.com/heroku/heroku-buildpack-python/pull/1526)) +- Fixed the caching of editable VCS Pipenv dependency repositories. ([#1528](https://github.com/heroku/heroku-buildpack-python/pull/1528)) + +## [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)). +- Added a deprecation warning for Python 3.8. ([#1515](https://github.com/heroku/heroku-buildpack-python/pull/1515)) + +## [v240] - 2023-11-30 + +- Updated setuptools from 68.0.0 to 68.2.2. ([#1501](https://github.com/heroku/heroku-buildpack-python/pull/1501)) +- Updated wheel from 0.41.3 to 0.42.0. ([#1511](https://github.com/heroku/heroku-buildpack-python/pull/1511)) +- Updated pipenv from 2023.7.23 to 2023.11.15. ([#1502](https://github.com/heroku/heroku-buildpack-python/pull/1502) and [#1512](https://github.com/heroku/heroku-buildpack-python/pull/1512)) + +## [v239] - 2023-11-08 + +- Dropped support for Python 3.7. ([#1508](https://github.com/heroku/heroku-buildpack-python/pull/1508)) + +## [v238] - 2023-11-06 + +- Updated pip from 23.2.1 to 23.3.1. ([#1496](https://github.com/heroku/heroku-buildpack-python/pull/1496)) +- Updated wheel from 0.41.0 to 0.41.3. ([#1482](https://github.com/heroku/heroku-buildpack-python/pull/1482) and [#1503](https://github.com/heroku/heroku-buildpack-python/pull/1503)) + +## [v237] - 2023-10-03 + +- Fixed `pkgutil.find_loader` deprecation warning when using Python 3.12. ([#1493](https://github.com/heroku/heroku-buildpack-python/pull/1493)) + +## [v236] - 2023-10-02 + +- Added support for Python 3.12. ([#1490](https://github.com/heroku/heroku-buildpack-python/pull/1490)) +- Added support for Python 3.11.6. ([#1491](https://github.com/heroku/heroku-buildpack-python/pull/1491)) +- Changed the default Python version for new apps from 3.11.5 to 3.11.6. ([#1491](https://github.com/heroku/heroku-buildpack-python/pull/1491)) + +## [v235] - 2023-08-25 + +- Added support for Python 3.8.18, 3.9.18, 3.10.13 and 3.11.5. ([#1477](https://github.com/heroku/heroku-buildpack-python/pull/1477)) +- Changed the default Python version for new apps from 3.11.4 to 3.11.5. ([#1477](https://github.com/heroku/heroku-buildpack-python/pull/1477)) + +## [v234] - 2023-07-24 + +- Updated pip from 23.1.2 to 23.2.1. ([#1465](https://github.com/heroku/heroku-buildpack-python/pull/1465) and [#1470](https://github.com/heroku/heroku-buildpack-python/pull/1470)) +- Updated setuptools from 67.8.0 to 68.0.0. ([#1467](https://github.com/heroku/heroku-buildpack-python/pull/1467)) +- Updated wheel from 0.40.0 to 0.41.0. ([#1469](https://github.com/heroku/heroku-buildpack-python/pull/1469)) +- Updated pipenv from 2023.2.4 to 2023.7.23. ([#1468](https://github.com/heroku/heroku-buildpack-python/pull/1468) and [#1471](https://github.com/heroku/heroku-buildpack-python/pull/1471)) +- Updated the Python 3.7 deprecation message to reflect that it has now reached end-of-life. ([#1460](https://github.com/heroku/heroku-buildpack-python/pull/1460)) + +## [v233] - 2023-06-07 + +- Python 3.7.17, 3.8.17, 3.9.17, 3.10.12 and 3.11.4 are now available ([#1454](https://github.com/heroku/heroku-buildpack-python/pull/1454)). +- The default Python version for new apps is now 3.11.4 (previously 3.11.3) ([#1454](https://github.com/heroku/heroku-buildpack-python/pull/1454)). +- Updated setuptools from 67.7.2 to 67.8.0. ([#1456](https://github.com/heroku/heroku-buildpack-python/pull/1456)) +- Removed support for Heroku-18. ([#1449](https://github.com/heroku/heroku-buildpack-python/pull/1449)) + +## [v232] - 2023-04-27 + +- Updated pip from 23.0.1 to 23.1.2. ([#1441](https://github.com/heroku/heroku-buildpack-python/pull/1441)) +- Updated setuptools from 67.6.1 to 67.7.2. ([#1441](https://github.com/heroku/heroku-buildpack-python/pull/1441)) +- The pip bootstrap step is now performed using the pip wheel bundled with the Python stdlib, rather than one downloaded from S3. ([#1442](https://github.com/heroku/heroku-buildpack-python/pull/1442) and [#1444](https://github.com/heroku/heroku-buildpack-python/pull/1444)) + +## [v231] - 2023-04-12 + +- Updated setuptools from 63.4.3 to 67.6.1. ([#1437](https://github.com/heroku/heroku-buildpack-python/pull/1437)) +- Updated wheel from 0.38.4 to 0.40.0. ([#1437](https://github.com/heroku/heroku-buildpack-python/pull/1437)) +- Raised curl connection timeout threshold from 5 to 10 seconds. ([#1439](https://github.com/heroku/heroku-buildpack-python/pull/1439)) + +## [v230] - 2023-04-06 + +- Python 3.10.11 and 3.11.3 are now available ([#1433](https://github.com/heroku/heroku-buildpack-python/pull/1433)). +- The default Python version for new apps is now 3.11.3 (previously 3.11.2) ([#1433](https://github.com/heroku/heroku-buildpack-python/pull/1433)). + +## [v229] - 2023-03-10 + +- Downgrade pipenv from 2023.2.18 to 2023.2.4 ([#1425](https://github.com/heroku/heroku-buildpack-python/pull/1425)). + +## [v228] - 2023-02-21 + +- Drop support for Python 3.6 ([#1415](https://github.com/heroku/heroku-buildpack-python/pull/1415)). + +## [v227] - 2023-02-20 + +- Update pip from 22.3.1 to 23.0.1 for Python 3.7+ ([#1413](https://github.com/heroku/heroku-buildpack-python/pull/1413)). +- Update pipenv from 2023.2.4 to 2023.2.18 for Python 3.7+ ([#1412](https://github.com/heroku/heroku-buildpack-python/pull/1412)). + +## [v226] - 2023-02-14 + +- Use Python 3.11 as the default Python version for new apps (previously Python 3.10) ([#1408](https://github.com/heroku/heroku-buildpack-python/pull/1408)). +- Update wheel from 0.37.1 to 0.38.4 for Python 3.7+ ([#1409](https://github.com/heroku/heroku-buildpack-python/pull/1409)). + +## [v225] - 2023-02-08 + +- Python 3.10.10 and 3.11.2 are now available ([#1405](https://github.com/heroku/heroku-buildpack-python/pull/1405)). +- The default Python version for new apps is now 3.10.10 (previously 3.10.9) ([#1405](https://github.com/heroku/heroku-buildpack-python/pull/1405)). +- Update Pipenv from 2020.11.15 to: ([#1407](https://github.com/heroku/heroku-buildpack-python/pull/1407)) + - 2022.4.8 for Python 3.6 + - 2023.2.4 for Python 3.7+ +- Add a deprecation warning for Python 3.7 ([#1404](https://github.com/heroku/heroku-buildpack-python/pull/1404)). + +## [v224] - 2022-12-07 + +- Python 3.7.16, 3.8.16, 3.9.16, 3.10.9 and 3.11.1 are now available ([#1392](https://github.com/heroku/heroku-buildpack-python/pull/1392)). +- The default Python version for new apps is now 3.10.9 (previously 3.10.8) ([#1392](https://github.com/heroku/heroku-buildpack-python/pull/1392)). + +## [v223] - 2022-11-07 + +- Update pip from 22.2.2 to 22.3.1 for Python 3.7+ ([#1387](https://github.com/heroku/heroku-buildpack-python/pull/1387)). + +## [v222] - 2022-10-25 + +- Add support for Python 3.11 ([#1379](https://github.com/heroku/heroku-buildpack-python/pull/1379)). + +## [v221] - 2022-10-12 + +- Python 3.7.15, 3.8.15, 3.9.15 and 3.10.8 are now available ([#1376](https://github.com/heroku/heroku-buildpack-python/pull/1376)). +- The default Python version for new apps is now 3.10.8 (previously 3.10.7) ([#1376](https://github.com/heroku/heroku-buildpack-python/pull/1376)). +- Fix automatic provisioning of Postgres DB addons ([#1375](https://github.com/heroku/heroku-buildpack-python/pull/1375)). + +## [v220] - 2022-09-28 + +- Improve the wording of the Python 2.7 EOL error message ([#1367](https://github.com/heroku/heroku-buildpack-python/pull/1367)). + +## [v219] - 2022-09-26 + +- Drop support for Python 2.7, 3.4 and 3.5 ([#1364](https://github.com/heroku/heroku-buildpack-python/pull/1364)). +- Drop support for PyPy ([#1364](https://github.com/heroku/heroku-buildpack-python/pull/1364)). +- The Heroku Postgres database auto-provisioning feature now provisions a DB in fewer cases ([#1363](https://github.com/heroku/heroku-buildpack-python/pull/1363)). + +## [v218] - 2022-09-07 + +- Python 3.7.14, 3.8.14 and 3.9.14 are now available ([#1362](https://github.com/heroku/heroku-buildpack-python/pull/1362)). + +## [v217] - 2022-09-06 + +- Python 3.10.7 is now available ([#1361](https://github.com/heroku/heroku-buildpack-python/pull/1361)). +- The default Python version for new apps is now 3.10.7 (previously 3.10.6) ([#1361](https://github.com/heroku/heroku-buildpack-python/pull/1361)). + +## [v216] - 2022-08-17 + +- Ensure path rewriting works when using setuptools v64's new PEP660-based editable install mode ([#1357](https://github.com/heroku/heroku-buildpack-python/pull/1357)). +- Display an EOL warning for Python 3.4, 3.5 and 3.6 ([#1356](https://github.com/heroku/heroku-buildpack-python/pull/1356)). +- Improve the EOL warning for Python 2.7 ([#1356](https://github.com/heroku/heroku-buildpack-python/pull/1356)). +- Display a deprecation warning for PyPy support ([#1356](https://github.com/heroku/heroku-buildpack-python/pull/1356)). + +## [v215] - 2022-08-15 + +- Update pip from 22.1.2 to 22.2.2 for Python 3.7+ ([#1344](https://github.com/heroku/heroku-buildpack-python/pull/1344)). +- Update setuptools from 60.10.0 to 63.4.3 for Python 3.7+ ([#1344](https://github.com/heroku/heroku-buildpack-python/pull/1344)). +- Prevent stray `cp: cannot stat ...` error message in build log output when using Pipenv ([#1350](https://github.com/heroku/heroku-buildpack-python/pull/1350)). +- Remove `BUILD_WITH_GEO_LIBRARIES` sunset messaging ([#1347](https://github.com/heroku/heroku-buildpack-python/pull/1347)). +- Remove outdated Django version warning ([#1345](https://github.com/heroku/heroku-buildpack-python/pull/1345)). +- Remove redundant package install warning checks ([#1348](https://github.com/heroku/heroku-buildpack-python/pull/1348)). + +## [v214] - 2022-08-02 + +- Python 3.10.6 is now available ([#1342](https://github.com/heroku/heroku-buildpack-python/pull/1342)). +- The default Python version for new apps is now 3.10.6 (previously 3.10.5) ([#1342](https://github.com/heroku/heroku-buildpack-python/pull/1342)). + +## [v213] - 2022-06-14 + +- Enable retries and connection timeouts when using `curl` ([#1335](https://github.com/heroku/heroku-buildpack-python/pull/1335)). +- Correct the error message shown when downloading a valid Python version fails ([#1335](https://github.com/heroku/heroku-buildpack-python/pull/1335)). +- Switch to the recommended regional S3 domain instead of the global one ([#1334](https://github.com/heroku/heroku-buildpack-python/pull/1334)). + +## [v212] - 2022-06-07 + +- Python 3.10.5 is now available ([#1332](https://github.com/heroku/heroku-buildpack-python/pull/1332)). +- The default Python version for new apps is now 3.10.5 (previously 3.10.4) ([#1332](https://github.com/heroku/heroku-buildpack-python/pull/1332)). +- Update pip from 22.0.4 to 22.1.2 for Python 3.7+ ([#1331](https://github.com/heroku/heroku-buildpack-python/pull/1331)). +- Add support for Heroku-22 ([#1299](https://github.com/heroku/heroku-buildpack-python/pull/1299)). + +## [v211] - 2022-05-17 + +- Python 3.9.13 is now available ([#1326](https://github.com/heroku/heroku-buildpack-python/pull/1326)). +- Use shared builds + LTO when building Python 3.10 binaries ([#1320](https://github.com/heroku/heroku-buildpack-python/pull/1320)). + Note: This and the other Python binary changes below will only take effect for future Python + version releases (or future Heroku stacks) - existing Python binaries are not being recompiled. +- Strip debugging symbols from the Python binary and libraries ([#1321](https://github.com/heroku/heroku-buildpack-python/pull/1321)). +- Switch the pre-generated `.pyc` files for the Python stdlib from `timestamp` to `unchecked-hash` validation mode, for improved compatibility with Cloud Native Buildpacks ([#1322](https://github.com/heroku/heroku-buildpack-python/pull/1322)). +- Stop shipping optimisation level one and two `.pyc` files with the Python stdlib ([#1322](https://github.com/heroku/heroku-buildpack-python/pull/1322)). +- Use the `expat` package from the stack image rather than CPython's vendored version, when building + Python binaries ([#1319](https://github.com/heroku/heroku-buildpack-python/pull/1319)). + +## [v210] - 2022-04-14 + +- Fix typo in the `BUILD_WITH_GEO_LIBRARIES` end-of-life error message ([#1307](https://github.com/heroku/heroku-buildpack-python/pull/1307)). +- No longer set a fallback value for `$STACK`, since it is always set on Heroku ([#1308](https://github.com/heroku/heroku-buildpack-python/pull/1308)). +- Adjust the configure options and packaging process for Python 3.7 releases, to enable loadable extensions in the `_sqlite` module, and to remove the `idle_test` module ([#1309](https://github.com/heroku/heroku-buildpack-python/pull/1309)). This change will only take effect as of the next Python 3.7 release (3.7.14). +- Update pip from 21.3.1 to 22.0.4 for Python 3.7+ ([#1310](https://github.com/heroku/heroku-buildpack-python/pull/1310)) +- Update setuptools from 57.5.0 to: ([#1310](https://github.com/heroku/heroku-buildpack-python/pull/1310)) + - 59.6.0 for Python 3.6 + - 60.10.0 for Python 3.7+ +- Update wheel from 0.37.0 to 0.37.1 for Python 2.7 and Python 3.5+ ([#1310](https://github.com/heroku/heroku-buildpack-python/pull/1310)) + +## [v209] - 2022-03-24 + +- Python 3.9.12 and 3.10.4 are now available (CPython) ([#1300](https://github.com/heroku/heroku-buildpack-python/pull/1300)). +- The default Python version for new apps is now 3.10.4 (previously 3.10.3) ([#1300](https://github.com/heroku/heroku-buildpack-python/pull/1300)). + +## [v208] - 2022-03-23 + +- Use Python 3.10 as the default Python version for new apps (previously Python 3.9) ([#1296](https://github.com/heroku/heroku-buildpack-python/pull/1296)). + +## [v207] - 2022-03-16 + +- Python 3.7.13, 3.8.13, 3.9.11 and 3.10.3 are now available (CPython) ([#1293](https://github.com/heroku/heroku-buildpack-python/pull/1293)). +- The default Python version for new apps is now 3.9.11 (previously 3.9.10) ([#1293](https://github.com/heroku/heroku-buildpack-python/pull/1293)). +- Adjust the configure options and packaging process for Python 3.8 releases to enable PGO, enable loadable extensions in the `_sqlite` module, and to remove the `idle_test` module ([#1293](https://github.com/heroku/heroku-buildpack-python/pull/1293)). Python 3.8 releases on Heroku prior to 3.8.13 are not affected. + +## [v206] - 2022-01-14 + +- Python 3.9.10 and 3.10.2 are now available (CPython) ([#1281](https://github.com/heroku/heroku-buildpack-python/pull/1281)). +- The default Python version for new apps is now 3.9.10 (previously 3.9.9) ([#1281](https://github.com/heroku/heroku-buildpack-python/pull/1281)). + +## [v205] - 2021-12-06 + +- Python 3.10.1 is now available ([#1271](https://github.com/heroku/heroku-buildpack-python/pull/1271)). + +## [v204] - 2021-11-16 + +- Python 3.9.9 is now available ([#1268](https://github.com/heroku/heroku-buildpack-python/pull/1268)). +- The default Python version for new apps is now 3.9.9 (previously 3.9.8) ([#1268](https://github.com/heroku/heroku-buildpack-python/pull/1268)). + +## [v203] - 2021-11-08 + +- Python 3.9.8 is now available ([#1263](https://github.com/heroku/heroku-buildpack-python/pull/1263)). +- The default Python version for new apps is now 3.9.8 (previously 3.9.7) ([#1263](https://github.com/heroku/heroku-buildpack-python/pull/1263)). +- Adjust the configure options and packaging process for Python 3.9 releases to enable PGO, enable loadable extensions in the `_sqlite` module, and to remove the `idle_test` module ([#1263](https://github.com/heroku/heroku-buildpack-python/pull/1263)). Python 3.9 releases on Heroku prior to 3.9.8 are not affected. + +## [v202] - 2021-11-01 + +- Update pip from 20.2.4 to: ([#1259](https://github.com/heroku/heroku-buildpack-python/pull/1259)) + - 20.3.4 for Python 2.7 and 3.5 + - 21.3.1 for Python 3.6+ + +## [v201] - 2021-10-20 + +- Update setuptools from 47.1.1 to: ([#1254](https://github.com/heroku/heroku-buildpack-python/pull/1254)) + - 50.3.2 for Python 3.5 + - 57.5.0 for Python 3.6+ +- Update wheel from 0.36.2 to 0.37.0 for Python 2.7 and Python 3.5+ ([#1254](https://github.com/heroku/heroku-buildpack-python/pull/1254)). +- Perform editable package `.pth` and `.egg-link` path rewriting at runtime ([#1252](https://github.com/heroku/heroku-buildpack-python/pull/1252)). + +## [v200] - 2021-10-04 + +- Add support for Python 3.10 ([#1246](https://github.com/heroku/heroku-buildpack-python/pull/1246)). +- Adjust the configure options and packaging process for Python 3.10 to enable PGO, enable loadable extensions in the `_sqlite` module, and to remove the `idle_test` module ([#1246](https://github.com/heroku/heroku-buildpack-python/pull/1246)). + +## v199 - 2021-09-05 + +- Python 3.6.15 and 3.7.12 are now available (CPython) ([#1238](https://github.com/heroku/heroku-buildpack-python/pull/1238)). + +## v198 - 2021-08-30 + +- Python 3.8.12 and 3.9.7 are now available (CPython) ([#1236](https://github.com/heroku/heroku-buildpack-python/pull/1236)). +- The default Python version for new apps is now 3.9.7 (previously 3.9.6) ([#1236](https://github.com/heroku/heroku-buildpack-python/pull/1236)). + +## v197 - 2021-06-28 + +- Python 3.6.14, 3.7.11, 3.8.11 and 3.9.6 are now available (CPython) ([#1219](https://github.com/heroku/heroku-buildpack-python/pull/1219)). +- The default Python version for new apps is now 3.9.6 (previously 3.9.5) ([#1219](https://github.com/heroku/heroku-buildpack-python/pull/1219)). +- Remove testing & binary generation support for Heroku-16 ([#1214](https://github.com/heroku/heroku-buildpack-python/pull/1214)). + +## v196 - 2021-05-25 + +- Django collectstatic is no longer skipped if `DISABLE_COLLECTSTATIC` is set to `0` or the empty string ([#1208](https://github.com/heroku/heroku-buildpack-python/pull/1208)). +- If Django collectstatic is skipped, output the reason why ([#1208](https://github.com/heroku/heroku-buildpack-python/pull/1208)). +- Output a deprecation warning when collectstatic is skipped via the `.heroku/collectstatic_disabled` file ([#1208](https://github.com/heroku/heroku-buildpack-python/pull/1208)). +- Remove redundant "Cedar-14 is unsupported" error ([#1212](https://github.com/heroku/heroku-buildpack-python/pull/1212)). + +## v195 - 2021-05-03 + +- Python 3.8.10 and 3.9.5 are now available (CPython) ([#1204](https://github.com/heroku/heroku-buildpack-python/pull/1204)). + +## v194 - 2021-04-26 + +- Always output the Python version used and reason why ([#1196](https://github.com/heroku/heroku-buildpack-python/pull/1196)). + +## v193 - 2021-04-13 + +- Update pip from 20.1.1 to 20.2.4 for Python 2.7 and Python 3.5+ ([#1192](https://github.com/heroku/heroku-buildpack-python/pull/1192)). +- Update wheel from 0.34.2 to 0.36.2 for Python 2.7 and Python 3.5+ ([#1191](https://github.com/heroku/heroku-buildpack-python/pull/1191)). +- Support build environments where `$BUILD_DIR` is set to a symlink of `/app` ([#992](https://github.com/heroku/heroku-buildpack-python/pull/992)). + +## v192 - 2021-04-06 + +- Python 3.8.9 and 3.9.4 are now available (CPython) ([#1188](https://github.com/heroku/heroku-buildpack-python/pull/1188)). +- Use Python 3.9 as the default Python version for new apps (previously Python 3.6) ([#1187](https://github.com/heroku/heroku-buildpack-python/pull/1187)). +- Remove Airflow `SLUGIFY_USES_TEXT_UNIDECODE` workaround ([#1186](https://github.com/heroku/heroku-buildpack-python/pull/1186)). +- Fix grammar in the Python 2 EOL message ([#1182](https://github.com/heroku/heroku-buildpack-python/pull/1182)). + +## v191 - 2021-02-19 + +- Python 3.8.8 and 3.9.2 are now available (CPython) ([#1178](https://github.com/heroku/heroku-buildpack-python/pull/1178)). + +## v190 - 2021-02-16 + +- Python 3.6.13 and 3.7.10 are now available (CPython) ([#1174](https://github.com/heroku/heroku-buildpack-python/pull/1174)). +- The default Python version for new apps is now 3.6.13 (previously 3.6.12) ([#1174](https://github.com/heroku/heroku-buildpack-python/pull/1174)). + +## v189 - 2021-02-12 + +- Update pipenv from `2018.5.18` to `2020.11.15` ([#1169](https://github.com/heroku/heroku-buildpack-python/pull/1169)). +- Remove pinning of pip to `9.0.2` when using pipenv ([#1169](https://github.com/heroku/heroku-buildpack-python/pull/1169)). + +## v188 - 2020-12-21 + +- Python 3.8.7 is now available (CPython) ([#1125](https://github.com/heroku/heroku-buildpack-python/pull/1125)). + +## v187 - 2020-12-08 + +- Python 3.9.1 is now available (CPython) ([#1127](https://github.com/heroku/heroku-buildpack-python/pull/1127)). + +## v186 - 2020-11-18 + +- Update the `BUILD_WITH_GEO_LIBRARIES` error message ([#1121](https://github.com/heroku/heroku-buildpack-python/pull/1121)). +- Switch NLTK feature detection away from `sp-grep` ([#1119](https://github.com/heroku/heroku-buildpack-python/pull/1119)). +- Switch Django collectstatic feature detection away from `sp-grep` ([#1119](https://github.com/heroku/heroku-buildpack-python/pull/1119)). +- Remove vendored `sp-grep` script ([#1119](https://github.com/heroku/heroku-buildpack-python/pull/1119)). +- Remove vendored `pip-diff` script ([#1118](https://github.com/heroku/heroku-buildpack-python/pull/1118)). +- Remove vendored `pip-grep` script ([#1116](https://github.com/heroku/heroku-buildpack-python/pull/1116)). + +## v185 - 2020-11-12 + +- Error if the unsupported `BUILD_WITH_GEO_LIBRARIES` env var is set ([#1115](https://github.com/heroku/heroku-buildpack-python/pull/1115)). +- Remove deprecated GDAL/GEOS/PROJ support ([#1113](https://github.com/heroku/heroku-buildpack-python/pull/1113)). +- Remove vendored `jq` binary ([#1112](https://github.com/heroku/heroku-buildpack-python/pull/1112)). +- Remove redundant Mercurial install step ([#1111](https://github.com/heroku/heroku-buildpack-python/pull/1111)). +- Remove support for the Cedar-14 stack ([#1110](https://github.com/heroku/heroku-buildpack-python/pull/1110)). + +## v184 - 2020-10-21 + +- Vendor buildpack-stdlib instead of fetching from S3 ([#1100](https://github.com/heroku/heroku-buildpack-python/pull/1100)). +- Fix metric names for metrics emitted within `sub_env` ([#1099](https://github.com/heroku/heroku-buildpack-python/pull/1099)). + +## v183 - 2020-10-12 + +- Add support for Heroku-20 ([#968](https://github.com/heroku/heroku-buildpack-python/pull/968)). + +## v182 - 2020-10-06 + +- Python 3.9.0 is now available (CPython) ([#1090](https://github.com/heroku/heroku-buildpack-python/pull/1090)). +- Migrate from the `lang-python` S3 bucket to `heroku-buildpack-python` ([#1089](https://github.com/heroku/heroku-buildpack-python/pull/1089)). +- Remove `vendor/shunit2` ([#1086](https://github.com/heroku/heroku-buildpack-python/pull/1086)). +- Replace `BUILDPACK_VENDOR_URL` and `USE_STAGING_BINARIES` with `BUILDPACK_S3_BASE_URL` ([#1085](https://github.com/heroku/heroku-buildpack-python/pull/1085)). + +## v181 - 2020-09-29 + +- PyPy 2.7 and 3.6, version 7.3.2 are now available (Note: PyPy support is in beta) ([#1081](https://github.com/heroku/heroku-buildpack-python/pull/1081)). + +## v180 - 2020-09-24 + +- Python 3.8.6 is now available (CPython) ([#1072](https://github.com/heroku/heroku-buildpack-python/pull/1072)). + +## v179 - 2020-09-23 + +- Remove duplicate pipenv metric event ([#1070](https://github.com/heroku/heroku-buildpack-python/pull/1070)). +- Emit metrics for how the Python version was chosen for an app ([#1069](https://github.com/heroku/heroku-buildpack-python/pull/1069)). +- Emit Python version metric events for all builds, not just clean installs ([#1066](https://github.com/heroku/heroku-buildpack-python/pull/1066)). + +## v178 - 2020-09-07 + +- Python 3.5.10 is now available (CPython) ([#1062](https://github.com/heroku/heroku-buildpack-python/pull/1062)). + +## v177 - 2020-08-18 + +- Python 3.6.12 and 3.7.9 are now available (CPython) ([#1054](https://github.com/heroku/heroku-buildpack-python/pull/1054)). +- The default Python version for new apps is now 3.6.12 (previously 3.6.11) ([#1054](https://github.com/heroku/heroku-buildpack-python/pull/1054)). + +## v176 - 2020-08-12 + +- Rebuild the Python 3.4.10 archives with the correct version of Python ([#1048](https://github.com/heroku/heroku-buildpack-python/pull/1048)). +- Fix the security update version check message for apps using PyPy ([#1040](https://github.com/heroku/heroku-buildpack-python/pull/1040)). +- Remove `vendor/test-utils` ([#1043](https://github.com/heroku/heroku-buildpack-python/pull/1043)). + +## v175 - 2020-08-05 + +- Update pip from 20.0.2 to 20.1.1 for Python 2.7 and Python 3.5+ ([#1030](https://github.com/heroku/heroku-buildpack-python/pull/1030)). +- Update setuptools from 39.0.1 to: ([#1024](https://github.com/heroku/heroku-buildpack-python/pull/1024)) + - 44.1.1 for Python 2.7 + - 43.0.0 for Python 3.4 + - 47.1.1 for Python 3.5+ +- Switch the `heroku-buildpack-python` repository default branch from `master` to `main` ([#1029](https://github.com/heroku/heroku-buildpack-python/pull/1029)). + +## v174 - 2020-07-30 + +- For repeat builds, also manage the installed versions of setuptools/wheel, rather than just that of pip ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Install an explicit version of wheel rather than the latest release at the time ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Output the installed version of pip, setuptools and wheel in the build log ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Errors installing pip/setuptools/wheel are now displayed in the build output and fail the build early ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Install pip using itself rather than `get-pip.py` ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Disable pip's version check + cache when installing pip/setuptools/wheel ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Install setuptools from PyPI rather than a vendored copy ([#1007](https://github.com/heroku/heroku-buildpack-python/pull/1007)). +- Reduce the number of environment variables exposed to `bin/{pre,post}_compile` and other subprocesses ([#1011](https://github.com/heroku/heroku-buildpack-python/pull/1011)). + +## v173 - 2020-07-21 + +- Python 3.8.5 is now available (CPython). + +## v172 - 2020-07-17 + +- Python 3.8.4 is now available (CPython). + +## v171 - 2020-07-07 + +- Python 3.6.11 and 3.7.8 are now available (CPython). + +## v170 - 2020-05-19 + +- Python 2.7.18, 3.5.9, 3.7.7 and 3.8.3 are now available (CPython). +- PyPy 2.7 and 3.6, version 7.3.1 are now available (Note: PyPy support is in beta). +- Docs: Fix explanation of runtime.txt generation when using pipenv. +- Bugfix: Correctly detect Python version when using a `python_version` of `3.8` in `Pipfile.lock`. + +## v169 - 2020-04-22 + +- Add a Hatchet test for python 3.8.2 +- Set Code Owners to `@heroku/languages` +- Bugfix: Caching on subsequent redeploys +- Update tests to support latest version of Python + +## v168 - 2020-04-06 + +- Doc: Update Readme with version numbers +- update Code Owners to include the Heroku Buildpack Maintainers team +- Deprecation warning: `BUILD_WITH_GEO_LIBRARIES` is now deprecated. See warning for details. +- Clean up build log output +- Update Python versions in README to match docs +- Django version detection fixed, link updated + +## v167 - 2020-03-26 + +- Add failcase for cache busting +- Bugfix: Clearing pip dependencies + +## v166 - 2020-03-05 + +- Correct ftp to https in vendored file +- Warn for Django 1.11 approaching EOL, provide link to roadmap + +## v165 - 2020-02-27 + +- Python 3.8.2 now available. + +## v164 - 2020-02-20 + +- Update requirements.txt builds to use pip 20.0.2 +- Download get-pip.py to tmpdir instead of root dir + +## v163 - 2019-12-23 + +- New pythons released: + Python 3.8.1, 3.7.6, 3.6.10 (CPython) + Beta Release: Pypy 2.7 and 3.6, version 7.2.0 + +## v162 - 2019-12-06 + +- Bug fix: fragile sqlite3 install + +## v161 - 2019-12-02 + +- Bug fix: Sqlite3 version bump + +## v160 - 2019-10-23 + +- Bugfix: Pipenv no longer installs twice in CI + +## v159 - 2019-10-22 + +- Python 2.7.17 now available on Heroku 18 and 16. + +## v158 - 2019-10-21 + +- Python 3.7.5 and 3.8.0 now available on Heroku 18 and 16. +- Add support for Python 3.8 branch +- Sqlite3 Update: + - Test Improvements +- Add support for staging binary testing + +## v157 - 2019-09-18 + +- Typo fixes + +## v156 - 2019-09-12 + +- Python 3.6.9 and 3.7.4 now available. + +- Move get-pip utility to S3 +- Build utility and documentation updates +- Bump Hatchet tests to point at new default python version. + +## v155 - 2019-08-22 + +add docs and make target for heroku-18 bob builds + +## v154 - 2019-07-17 + +Fix python 3.5.7 formula actually building 3.7.2 + +## v153 - 2019-06-21 + +Hotfix for broken heroku-16 deploys + +## v152 - 2019-04-04 + +Python 3.7.3 now available. + +## v151 - 2019-03-21 + +Python 3.5.7 and 3.4.10 now available on all Heroku stacks. + +## v150 - 2019-03-13 + +Python 2.7.16 now available on all Heroku stacks. + +## v149 - 2019-03-04 + +Hotfix for broken Cedar 14 deploys + +## v148 - 2019-02-21 + +No user facing changes, improving internal metrics + +## v147 - 2019-02-07 + +Python 3.7.2 and 3.6.8 now available on all Heroku stacks. + +## v146 - 2018-11-11 + +Python 3.7.1, 3.6.7, 3.5.6 and 3.4.9 now available on all Heroku stacks. + +## v145 - 2018-11-08 + +Testing and tooling expanded to better support new runtimes + +## v144 - 2018-10-10 + +Switch to cautious upgrade for Pipenv install to ensure the pinned pip version +is used with Pipenv + +## v143 - 2018-10-09 + +Add support for detecting `SLUGIFY_USES_TEXT_UNIDECODE`, which is required to +install Apache Airflow version 1.10 or higher. + +## v142 - 2018-10-08 + +Improvements to Python install messaging + +## v139, 140, 141 + +No user-facing changes, documenting for version clarity + +## v138 - 2018-08-01 + +Use stack image SQLite3 instead of vendoring + +## v137 - 2018-07-17 + +Prevent 3.7.0 from appearing as unsupported in buildpack messaging. + +## v136 - 2018-06-28 + +Upgrade to 3.6.6 and support 3.7.0 on all runtimes. + +## v135 - 2018-05-29 + +Upgrade Pipenv to v2018.5.18. + +## v134 - 2018-05-02 + +Default to 3.6.5, bugfixes. + +## v133 + +Fixes for pip 10 release. + +## v132 + +Improve pip installation, with the release of v9.0.2. + +## v131 + +Fix bug with pip. + +## v130 + +Better upgrade strategy for pip. + +## v129 + +Don't upgrade pip (from v128). + +## v128 + +Upgrade pip, pin to Pipenv v11.8.2. + +## v127 + +Pin to Pipenv v11.7.1. + +## v126 + +Bugfixes. + +## v125 + +Bugfixes. + +## v124 + +Update buildpack to automatically install `[dev-packages]` during Heroku CI Pipenv builds. + +- Skip installs if Pipfile.lock hasn't changed, and uninstall stale dependencies with Pipenv. +- Set `PYTHONPATH` during collectstatic runs. +- No longer warn if there is no `Procfile`. +- Update Pipenv's "3.6" runtime specifier to point to "3.6.4". + +## v123 + +Update gunicorn `init.d` script to allow overrides. + +## v122 + +Update default Python to v3.6.4. + +## v121 + +Update default Python to v3.6.3. + +## v120 + +Use `$ pipenv --deploy`. + +## v119 + +Improvements to Pipenv support, warning on unsupported Python versions. + +- We now warn when a user is not using latest 2.x or 3.x Python. +- Heroku now supports `[requires]` `python_full_version` in addition to `python_version`. + +## v118 + +Improvements to Pipenv support. + +## v117 + +Bug fix. + +## v116 + +Vendoring improvements. + +- Geos libraries should work on Heroku-16 now. +- The libffi/libmemcached vendoring step is now skipped on Heroku-16 (since they are installed in the base image). + +## v115 + +Revert a pull request. + +- No longer using `sub_env` for `pip install` step. + +## v114 + +- Bugfixes. + +Blacklisting `PYTHONHOME` and `PYTHONPATH` for older apps. Upgrades to nltk support. + +## v113 + +Updates to Pipenv support. + +## v112 + +Bugfix. + +- Fixed grep output bug. + +## v111 + +Linting, bugfixes. + +## v110 + +Update default Python to 3.6.2. + +## v109 + +Update Default Python to 3.6.1, bugfixes. + +- Fixed automatic pip uninstall of dependencies removed from requirements.txt. + +## v108 + +Fix output for collectstatic step. + +## v107 + +Bugfix for C dependency installation. + +## v106 + +Don't install packages that could mess up packaging. + + - The Python buildpack will automatically remove `six`, `pyparsing`, `appdirs`, + `setuptools`, and `distribute` from a `requirements.txt` file now, as these + packages are provided by the Python buildpack. + +## v105 + +Improvements to output messaging. + +## v104 + +General improvements. + +- Fix for Heroku CI. +- Use `pkg_resources` to check if a distribution is installed instead of + parsing `requirements.txt`. ([#395](https://github.com/heroku/heroku-buildpack-python/pull/395)) + +## v103 + +Bug fixes and improvements. + +- Fix for Pipenv. +- Fix for Heroku CI. +- Improve handling of `WEB_CONCURRENCY` when using multiple buildpacks. +- Adjust environment variables set during the build to more closely match those in the dyno environment (`DYNO` is now available, `STACK` is not). +- Restore the build cache prior to running bin/pre_compile. + +## v102 + +Buildpack code cleanup. + +- Improved messaging around NLTK. + +## v101 + +Updated setuptools installation method. + +- Improved pipenv support. + +## v100 + +Preliminary pipenv support. + +## v99 + +Cleanup. + +## v98 + +Official NLTK support and other improvements. + +- Support for `nltk.txt` file for declaring corpora to be downloaded. +- Leading zeros for auto-set `WEB_CONCURRENCY`. + +## v97 + +Improved egg-link functionality. + +## v96 + +Bugfix. + +## v95 + +Improved output support. + +## v94 + +Improved support for PyPy. + +## v93 + +Improved support for PyPy. + +## v92 + +Improved cache functionality and fix egg-links regression. + +## v91 + +Bugfix, rolled back to v88. + +## v90 + +Bugfix. + +## v89 + +Improved cache functionality and fix egg-links regression. + +## v88 + +Fixed bug with editable pip installations. + +## v87 + +Updated default Python 2.7.13. + +- Python 2.7.13 uses UCS-4 build, more compatible with linux wheels. +- Updated setuptools to v32.1.0. + +## v86 + +Refactor and multi-buildpack compatibility. + +## v85 + +Packaging fix. + +## v84 + +Updated pip and setuptools. + +- Updated pip to v9.0.1. +- Updated setuptools to v28.8.0. + +## v83 + +Support for Heroku CI. + +- Cffi support for argon2 + +## v82 - 2016-08-22 + +Update to library detection mechanisms (pip-pop). + +- Updated setuptools to v25.5.0 + +## v81 - 2016-06-28 + +Updated default Python to 2.7.11. + +- Updated pip to v8.1.2. +- Updated setuptools to v23.1.0. + +## v80 - 2016-04-05 + +Improved pip-pop compatibility with latest pip releases. + +## v79 - 2016-03-22 + +Compatibility improvements with heroku-apt-buildpack. + +## v78 - 2016-03-18 + +Added automatic configuration of Gunicorn's `FORWARDED_ALLOW_IPS` setting. + +Improved detection of libffi dependency when using bcrypt via `Django[bcrypt]`. + +Improved GDAL support. + +- GDAL dependency detection now checks for pygdal and is case-insensitive. +- The vendored GDAL library has been updated to 1.11.1. +- GDAL bootstrapping now also installs the GEOS and Proj.4 libraries. + +Updated pip to 8.1.1 and setuptools to 20.3. + +## v77 - 2016-02-10 + +Improvements to warnings and minor bugfix. + +## v76 - 2016-02-08 + +Improved Django collectstatic support. + +- `$ python manage.py collectstatic` will only be run if `Django` is present in `requirements.txt`. +- If collectstatic fails, the build fails. Full traceback is provided. +- `$DISABLE_COLLECTSTATIC`: skip collectstatic step completely (not new). +- `$DEBUG_COLLECTSTATIC`: echo environment variables upon collectstatic failure. +- Updated build output style. +- New warning for outdated Python (via pip `InsecurePlatform` warning). + +## v75 - 2016-01-29 + +Updated pip and Setuptools. + +## v74 - 2015-12-29 + +Added warnings for lack of Procfile. + +## v72 - 2015-12-07 + +Updated default Python to 2.7.11. + +## v72 - 2015-12-03 + +Added friendly warnings for common build failures. + +## v70 - 2015-10-29 + +Improved compatibility with multi and node.js buildpacks. + +## v69 - 2015-10-12 + +Revert to v66. + +## v68 - 2015-10-12 + +Fixed `.heroku/venv` error with modern apps. + +## v67 - 2015-10-12 + +Further improved cache compatibility with multi and node.js buildpacks. + +## v66 - 2015-10-09 + +Improved compatibility with multi and node.js buildpacks. + +## v65 - 2015-10-08 + +Reverted v64. + +## v64 - 2015-10-08 + +Improved compatibility with multi and node.js buildpacks. + +## v63 - 2015-10-08 + +Updated pip and Setuptools. + +- Setuptools updated to v18.3.2 +- pip updated to v7.1.2 + +## v62 - 2015-08-07 + +Updated pip and Setuptools. + +- Setuptools updated to v18.1 +- pip updated to v7.1.0 + +## v61 - 2015-06-30 + +Updated pip and Setuptools. + +- Setuptools updated to v18.0.1 +- pip updated to v7.0.3 + +## v60 - 2015-05-27 + +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/v335...main +[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 +[v204]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v203...archive/v204 +[v203]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v202...archive/v203 +[v202]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v201...archive/v202 +[v201]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v200...archive/v201 +[v200]: https://github.com/heroku/heroku-buildpack-python/compare/archive/v199...archive/v200 diff --git a/Changelog.md b/Changelog.md deleted file mode 100644 index 954dfab87..000000000 --- a/Changelog.md +++ /dev/null @@ -1,130 +0,0 @@ -## v14 - -Features: - -* Full removal of Django settings injection for new apps. -* Support for profile.d -* Fresh app detection. -* Update to Virtualenv v1.7.2 -* Updated to Pip v1.1 (patched) - -Bugfixes: - -* Default pip path exists action. - -## v13 - -Bugfixes: - -* Fix pip quoting error. -* Only talk about collectstatic in buildpack output when it's configured. - -## v12 - -Bugfixes: - -* Catch database setting corner case. - -## v11 - -Bugfixes: - -* Cleanup collectstatic output. - - -## v10 - -Bugfixes: - -* Check for collectstatic validity with --dry-run instead of --help for Django 1.4. - -## v9 - -Bugfixes: - -* Unset PYTHONHOME in buildpack for [user_env_compile](http://devcenter.heroku.com/articles/labs-user-env-compile). - -## v8 - -Features: - -* Disable Django collectstatic with `$DISABLE_COLLECTSTATIC` + [user_env_compile](http://devcenter.heroku.com/articles/labs-user-env-compile). - -Bugfixes: - -* Don't disbable injection for new Django apps. -* Inform user of July 1, 2012 deprecation of Django injection. - -## v7 - -Features: - -* Full removal of Django setting injection for new apps. -* Automatic execution of collectstatic. -* Suppress collectstatic errors via env SILENCE_COLLECTSTATIC. -* Increase settings.py search depth to 3. -* Search recursively from included requirements.txt files. - - -## v6 (03/23/2012) - -Features: - -* Dist packages (setup.py) support. -* Move new virtualenvs to `/app/.heroku/venv`. -* Heavily improved Django app detection, accounting for `Django` in `requirements.txt`. -* Literate [documentation](http://python-buildpack.herokuapp.com). -* Default `$PYTHONHOME`, `$PYTHONPATH`, and `$LANG` configurations. -* Disable Django setting injection with `$DISABLE_INJECTION` + [user_env_compile](http://devcenter.heroku.com/articles/labs-user-env-compile). -* General code refactor and improved messaging. -* Unit tests. - -Bugfixes: - -* Django 1.4 startproject template layout support. -* Django `manage.py` location can now be independent from `settings.py`. - -## v5 (02/01/2012) - -Bugfixes: - -* Git requirements 100% work. - - -## v4 (01/20/2012) - -Features: - -* Updated to virtualenv v1.7 with patched pip v1.2. -* Actually activate created virtualenv within compile process. -* Use distribute instead of deprecated setuptools. -* Automatically destroy and rebuild corrupt virtualenvs. -* Refactor django and pylibmc detection. - -Bugfixes: - -* Fixed `package==dev` in requirements with patched pip embedded within virtualenv. Patch upstreamed. -* Minor curl/rm flag fixes (thanks, contributors!) - - -## v3 (12/07/2011) - -Bugfixes: - -* Better django setup.py injection. - - -## v2 (11/15/2011) - -Features: - -* Support for pylibmc and libmemcached +sasl. - -Bugfixes: - -* Detect when virtualenv is checked in and alert user. - - -## v1 (10/01/2011) - -* Conception. diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..2abf2b7cb --- /dev/null +++ b/Gemfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +ruby '>= 3.2', '< 3.5' + +group :test, :development do + gem 'heroku_hatchet' + gem 'parallel_split_test' + gem 'rspec-core' + gem 'rspec-expectations' + gem 'rspec-retry' + gem 'rubocop', require: false + gem 'rubocop-rspec', require: false +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..1d69d2744 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,95 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + base64 (0.3.0) + diff-lcs (1.6.2) + erubis (2.7.0) + excon (1.3.1) + logger + heroics (0.1.3) + base64 + erubis (~> 2.0) + excon + moneta + multi_json (>= 1.9.2) + webrick + heroku_hatchet (8.0.6) + excon (< 2) + platform-api (~> 3) + rrrretry (~> 1) + thor (~> 1) + threaded (~> 0) + json (2.18.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + moneta (1.0.0) + multi_json (1.18.0) + parallel (1.27.0) + parallel_split_test (0.10.0) + parallel (>= 0.5.13) + rspec-core (>= 3.9.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + platform-api (3.8.0) + heroics (~> 0.1.1) + moneta (~> 1.0.0) + rate_throttle_client (~> 0.1.0) + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rate_throttle_client (0.1.2) + regexp_parser (2.11.3) + rrrretry (1.0.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + 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.6) + rubocop (1.84.0) + json (~> 2.3) + 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 (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rspec (3.9.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + ruby-progressbar (1.13.0) + thor (1.4.0) + threaded (0.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webrick (1.9.2) + +PLATFORMS + ruby + +DEPENDENCIES + heroku_hatchet + parallel_split_test + rspec-core + rspec-expectations + rspec-retry + rubocop + rubocop-rspec + +RUBY VERSION + ruby 3.4.7p58 + +BUNDLED WITH + 2.7.2 diff --git a/LICENSE b/LICENSE index 7bc93ec6a..146430176 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License: -Copyright (C) 2013 Heroku, Inc. +Copyright (C) 2022 Heroku, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/Makefile b/Makefile index 11b4a6151..ea5971da4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,49 @@ # These targets are not files -.PHONY: tests +.PHONY: lint lint-scripts lint-ruby check-format format run publish -tests: - ./bin/test +STACK ?= heroku-24 +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 check-format lint-ruby + +lint-scripts: + @git ls-files -z --cached --others --exclude-standard 'bin/*' '*/bin/*' '*.sh' | xargs -0 shellcheck --check-sourced --color=always + +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 -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 + +publish: + @etc/publish.sh diff --git a/README.md b/README.md new file mode 100644 index 000000000..4f8703ea4 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +![python](https://raw.githubusercontent.com/heroku/buildpacks/refs/heads/main/assets/images/buildpack-banner-python.png) + +# Heroku Buildpack: Python + +[![CI](https://github.com/heroku/heroku-buildpack-python/actions/workflows/ci.yml/badge.svg)](https://github.com/heroku/heroku-buildpack-python/actions/workflows/ci.yml) + +This is the official [Heroku buildpack](https://devcenter.heroku.com/articles/buildpacks) for Python apps. + +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. + +## 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/Readme.md b/Readme.md deleted file mode 100644 index 7acb9b773..000000000 --- a/Readme.md +++ /dev/null @@ -1,52 +0,0 @@ -Heroku buildpack: Python -======================== - -This is a [Heroku buildpack](http://devcenter.heroku.com/articles/buildpacks) for Python apps, powered by [pip](http://www.pip-installer.org/). - - -Usage ------ - -Example usage: - - $ ls - Procfile requirements.txt web.py - - $ heroku create --buildpack git://github.com/heroku/heroku-buildpack-python.git - - $ git push heroku master - ... - -----> Python app detected - -----> Installing runtime (python-2.7.8) - -----> Installing dependencies using pip - Downloading/unpacking requests (from -r requirements.txt (line 1)) - Installing collected packages: requests - Successfully installed requests - Cleaning up... - -----> Discovering process types - Procfile declares types -> (none) - -You can also add it to upcoming builds of an existing application: - - $ heroku config:add BUILDPACK_URL=git://github.com/heroku/heroku-buildpack-python.git - -The buildpack will detect your app as Python if it has the file `requirements.txt` in the root. - -It will use Pip to install your dependencies, vendoring a copy of the Python runtime into your slug. - -Specify a Runtime ------------------ - -You can also provide arbitrary releases Python with a `runtime.txt` file. - - $ cat runtime.txt - python-3.4.2 - -Runtime options include: - -- python-2.7.8 -- python-3.4.2 -- pypy-2.4.0 (unsupported, experimental) -- pypy3-2.3.1 (unsupported, experimental) - -Other [unsupported runtimes](https://github.com/heroku/heroku-buildpack-python/tree/master/builds/runtimes) are available as well. diff --git a/bin/compile b/bin/compile index d2f72a587..966c032a6 100755 --- a/bin/compile +++ b/bin/compile @@ -1,213 +1,308 @@ #!/usr/bin/env bash +# Usage: bin/compile +# See: https://devcenter.heroku.com/articles/buildpack-api -# Usage: -# -# $ bin/compile - -# Fail fast and fail hard. -set -eo pipefail - -# Prepend proper path for virtualenv hackery. This will be deprecated soon. -export PATH=:/usr/local/bin:$PATH - -# Paths. -BIN_DIR=$(cd $(dirname $0); pwd) # absolute path -ROOT_DIR=$(dirname $BIN_DIR) -BUILD_DIR=$1 -CACHE_DIR=$2 -ENV_DIR=$3 - - -CACHED_DIRS=".heroku" - -# Static configurations for virtualenv caches. -VIRTUALENV_LOC=".heroku/venv" -LEGACY_TRIGGER="lib/python2.7" -PROFILE_PATH="$BUILD_DIR/.profile.d/python.sh" - -DEFAULT_PYTHON_VERSION="python-2.7.8" -DEFAULT_PYTHON_STACK="cedar" -PYTHON_EXE="/app/.heroku/python/bin/python" -PIP_VERSION="1.5.6" -SETUPTOOLS_VERSION="7.0" - -# Setup bpwatch -export PATH=$PATH:$ROOT_DIR/vendor/bpwatch -LOGPLEX_KEY="t.b90d9d29-5388-4908-9737-b4576af1d4ce" -export BPWATCH_STORE_PATH=$CACHE_DIR/bpwatch.json -BUILDPACK_VERSION=v28 - -# Setup pip-pop (pip-diff) -export PATH=$PATH:$ROOT_DIR/vendor/pip-pop - -# Support Anvil Build_IDs -[ ! "$SLUG_ID" ] && SLUG_ID="defaultslug" -[ ! "$REQUEST_ID" ] && REQUEST_ID=$SLUG_ID -[ ! "$STACK" ] && STACK=$DEFAULT_PYTHON_STACK - -# Sanitizing environment variables. -unset GIT_DIR PYTHONHOME PYTHONPATH LD_LIBRARY_PATH LIBRARY_PATH - -bpwatch init $LOGPLEX_KEY -bpwatch build python $BUILDPACK_VERSION $REQUEST_ID -TMP_APP_DIR=$CACHE_DIR/tmp_app_dir - -bpwatch start compile - - -# We'll need to send these statics to other scripts we `source`. -export BUILD_DIR CACHE_DIR BIN_DIR PROFILE_PATH - -# Syntax sugar. -source $BIN_DIR/utils - -# Directory Hacks for path consistiency. -APP_DIR='/app' -TMP_APP_DIR=$CACHE_DIR/tmp_app_dir - -# Copy Anvil app dir to temporary storage... -bpwatch start anvil_appdir_stage - if [ "$SLUG_ID" ]; then - mkdir -p $TMP_APP_DIR - deep-mv $APP_DIR $TMP_APP_DIR - else - deep-rm $APP_DIR - fi -bpwatch stop anvil_appdir_stage - -# Copy Application code in. -bpwatch start appdir_stage - deep-mv $BUILD_DIR $APP_DIR -bpwatch stop appdir_stage - -# Set new context. -ORIG_BUILD_DIR=$BUILD_DIR -BUILD_DIR=$APP_DIR +# We use `errtrace` and `inherit_errexit` to ensure the ERR trap and exit on error behaviour +# propagates to buildpack functions run in subshells, such when using command substitutions. +set -eu -o pipefail -o errtrace +shopt -s inherit_errexit -# Prepend proper path buildpack use. -export PATH=$BUILD_DIR/.heroku/python/bin:$BUILD_DIR/.heroku/vendor/bin:$PATH -export PYTHONUNBUFFERED=1 -export LANG=en_US.UTF-8 -export C_INCLUDE_PATH=/app/.heroku/vendor/include:$BUILD_DIR/.heroku/vendor/include:/app/.heroku/python/include -export CPLUS_INCLUDE_PATH=/app/.heroku/vendor/include:$BUILD_DIR/.heroku/vendor/include:/app/.heroku/python/include -export LIBRARY_PATH=/app/.heroku/vendor/lib:$BUILD_DIR/.heroku/vendor/lib:/app/.heroku/python/lib -export LD_LIBRARY_PATH=/app/.heroku/vendor/lib:$BUILD_DIR/.heroku/vendor/lib:/app/.heroku/python/lib -export PKG_CONFIG_PATH=/app/.heroku/vendor/lib/pkg-config:$BUILD_DIR/.heroku/vendor/lib/pkg-config:/app/.heroku/python/lib/pkg-config - -# Switch to the repo's context. -cd $BUILD_DIR - -# Experimental pre_compile hook. -bpwatch start pre_compile - source $BIN_DIR/steps/hooks/pre_compile -bpwatch stop pre_compile - -# If no requirements given, assume `setup.py develop`. -if [ ! -f requirements.txt ]; then - echo "-e ." > requirements.txt -fi - -# Sticky runtimes. -if [ -f $CACHE_DIR/.heroku/python-version ]; then - DEFAULT_PYTHON_VERSION=$(cat $CACHE_DIR/.heroku/python-version) +# 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 -# Stack fallback for non-declared caches. -if [ -f $CACHE_DIR/.heroku/python-stack ]; then - CACHED_PYTHON_STACK=$(cat $CACHE_DIR/.heroku/python-stack) +BUILD_DIR="${1}" +CACHE_DIR="${2}" +ENV_DIR="${3}" + +# The absolute path to the root of the buildpack. +BUILDPACK_DIR=$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd) + +source "${BUILDPACK_DIR}/bin/utils" +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 + +# 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 +# as clean and pristine as possible. +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 +# to `/app`. +# Symlinks are required, since Python is not a portable installation. +# More on this topic later. +mkdir -p /app/.heroku + +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" +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}" +# Tell Python to not buffer it's stdin/stdout. +export PYTHONUNBUFFERED=1 +# 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=$DEFAULT_PYTHON_STACK + build_data::set_raw "python_version_pinned" "false" fi -# If no runtime given, assume default version. -if [ ! -f runtime.txt ]; then - echo $DEFAULT_PYTHON_VERSION > runtime.txt +# 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}" +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}" + +cache::restore "${BUILD_DIR}" "${CACHE_DIR}" "${STACK}" "${cached_python_full_version}" "${python_full_version}" "${package_manager}" + +# 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 fi -# ### The Cache -mkdir -p $CACHE_DIR - -# Purge "old-style" virtualenvs. -bpwatch start clear_old_venvs - [ -d $CACHE_DIR/$LEGACY_TRIGGER ] && rm -fr $CACHE_DIR/.heroku/bin $CACHE_DIR/.heroku/lib $CACHE_DIR/.heroku/include - [ -d $CACHE_DIR/$VIRTUALENV_LOC ] && rm -fr $CACHE_DIR/.heroku/venv $CACHE_DIR/.heroku/src -bpwatch stop clear_old_venvs - -# Restore old artifacts from the cache. -bpwatch start restore_cache - for dir in $CACHED_DIRS; do - cp -R $CACHE_DIR/$dir . &> /dev/null || true - done -bpwatch stop restore_cache - -set +e -# Create set-aside `.heroku` folder. -mkdir .heroku &> /dev/null -set -e - -mkdir -p $(dirname $PROFILE_PATH) - -# Install Python. -source $BIN_DIR/steps/python - -# Sanity check for setuptools/distribute. -source $BIN_DIR/steps/setuptools - -# Uninstall removed dependencies with Pip. -source $BIN_DIR/steps/pip-uninstall +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=$(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}" -# Mercurial support. -source $BIN_DIR/steps/mercurial +# Django collectstatic support. +# The buildpack automatically runs collectstatic for Django applications. +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}" +# Tell Python to run in unbuffered mode. +set_env PYTHONUNBUFFERED true +# Tell Python where it lives. +set_env PYTHONHOME "\${HOME}/.heroku/python" +# Set variables for C libraries. +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 +# Tell Python to look for Python modules in the /app dir. Don't change this. +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}" +if [[ \$HOME != "/app" ]]; then + mkdir -p /app/.heroku + ln -nsf "\$HOME/.heroku/python" /app/.heroku/python +fi +EOT + +# 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 +# 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 + 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 -# Pylibmc support. -source $BIN_DIR/steps/pylibmc +# 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}" -# Libffi support. -source $BIN_DIR/steps/cryptography +# Runs a `bin/post_compile` script if found in the app source, allowing build customisation. +hooks::run_hook "post_compile" -# Install dependencies with Pip. -source $BIN_DIR/steps/pip-install +cache::save "${BUILD_DIR}" "${CACHE_DIR}" "${STACK}" "${python_full_version}" "${package_manager}" -# Django collectstatic support. -sub-env $BIN_DIR/steps/collectstatic +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. -# ### Finalize -# + If you haven't tried it yet, we suggest you take a look! + https://docs.astral.sh/uv/ + EOF +fi -# Set context environment variables. -set-env PATH '$HOME/.heroku/python/bin:$PATH' -set-env PYTHONUNBUFFERED true -set-env PYTHONHOME /app/.heroku/python -set-env LIBRARY_PATH /app/.heroku/vendor/lib:/app/.heroku/python/lib -set-env LD_LIBRARY_PATH '/app/.heroku/vendor/lib:/app/.heroku/python/lib:$LD_LIBRARY_PATH' -set-default-env LANG en_US.UTF-8 -set-default-env PYTHONHASHSEED random -set-default-env PYTHONPATH /app/ - - -# Experimental post_compile hook. -bpwatch start post_compile - source $BIN_DIR/steps/hooks/post_compile -bpwatch stop post_compile - -# Store new artifacts in cache. -bpwatch start dump_cache - for dir in $CACHED_DIRS; do - rm -rf $CACHE_DIR/$dir - cp -R $dir $CACHE_DIR/ - done -bpwatch stop dump_cache - -# ### Fin. -bpwatch start appdir_commit - deep-mv $BUILD_DIR $ORIG_BUILD_DIR -bpwatch stop appdir_commit - -bpwatch start anvil_appdir_commit - if [ "$SLUG_ID" ]; then - deep-mv $TMP_APP_DIR $APP_DIR - fi - -bpwatch stop anvil_appdir_commit -bpwatch stop compile +build_data::set_duration "total_duration" "${compile_start_time}" diff --git a/bin/detect b/bin/detect index 818ed74e9..86da2ddc0 100755 --- a/bin/detect +++ b/bin/detect @@ -1,22 +1,99 @@ #!/usr/bin/env bash +# Usage: bin/detect +# See: https://devcenter.heroku.com/articles/buildpack-api -# This script serves as the -# [**Python Buildpack**](https://github.com/heroku/heroku-buildpack-python) -# detector. -# -# A [buildpack](http://devcenter.heroku.com/articles/buildpacks) is an -# adapter between a Python application and Heroku's runtime. +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) -# ## Usage -# Compiling an app into a slug is simple: +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. # -# $ bin/detect +# This list is deliberately larger than just the list of supported package manager files, +# 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 + __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 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 + +# 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 upfront. +output::error < +# Usage: bin/release +# See: https://devcenter.heroku.com/articles/buildpack-api -BIN_DIR=$(cd $(dirname $0); pwd) # absolute path -BUILD_DIR=$1 +set -euo pipefail +shopt -s inherit_errexit -MANAGE_FILE=$(cd $BUILD_DIR && find . -maxdepth 3 -type f -name 'manage.py' | head -1) -MANAGE_FILE=${MANAGE_FILE:2} +BUILD_DIR="${1}" -cat < + +# 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. +# +# 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 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) + +source "${BUILDPACK_DIR}/lib/build_data.sh" + +build_data::print_bin_report_json diff --git a/bin/steps/collectstatic b/bin/steps/collectstatic index 134b5c694..0abed5a56 100755 --- a/bin/steps/collectstatic +++ b/bin/steps/collectstatic @@ -1,36 +1,118 @@ #!/usr/bin/env bash -source $BIN_DIR/utils +# Django Collectstatic runner. If you have Django installed, collectstatic will +# automatically be executed as part of the build process. If collectstatic +# fails, your build fails. -MANAGE_FILE=$(find . -maxdepth 3 -type f -name 'manage.py' | head -1) +# 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 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" + +# 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 + 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 + 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 +fi + +# Location of 'manage.py', if it exists. +MANAGE_FILE=$(find . -maxdepth 3 -type f -name 'manage.py' -printf '%d\t%P\n' | sort -nk1 | cut -f2 | head -1) MANAGE_FILE=${MANAGE_FILE:-fakepath} -[ -f .heroku/collectstatic_disabled ] && DISABLE_COLLECTSTATIC=1 +if [[ ! -f "${MANAGE_FILE}" ]]; then + output::step "Skipping Django collectstatic since no manage.py file found." + build_data::set_string "django_collectstatic" "skipped-no-manage-py" + exit 0 +fi + +build_data::set_string "django_collectstatic" "enabled" + +output::step "$ python ${MANAGE_FILE} collectstatic --noinput" + +PYTHONPATH="${PYTHONPATH:-.}" +export PYTHONPATH +COLLECTSTATIC_LOG=$(mktemp) + +set +e +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 +fi + +# Display a warning if collectstatic failed. +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 + build_data::set_string "failure_reason" "collectstatic-other" +fi + +output::error <<-EOF + Error: Unable to generate Django static files. -bpwatch start collectstatic + The 'python ${MANAGE_FILE} collectstatic --noinput' Django + management command to generate static files failed. -if [ ! "$DISABLE_COLLECTSTATIC" ] && [ -f "$MANAGE_FILE" ]; then - set +e + See the traceback above for details. - echo "-----> Preparing static assets" - # Check if collectstatic is configured properly. - python $MANAGE_FILE collectstatic --dry-run --noinput &> /dev/null && RUN_COLLECTSTATIC=true + You may need to update application code to resolve this error. + Or, you can disable collectstatic for this application: - # Compile assets if collectstatic appears to be kosher. - if [ "$RUN_COLLECTSTATIC" ]; then + $ heroku config:set DISABLE_COLLECTSTATIC=1 - echo " Running collectstatic..." - python $MANAGE_FILE collectstatic --noinput 2>&1 | sed '/^Copying/d;/^$/d;/^ /d' | indent + https://devcenter.heroku.com/articles/django-assets +EOF - [ $? -ne 0 ] && { - echo " ! Error running 'manage.py collectstatic'. More info:" - echo " http://devcenter.heroku.com/articles/django-assets" - } - else - echo " Collectstatic configuration error. To debug, run:" - echo " $ heroku run python $MANAGE_FILE collectstatic --noinput" - fi - echo +# Additionally, dump out the environment, if debug mode is on. +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 -bpwatch stop collectstatic \ No newline at end of file +exit 1 diff --git a/bin/steps/cryptography b/bin/steps/cryptography deleted file mode 100755 index 080360fc4..000000000 --- a/bin/steps/cryptography +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -# This script serves as the Pylibmc build step of the -# [**Python Buildpack**](https://github.com/heroku/heroku-buildpack-python) -# compiler. -# -# A [buildpack](http://devcenter.heroku.com/articles/buildpacks) is an -# adapter between a Python application and Heroku's runtime. -# -# This script is invoked by [`bin/compile`](/). - -# The location of the pre-compiled cryptography binary. -VENDORED_LIBFFI="http://lang-python.s3.amazonaws.com/$STACK/libraries/vendor/libffi.tar.gz" - -PKG_CONFIG_PATH="/app/.heroku/vendor/lib/pkgconfig:$PKG_CONFIG_PATH" - -# Syntax sugar. -source $BIN_DIR/utils - -bpwatch start libffi_install - -# If pylibmc exists within requirements, use vendored cryptography. -if (pip-grep -s requirements.txt bcrypt cffi cryptography &> /dev/null) then - - if [ -d ".heroku/vendor/lib/libffi-3.1.1" ]; then - export LIBFFI=$(pwd)/vendor - else - echo "-----> Noticed cffi. Bootstrapping libffi." - mkdir -p .heroku/vendor - # Download and extract cryptography into target vendor directory. - curl $VENDORED_LIBFFI -s | tar zxv -C .heroku/vendor &> /dev/null - - export LIBFFI=$(pwd)/vendor - fi -fi - -bpwatch stop libffi_install diff --git a/bin/steps/hooks/post_compile b/bin/steps/hooks/post_compile deleted file mode 100755 index e4afc09e9..000000000 --- a/bin/steps/hooks/post_compile +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -if [ -f bin/post_compile ]; then - echo "-----> Running post-compile hook" - chmod +x bin/post_compile - sub-env bin/post_compile -fi \ No newline at end of file diff --git a/bin/steps/hooks/pre_compile b/bin/steps/hooks/pre_compile deleted file mode 100755 index 86c706b65..000000000 --- a/bin/steps/hooks/pre_compile +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -if [ -f bin/pre_compile ]; then - echo "-----> Running pre-compile hook" - chmod +x bin/pre_compile - sub-env bin/pre_compile -fi \ No newline at end of file diff --git a/bin/steps/mercurial b/bin/steps/mercurial deleted file mode 100755 index 505aad603..000000000 --- a/bin/steps/mercurial +++ /dev/null @@ -1,6 +0,0 @@ -# Install Mercurial if it appears to be required. -if (grep -Fiq "hg+" requirements.txt) then - bpwatch start mercurial_install - /app/.heroku/python/bin/pip install mercurial | cleanup | indent - bpwatch stop mercurial_install -fi \ No newline at end of file diff --git a/bin/steps/nltk b/bin/steps/nltk new file mode 100755 index 000000000..a1317ebf7 --- /dev/null +++ b/bin/steps/nltk @@ -0,0 +1,49 @@ +#!/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: 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" +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" +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 + 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[*]}" + + nltk_data_dir="/app/.heroku/python/nltk_data" + + # 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 "${nltk_data_dir}" "${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 + + set_env NLTK_DATA "${nltk_data_dir}" + 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 00aabd79d..000000000 --- a/bin/steps/pip-install +++ /dev/null @@ -1,16 +0,0 @@ -# Install dependencies with Pip. -puts-step "Installing dependencies with pip" - -[ ! "$FRESH_PYTHON" ] && bpwatch start pip_install -[ "$FRESH_PYTHON" ] && bpwatch start pip_install_first - -/app/.heroku/python/bin/pip install -r requirements.txt --exists-action=w --src=./.heroku/src --allow-all-external | cleanup | indent - -# Smart Requirements handling -cp requirements.txt .heroku/python/requirements-declared.txt -/app/.heroku/python/bin/pip freeze > .heroku/python/requirements-installed.txt - -[ ! "$FRESH_PYTHON" ] && bpwatch stop pip_install -[ "$FRESH_PYTHON" ] && bpwatch stop pip_install_first - -echo \ No newline at end of file diff --git a/bin/steps/pip-uninstall b/bin/steps/pip-uninstall deleted file mode 100755 index 49124f174..000000000 --- a/bin/steps/pip-uninstall +++ /dev/null @@ -1,18 +0,0 @@ -set +e -# Install dependencies with Pip. -bpwatch start pip_uninstall -if [[ -f .heroku/python/requirements-declared.txt ]]; then - - cp .heroku/python/requirements-declared.txt requirements-declared.txt - - pip-diff --stale requirements-declared.txt requirements.txt > .heroku/python/requirements-stale.txt - - rm -fr requirements-declared.txt - - if [[ -s .heroku/python/requirements-stale.txt ]]; then - puts-step "Uninstalling stale dependencies" - /app/.heroku/python/bin/pip uninstall -r .heroku/python/requirements-stale.txt -y --exists-action=w | cleanup | indent - fi -fi -bpwatch stop pip_uninstall -set -e \ No newline at end of file diff --git a/bin/steps/pylibmc b/bin/steps/pylibmc deleted file mode 100755 index 18983029e..000000000 --- a/bin/steps/pylibmc +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -# This script serves as the Pylibmc build step of the -# [**Python Buildpack**](https://github.com/heroku/heroku-buildpack-python) -# compiler. -# -# A [buildpack](http://devcenter.heroku.com/articles/buildpacks) is an -# adapter between a Python application and Heroku's runtime. -# -# This script is invoked by [`bin/compile`](/). - -# The location of the pre-compiled libmemcached binary. -VENDORED_MEMCACHED="http://lang-python.s3.amazonaws.com/$STACK/libraries/vendor/libmemcache.tar.gz" - -# Syntax sugar. -source $BIN_DIR/utils - - -bpwatch start pylibmc_install - -# If pylibmc exists within requirements, use vendored libmemcached. -if (pip-grep -s requirements.txt pylibmc &> /dev/null) then - - if [ -d ".heroku/vendor/lib/sasl2" ]; then - export LIBMEMCACHED=$(pwd)/vendor - else - echo "-----> Noticed pylibmc. Bootstrapping libmemcached." - mkdir -p .heroku/vendor - # Download and extract libmemcached into target vendor directory. - curl $VENDORED_MEMCACHED -s | tar zxv -C .heroku/vendor &> /dev/null - - export LIBMEMCACHED=$(pwd)/vendor - fi -fi - -bpwatch stop pylibmc_install diff --git a/bin/steps/python b/bin/steps/python deleted file mode 100755 index d36b3164c..000000000 --- a/bin/steps/python +++ /dev/null @@ -1,79 +0,0 @@ -set +e -PYTHON_VERSION=$(cat runtime.txt) - -# Install Python. -if [ -f .heroku/python-version ]; then - if [ ! $(cat .heroku/python-version) = $PYTHON_VERSION ]; then - bpwatch start uninstall_python - puts-step "Found runtime $(cat .heroku/python-version), removing" - rm -fr .heroku/python - bpwatch stop uninstall_python - else - SKIP_INSTALL=1 - fi -fi - -if [ ! $STACK = $CACHED_PYTHON_STACK ]; then - bpwatch start uninstall_python - puts-step "Stack changed, re-installing runtime" - rm -fr .heroku/python - unset SKIP_INSTALL - bpwatch stop uninstall_python -fi - - -if [ ! "$SKIP_INSTALL" ]; then - bpwatch start install_python - puts-step "Installing runtime ($PYTHON_VERSION)" - - # Prepare destination directory. - mkdir -p .heroku/python - - curl http://lang-python.s3.amazonaws.com/$STACK/runtimes/$PYTHON_VERSION.tar.gz -s | tar zxv -C .heroku/python &> /dev/null - if [[ $? != 0 ]] ; then - puts-warn "Requested runtime ($PYTHON_VERSION) is not available for this stack ($STACK)." - puts-warn "Aborting. More info: https://devcenter.heroku.com/articles/python-support" - exit 1 - fi - - bpwatch stop install_python - - # Record for future reference. - echo $PYTHON_VERSION > .heroku/python-version - echo $STACK > .heroku/python-stack - FRESH_PYTHON=true - - hash -r -fi - -# If Pip isn't up to date: -if [ "$FRESH_PYTHON" ] || [[ ! $(pip --version) == *$PIP_VERSION* ]]; then - WORKING_DIR=$(pwd) - - bpwatch start prepare_environment - - TMPTARDIR=$(mktemp -d) - trap "rm -rf $TMPTARDIR" RETURN - - bpwatch start install_setuptools - # Prepare it for the real world - # puts-step "Installing Setuptools ($SETUPTOOLS_VERSION)" - tar zxf $ROOT_DIR/vendor/setuptools-$SETUPTOOLS_VERSION.tar.gz -C $TMPTARDIR - cd $TMPTARDIR/setuptools-$SETUPTOOLS_VERSION/ - python setup.py install &> /dev/null - cd $WORKING_DIR - bpwatch stop install_setuptoools - - bpwatch start install_pip - # puts-step "Installing Pip ($PIP_VERSION)" - tar zxf $ROOT_DIR/vendor/pip-$PIP_VERSION.tar.gz -C $TMPTARDIR - cd $TMPTARDIR/pip-$PIP_VERSION/ - python setup.py install &> /dev/null - cd $WORKING_DIR - - bpwatch stop install_pip - bpwatch stop prepare_environment -fi - -set -e -hash -r diff --git a/bin/steps/setuptools b/bin/steps/setuptools deleted file mode 100755 index 870cfd4a5..000000000 --- a/bin/steps/setuptools +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -# Syntax sugar. -source $BIN_DIR/utils - -if (pip-grep -s requirements.txt setuptools distribute &> /dev/null) then - - puts-warn 'The package setuptools/distribute is listed in requirements.txt.' - puts-warn 'Please remove to ensure expected behavior. ' - -fi diff --git a/bin/test b/bin/test deleted file mode 100755 index 93289c7c3..000000000 --- a/bin/test +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash - -# -# Create a Heroku app with the following buildpack: -# https://github.com/ddollar/buildpack-test -# -# Push this Python buildpack to that Heroku app to -# run the tests. -# - -testDetectWithReqs() { - detect "simple-requirements" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectWithEmptyReqs() { - detect "empty-requirements" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectDjango16() { - detect "django-1.6-skeleton" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectDjango15() { - detect "django-1.5-skeleton" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectDjango14() { - detect "django-1.4-skeleton" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectDjango13() { - detect "django-1.3-skeleton" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectNotDjangoWithSettings() { - detect "not-django" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectWithSetupPy() { - detect "distutils" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectWithSetupRequires() { - detect "no-requirements" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectNotPython() { - detect "not-python" - assertNotCaptured "Python" - assertEquals "1" "${RETURN}" -} - -testDetectSimpleRuntimePypy2() { - detect "simple-runtime-pypy2" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectSimpleRuntimePython2() { - detect "simple-runtime-python2" - assertCapturedEquals "Python" - assertCapturedSuccess -} - -testDetectSimpleRuntimePython3() { - detect "simple-runtime" # should probably be renamed simple-runtime-python3 - assertCapturedEquals "Python" - assertCapturedSuccess -} - -## utils ######################################## - -pushd $(dirname 0) >/dev/null -BASE=$(pwd) -popd >/dev/null - -source ${BASE}/vendor/test-utils - -detect() { - capture ${BASE}/bin/detect ${BASE}/test/$1 -} - -compile() { - capture ${BASE}/bin/compile ${BASE}/test/$1 -} - -source ${BASE}/vendor/shunit2 - diff --git a/bin/test-compile b/bin/test-compile new file mode 100755 index 000000000..1fef46275 --- /dev/null +++ b/bin/test-compile @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Usage: bin/test-compile +# 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) + +# Locale support for Pipenv. +export LC_ALL=C.UTF-8 +export LANG=C.UTF-8 + +DISABLE_COLLECTSTATIC=1 INSTALL_TEST=1 "${BUILDPACK_DIR}/bin/compile" "${1}" "${2}" "${3}" diff --git a/bin/utils b/bin/utils index a57d54e74..3eedc1f81 100755 --- a/bin/utils +++ b/bin/utils @@ -1,93 +1,19 @@ -shopt -s extglob - -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 '/Overwriting/Id' | sed -e '/python executable/Id' | sed -e '/no previously-included files/Id' -} - -# Buildpack Steps. -puts-step() { - echo "-----> $@" -} - -# Buildpack Warnings. -puts-warn() { - echo " ! $@" -} - -# Usage: $ set-env key value -set-env() { - echo "export $1=$2" >> $PROFILE_PATH -} - -# Usage: $ set-default-env key value -set-default-env() { - echo "export $1=\${$1:-$2}" >> $PROFILE_PATH -} - -# Usage: $ set-default-env key value -un-set-env() { - echo "unset $1" >> $PROFILE_PATH -} - -# Does some serious copying. -deep-cp() { - declare source="$1" target="$2" +#!/usr/bin/env bash - mkdir -p "$target" +# Be careful about moving these to bin/compile, since this utils script is sourced +# directly by other scripts run via subshells, and not only from bin/compile. +shopt -s extglob +shopt -s nullglob - # 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" -# Does some serious moving. -deep-mv() { - deep-cp "$1" "$2" - deep-rm "$1" +# Measure the size of the Python installation. +measure-size() { + { du -s .heroku/python 2>/dev/null || echo 0; } | awk '{print $1}' } -# Does some serious deleting. -deep-rm() { - # subshell to avoid surprising caller with shopts. - ( - shopt -s dotglob - rm -rf "$1"/!(tmp|.|..) - ) -} - - -sub-env() { - - WHITELIST=${2:-''} - BLACKLIST=${3:-'^(GIT_DIR|PYTHONHOME|LD_LIBRARY_PATH|LIBRARY_PATH|PATH)$'} - - ( - if [ -d "$ENV_DIR" ]; then - for e in $(ls $ENV_DIR); do - echo "$e" | grep -E "$WHITELIST" | grep -qvE "$BLACKLIST" && - export "$e=$(cat $ENV_DIR/$e)" - : - done - fi - - $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)" } diff --git a/bin/warnings b/bin/warnings new file mode 100755 index 000000000..04b453a2d --- /dev/null +++ b/bin/warnings @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +show-warnings() { + 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 new file mode 100644 index 000000000..4ccd6f242 --- /dev/null +++ b/buildpack.toml @@ -0,0 +1,19 @@ +[buildpack] +name = "Python" + +[publish.Ignore] +files = [ + ".github/", + "builds/", + "etc/publish.sh", + "spec/", + ".editorconfig", + ".gitignore", + ".rubocop.yml", + ".shellcheckrc", + "Gemfile", + "Gemfile.lock", + "hatchet.json", + "hatchet.lock", + "Makefile", +] diff --git a/builds/Dockerfile b/builds/Dockerfile new file mode 100644 index 000000000..be7742e9e --- /dev/null +++ b/builds/Dockerfile @@ -0,0 +1,20 @@ +ARG STACK_VERSION="24" +FROM ghcr.io/sigstore/cosign/cosign:v3.0.4@sha256:0b015a3557a64a751712da8a6395534160018eaaa2d969882a85a336de9adb70 AS cosign +FROM heroku/heroku:${STACK_VERSION}-build + +ARG STACK_VERSION +ENV STACK="heroku-${STACK_VERSION}" + +# For Heroku-24 and newer, the build image sets a non-root default `USER`. +USER root + +RUN apt-get update --error-on=any \ + && apt-get install -y --no-install-recommends \ + 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/README.md b/builds/README.md index a33c3adb2..772d17858 100644 --- a/builds/README.md +++ b/builds/README.md @@ -1,31 +1,11 @@ # Python Buildpack Binaries +The binaries for this buildpack are built on GitHub Actions, inside Docker containers based on the Heroku stack image. -To get started with it, create an app on Heroku inside a clone of this repository, and set your S3 config vars: +Users with suitable repository access can trigger builds by: - $ heroku create --buildpack https://github.com/heroku/heroku-buildpack-python#not-heroku - $ heroku config:set WORKSPACE_DIR=builds - $ heroku config:set AWS_ACCESS_KEY_ID= - $ heroku config:set AWS_SECRET_ACCESS_KEY= - $ heroku config:set S3_BUCKET= - - -Then, shell into an instance and run a build by giving the name of the formula inside `builds`: - - $ heroku run bash - Running `bash` attached to terminal... up, run.6880 - ~ $ bob build runtimes/python-2.7.6 - - Fetching dependencies... found 2: - - libraries/sqlite - - Building formula runtimes/python-2.7.6: - === Building Python 2.7.6 - Fetching Python v2.7.6 source... - Compiling... - -If this works, run `bob deploy` instead of `bob build` to have the result uploaded to S3 for you. - -To speed things up drastically, it'll usually be a good idea to `heroku run bash --size PX` instead. - -Enjoy :) \ No newline at end of file +1. Navigating to the [Build and upload Python runtime](https://github.com/heroku/heroku-buildpack-python/actions/workflows/build_python_runtime.yml) workflow. +2. Opening the "Run workflow" prompt. +3. Entering the desired Python version. +4. Optionally checking the "Skip deploying" checkbox (if testing) +5. Clicking "Run workflow". diff --git a/builds/build_python_runtime.sh b/builds/build_python_runtime.sh new file mode 100755 index 000000000..461d8d24b --- /dev/null +++ b/builds/build_python_runtime.sh @@ -0,0 +1,210 @@ +#!/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%.*}" + +ARCH=$(dpkg --print-architecture) + +# Python is relocated to different locations by the classic buildpack and CNB (which works since we +# set `LD_LIBRARY_PATH` and `PYTHONHOME` appropriately at build/run-time), so for packaging purposes +# we install Python into an arbitrary location that intentionally matches neither location. +INSTALL_DIR="/tmp/python" +SRC_DIR="/tmp/src" +UPLOAD_DIR="/tmp/upload" + +function abort() { + echo "Error: ${1}" >&2 + exit 1 +} + +case "${STACK:?}" in + heroku-22 | heroku-24) + 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 + abort "Python ${PYTHON_MAJOR_VERSION} isn't supported on ${STACK}!" +fi + +# Sigstore identities taken from: https://www.python.org/downloads/metadata/sigstore/ +case "${PYTHON_MAJOR_VERSION}" in + 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" +SIGSTORE_BUNDLE_URL="${SOURCE_URL}.sigstore" + +set -o xtrace + +mkdir -p "${SRC_DIR}" "${INSTALL_DIR}" "${UPLOAD_DIR}" + +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}" + +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}" + +# Aim to keep this roughly consistent with the options used in the Python Docker images, +# 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" + # 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 is 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.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[@]}" + +# `-Wl,--strip-all` instructs the linker to omit all symbol information from the final binary +# and shared libraries, to reduce the size of the build. We have to use `--strip-all` and +# not `--strip-unneeded` since `ld` only understands the former (unlike the `strip` command), +# however, `--strip-all` is safe to use since LDFLAGS doesn't apply to static libraries. +# `dpkg-buildflags` returns the distro's default compiler/linker options, which enable various +# security/hardening best practices. See: +# - https://wiki.ubuntu.com/ToolChain/CompilerFlags +# - https://wiki.debian.org/Hardening +# - https://github.com/docker-library/python/issues/810 +EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)" +LDFLAGS="$(dpkg-buildflags --get LDFLAGS) -Wl,--strip-all" + +CPU_COUNT="$(nproc)" +make -j "${CPU_COUNT}" "EXTRA_CFLAGS=${EXTRA_CFLAGS}" "LDFLAGS=${LDFLAGS}" +make install + +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: +# https://github.com/docker-library/python +# This is a no-op on Python 3.11+, since --disable-test-modules will have prevented +# the test files from having been built in the first place. +find "${INSTALL_DIR}" -depth -type d -a \( -name 'test' -o -name 'tests' -o -name 'idle_test' \) -print -exec rm -rf '{}' + + +# The `make install` step automatically generates `.pyc` files for the stdlib, however: +# - It generates these using the default `timestamp` invalidation mode, which does +# not work well with the CNB file timestamp normalisation behaviour. As such, we +# must use one of the hash-based invalidation modes to prevent the `.pyc`s from +# always being treated as outdated and so being regenerated at application boot. +# - It generates `.pyc`s for all three optimisation levels (standard, -O and -OO), +# when the vast majority of apps only use the standard mode. As such, we can skip +# regenerating/shipping those `.opt-{1,2}.pyc` files, reducing build output by 18MB. +# +# We use the `unchecked-hash` mode rather than `checked-hash` since it improves app startup +# times by ~5%, and is only an issue if manual edits are made to the stdlib, which is not +# something we support. +# +# See: +# https://docs.python.org/3/reference/import.html#cached-bytecode-invalidation +# https://docs.python.org/3/library/compileall.html +# https://peps.python.org/pep-0488/ +# https://peps.python.org/pep-0552/ +find "${INSTALL_DIR}" -depth -type f -name "*.pyc" -delete +# We use the Python binary from the original build output in the source directory, +# rather than the installed binary in `$INSTALL_DIR`, for parity with the automatic +# `.pyc` generation run by `make install`: +# 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. +# This symlink must be relative, to ensure that the Python install remains relocatable. +ln -srvT "${INSTALL_DIR}/bin/python3" "${INSTALL_DIR}/bin/python" + +# Results in a compressed archive filename of form: 'python-X.Y.Z-ubuntu-22.04-amd64.tar.zst' +UBUNTU_VERSION=$(lsb_release --short --release 2>/dev/null) +TAR_FILEPATH="${UPLOAD_DIR}/python-${PYTHON_VERSION}-ubuntu-${UBUNTU_VERSION}-${ARCH}.tar" +tar --create --format=pax --sort=name --file "${TAR_FILEPATH}" --directory="${INSTALL_DIR}" . +zstd -T0 -22 --ultra --long --no-progress --rm "${TAR_FILEPATH}" + +du --max-depth 1 --human-readable "${INSTALL_DIR}" +du --all --human-readable "${UPLOAD_DIR}" diff --git a/builds/libraries/autoconf b/builds/libraries/autoconf deleted file mode 100755 index 50d7d82e8..000000000 --- a/builds/libraries/autoconf +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ - -OUT_PREFIX=$1 - -echo "Building autoconf..." - - -SOURCE_TARBALL='http://ftp.gnu.org/gnu/autoconf/autoconf-2.68.tar.gz' -curl -L $SOURCE_TARBALL | tar xz - -cd autoconf-2.68 -./configure --prefix=$OUT_PREFIX -make -make install \ No newline at end of file diff --git a/builds/libraries/libffi b/builds/libraries/libffi deleted file mode 100755 index 51789cfd4..000000000 --- a/builds/libraries/libffi +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ - -OUT_PREFIX=$1 - -# Use new path, containing autoconf. -export PATH="/app/.heroku/python/bin/:$PATH" -hash -r - - -echo "Building libffi..." - -SOURCE_TARBALL='http://cl.ly/2s1t1u3v0N0I/download/libffi-3.1.tar' - -curl -L $SOURCE_TARBALL | tar x - -cd libffi-3.1 -./configure --prefix=$OUT_PREFIX --disable-static && -make -make install - -# Cleanup -cd .. diff --git a/builds/libraries/sqlite b/builds/libraries/sqlite deleted file mode 100755 index 026a448e9..000000000 --- a/builds/libraries/sqlite +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ - -OUT_PREFIX=$1 - -echo "Building SQLite..." - - -SOURCE_TARBALL='http://www.sqlite.org/sqlite-autoconf-3070900.tar.gz' - -curl $SOURCE_TARBALL | tar xz -# jx -mv sqlite-autoconf-3070900 sqlite - -cd sqlite -./configure --prefix=$OUT_PREFIX -make -make install - -# Cleanup -cd .. -rm -fr sqlite diff --git a/builds/libraries/vendor/libffi b/builds/libraries/vendor/libffi deleted file mode 100755 index 6cf394a0c..000000000 --- a/builds/libraries/vendor/libffi +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/vendor/ - -OUT_PREFIX=$1 - -# Use new path, containing autoconf. -export PATH="/app/.heroku/python/bin/:$PATH" -hash -r - - -echo "Building libffi..." - -SOURCE_TARBALL='ftp://sourceware.org/pub/libffi/libffi-3.1.tar.gz' - -curl -L $SOURCE_TARBALL | tar x - -cd libffi-3.1 -./configure --prefix=$OUT_PREFIX --disable-static && -make -make install - -# Cleanup -cd .. diff --git a/builds/libraries/vendor/libmemcache b/builds/libraries/vendor/libmemcache deleted file mode 100755 index a4b0ea973..000000000 --- a/builds/libraries/vendor/libmemcache +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/vendor/ - -OUT_PREFIX=$1 - -# fail hard -set -o pipefail -# fail harder -set -eux - -DEFAULT_VERSION="1.0.18" -dep_version=${VERSION:-$DEFAULT_VERSION} -dep_dirname=libmemcached-${dep_version} -dep_archive_name=${dep_dirname}.tar.gz -dep_url=https://launchpad.net/libmemcached/1.0/${dep_version}/+download/${dep_archive_name} - -# SASL Support. -echo "-----> Building cyrus-sasl 2.1.26..." - -curl -LO ftp://ftp.cyrusimap.org/cyrus-sasl/cyrus-sasl-2.1.26.tar.gz -# FTP doesn't play well with piping into tar xz -tar xzf cyrus-sasl-2.1.26.tar.gz - -pushd cyrus-sasl-2.1.26 -./configure --prefix=${OUT_PREFIX} --with-plugindir=${OUT_PREFIX}lib/sasl2 --with-configdir=${OUT_PREFIX}lib/sasl2 - -make -s -j 9 -make install -s -popd - -echo "-----> Building libmemcached ${dep_version}..." - -curl -L ${dep_url} | tar xz -pushd ${dep_dirname} -CPPFLAGS=-I${OUT_PREFIX}/include LDFLAGS=-L${OUT_PREFIX}/lib ./configure --prefix=${OUT_PREFIX} --without-memcached -make -s -j 9 -make install -s -popd - -echo "-----> Done." \ No newline at end of file diff --git a/builds/runtimes/pypy-1.7 b/builds/runtimes/pypy-1.7 deleted file mode 100755 index 10b44f734..000000000 --- a/builds/runtimes/pypy-1.7 +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-1.7-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-1.7/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python \ No newline at end of file diff --git a/builds/runtimes/pypy-1.8 b/builds/runtimes/pypy-1.8 deleted file mode 100755 index 5a517b607..000000000 --- a/builds/runtimes/pypy-1.8 +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-1.8-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-1.8/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-1.9 b/builds/runtimes/pypy-1.9 deleted file mode 100755 index 2ff5d52e7..000000000 --- a/builds/runtimes/pypy-1.9 +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-1.9-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-1.9/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.0 b/builds/runtimes/pypy-2.0 deleted file mode 100755 index 922f57325..000000000 --- a/builds/runtimes/pypy-2.0 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.0-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.0/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.0.1 b/builds/runtimes/pypy-2.0.1 deleted file mode 100755 index 0a87a4862..000000000 --- a/builds/runtimes/pypy-2.0.1 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.0.1-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.0.1/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.0.2 b/builds/runtimes/pypy-2.0.2 deleted file mode 100755 index b15317856..000000000 --- a/builds/runtimes/pypy-2.0.2 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.0.2-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.0.2/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.1 b/builds/runtimes/pypy-2.1 deleted file mode 100755 index d811c0a9b..000000000 --- a/builds/runtimes/pypy-2.1 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.1-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.1/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.2 b/builds/runtimes/pypy-2.2 deleted file mode 100755 index ad06362dd..000000000 --- a/builds/runtimes/pypy-2.2 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.2-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.2-linux64/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.2.1 b/builds/runtimes/pypy-2.2.1 deleted file mode 100755 index 673357009..000000000 --- a/builds/runtimes/pypy-2.2.1 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.2.1-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.2.1-linux64/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.3 b/builds/runtimes/pypy-2.3 deleted file mode 100755 index 27a8bbe70..000000000 --- a/builds/runtimes/pypy-2.3 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite, libraries/libffi - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.3-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.3-linux64/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.3.1 b/builds/runtimes/pypy-2.3.1 deleted file mode 100755 index dd7576dbd..000000000 --- a/builds/runtimes/pypy-2.3.1 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.3.1-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.3.1-linux64/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy-2.4.0 b/builds/runtimes/pypy-2.4.0 deleted file mode 100755 index 0a80c666a..000000000 --- a/builds/runtimes/pypy-2.4.0 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy-2.4.0-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy-2.4.0-linux64/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/pypy3-2.3.1 b/builds/runtimes/pypy3-2.3.1 deleted file mode 100755 index bbe5fea78..000000000 --- a/builds/runtimes/pypy3-2.3.1 +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar-14 stack, not cedar. - -OUT_PREFIX=$1 - -echo "Building PyPy..." -SOURCE_TARBALL='https://bitbucket.org/pypy/pypy/downloads/pypy3-2.3.1-linux64.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -cp -R pypy3-2.3.1-linux64/* $OUT_PREFIX - -ln $OUT_PREFIX/bin/pypy $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-2.4.4 b/builds/runtimes/python-2.4.4 deleted file mode 100755 index 84addd6f7..000000000 --- a/builds/runtimes/python-2.4.4 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.4 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.4.4/Python-2.4.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.4.4 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.4.5 b/builds/runtimes/python-2.4.5 deleted file mode 100755 index b4555d0e1..000000000 --- a/builds/runtimes/python-2.4.5 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.4 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.4.5/Python-2.4.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.4.5 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.4.6 b/builds/runtimes/python-2.4.6 deleted file mode 100755 index ffebad19f..000000000 --- a/builds/runtimes/python-2.4.6 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.4 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.4.6/Python-2.4.6.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.4.6 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.0 b/builds/runtimes/python-2.5.0 deleted file mode 100755 index 2b31b9607..000000000 --- a/builds/runtimes/python-2.5.0 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.5 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5/Python-2.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.1 b/builds/runtimes/python-2.5.1 deleted file mode 100755 index 775b6130d..000000000 --- a/builds/runtimes/python-2.5.1 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.5 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5.1/Python-2.5.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5.1 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.2 b/builds/runtimes/python-2.5.2 deleted file mode 100755 index 84bb09422..000000000 --- a/builds/runtimes/python-2.5.2 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.5 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5.2/Python-2.5.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5.2 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.3 b/builds/runtimes/python-2.5.3 deleted file mode 100755 index 7ff33b445..000000000 --- a/builds/runtimes/python-2.5.3 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.5 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5.3/Python-2.5.3.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5.3 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.4 b/builds/runtimes/python-2.5.4 deleted file mode 100755 index 063c4f628..000000000 --- a/builds/runtimes/python-2.5.4 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.5 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5.4/Python-2.5.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5.4 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.5 b/builds/runtimes/python-2.5.5 deleted file mode 100755 index 9c9ee091f..000000000 --- a/builds/runtimes/python-2.5.5 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5.5/Python-2.5.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5.5 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.5.6 b/builds/runtimes/python-2.5.6 deleted file mode 100755 index 6da7ca45b..000000000 --- a/builds/runtimes/python-2.5.6 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.5 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.5.6/Python-2.5.6.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.5.6 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.6.0 b/builds/runtimes/python-2.6.0 deleted file mode 100755 index 353abc6a7..000000000 --- a/builds/runtimes/python-2.6.0 +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar stack, not cedar-14. - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://www.python.org/ftp/python/2.6/Python-2.6.tar.bz2' -curl -L $SOURCE_TARBALL | tar jx -mv Python-2.6 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.1 b/builds/runtimes/python-2.6.1 deleted file mode 100755 index e32cdffac..000000000 --- a/builds/runtimes/python-2.6.1 +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -# NOTICE: This formula only works for the cedar stack, not cedar-14. - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.1/Python-2.6.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.1 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.2 b/builds/runtimes/python-2.6.2 deleted file mode 100755 index dcce19c97..000000000 --- a/builds/runtimes/python-2.6.2 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.2/Python-2.6.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.2 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.3 b/builds/runtimes/python-2.6.3 deleted file mode 100755 index 71839b0a8..000000000 --- a/builds/runtimes/python-2.6.3 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.3/Python-2.6.3.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.3 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.4 b/builds/runtimes/python-2.6.4 deleted file mode 100755 index c1b4f89c5..000000000 --- a/builds/runtimes/python-2.6.4 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.4/Python-2.6.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.4 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.5 b/builds/runtimes/python-2.6.5 deleted file mode 100755 index c681bffa5..000000000 --- a/builds/runtimes/python-2.6.5 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.5/Python-2.6.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.5 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.6 b/builds/runtimes/python-2.6.6 deleted file mode 100755 index b5cb8ecfc..000000000 --- a/builds/runtimes/python-2.6.6 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.6/Python-2.6.6.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.6 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.7 b/builds/runtimes/python-2.6.7 deleted file mode 100755 index 0eb587b2b..000000000 --- a/builds/runtimes/python-2.6.7 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.7/Python-2.6.7.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.7 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.8 b/builds/runtimes/python-2.6.8 deleted file mode 100755 index ed071fc97..000000000 --- a/builds/runtimes/python-2.6.8 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.8/Python-2.6.8.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.8 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.6.9 b/builds/runtimes/python-2.6.9 deleted file mode 100755 index dfa528392..000000000 --- a/builds/runtimes/python-2.6.9 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -# Protect 2.6 builds from parent Python (causes segfault during build). -unset LANG PYTHONHOME PYTHONPATH - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.6.9/Python-2.6.9.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.6.9 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.0 b/builds/runtimes/python-2.7.0 deleted file mode 100755 index 17559ec88..000000000 --- a/builds/runtimes/python-2.7.0 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7/Python-2.7.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.1 b/builds/runtimes/python-2.7.1 deleted file mode 100755 index 06b03af48..000000000 --- a/builds/runtimes/python-2.7.1 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.1/Python-2.7.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.1 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.2 b/builds/runtimes/python-2.7.2 deleted file mode 100755 index f89af12a1..000000000 --- a/builds/runtimes/python-2.7.2 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.2/Python-2.7.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.2 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.3 b/builds/runtimes/python-2.7.3 deleted file mode 100755 index d139ca38d..000000000 --- a/builds/runtimes/python-2.7.3 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.3/Python-2.7.3.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.3 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.4 b/builds/runtimes/python-2.7.4 deleted file mode 100755 index 08b04e0ed..000000000 --- a/builds/runtimes/python-2.7.4 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.4/Python-2.7.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.4 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.5 b/builds/runtimes/python-2.7.5 deleted file mode 100755 index 520ec0131..000000000 --- a/builds/runtimes/python-2.7.5 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.5/Python-2.7.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.5 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.6 b/builds/runtimes/python-2.7.6 deleted file mode 100755 index 098ea0f58..000000000 --- a/builds/runtimes/python-2.7.6 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.6/Python-2.7.6.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.6 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.7 b/builds/runtimes/python-2.7.7 deleted file mode 100755 index 7ff982fd4..000000000 --- a/builds/runtimes/python-2.7.7 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.7/Python-2.7.7.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.7 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.7.7-shared b/builds/runtimes/python-2.7.7-shared deleted file mode 100755 index 2c6a60e1f..000000000 --- a/builds/runtimes/python-2.7.7-shared +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.7/Python-2.7.7.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.7 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-2.7.8 b/builds/runtimes/python-2.7.8 deleted file mode 100755 index 070098911..000000000 --- a/builds/runtimes/python-2.7.8 +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.8/Python-2.7.8.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.8 src -cd src - -./configure --prefix=$OUT_PREFIX -make -make install diff --git a/builds/runtimes/python-2.7.8-shared b/builds/runtimes/python-2.7.8-shared deleted file mode 100755 index 1baeff972..000000000 --- a/builds/runtimes/python-2.7.8-shared +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/2.7.8/Python-2.7.8.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-2.7.8 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install diff --git a/builds/runtimes/python-3.1.0 b/builds/runtimes/python-3.1.0 deleted file mode 100755 index effbf4414..000000000 --- a/builds/runtimes/python-3.1.0 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.1/Python-3.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.1 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.1.1 b/builds/runtimes/python-3.1.1 deleted file mode 100755 index bf5698af4..000000000 --- a/builds/runtimes/python-3.1.1 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.1.1/Python-3.1.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.1.1 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.1.2 b/builds/runtimes/python-3.1.2 deleted file mode 100755 index 2544ba268..000000000 --- a/builds/runtimes/python-3.1.2 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.1.2/Python-3.1.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.1.2 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.1.3 b/builds/runtimes/python-3.1.3 deleted file mode 100755 index 7af8e662f..000000000 --- a/builds/runtimes/python-3.1.3 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.1.3/Python-3.1.3.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.1.3 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.1.4 b/builds/runtimes/python-3.1.4 deleted file mode 100755 index e1050d97f..000000000 --- a/builds/runtimes/python-3.1.4 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.1.4/Python-3.1.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.1.4 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.1.5 b/builds/runtimes/python-3.1.5 deleted file mode 100755 index f538d93c4..000000000 --- a/builds/runtimes/python-3.1.5 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.1.5/Python-3.1.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.1.5 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.2.0 b/builds/runtimes/python-3.2.0 deleted file mode 100755 index f4c5ce231..000000000 --- a/builds/runtimes/python-3.2.0 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.2/Python-3.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.2 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.2.1 b/builds/runtimes/python-3.2.1 deleted file mode 100755 index 68392d09f..000000000 --- a/builds/runtimes/python-3.2.1 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.2.1/Python-3.2.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.2.1 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.2.2 b/builds/runtimes/python-3.2.2 deleted file mode 100755 index dd22cab77..000000000 --- a/builds/runtimes/python-3.2.2 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.2.2/Python-3.2.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.2.2 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.2.3 b/builds/runtimes/python-3.2.3 deleted file mode 100755 index 9d887e6e4..000000000 --- a/builds/runtimes/python-3.2.3 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.2.3/Python-3.2.3.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.2.3 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.2.4 b/builds/runtimes/python-3.2.4 deleted file mode 100755 index 20d2050d7..000000000 --- a/builds/runtimes/python-3.2.4 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.2.4/Python-3.2.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.2.4 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.2.5 b/builds/runtimes/python-3.2.5 deleted file mode 100755 index 1c4b18e68..000000000 --- a/builds/runtimes/python-3.2.5 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.2.5/Python-3.2.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.2.5 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.0 b/builds/runtimes/python-3.3.0 deleted file mode 100755 index 436fd92ee..000000000 --- a/builds/runtimes/python-3.3.0 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.0/Python-3.3.0.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.0 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.1 b/builds/runtimes/python-3.3.1 deleted file mode 100755 index fcf234e0f..000000000 --- a/builds/runtimes/python-3.3.1 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.1/Python-3.3.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.1 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.2 b/builds/runtimes/python-3.3.2 deleted file mode 100755 index 076bf757b..000000000 --- a/builds/runtimes/python-3.3.2 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.2/Python-3.3.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.2 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.3 b/builds/runtimes/python-3.3.3 deleted file mode 100755 index 50fe9b878..000000000 --- a/builds/runtimes/python-3.3.3 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.3/Python-3.3.3.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.3 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.4 b/builds/runtimes/python-3.3.4 deleted file mode 100755 index d07a5c6b3..000000000 --- a/builds/runtimes/python-3.3.4 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.4/Python-3.3.4.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.4 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.5 b/builds/runtimes/python-3.3.5 deleted file mode 100755 index df64fd8b2..000000000 --- a/builds/runtimes/python-3.3.5 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.5/Python-3.3.5.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.5 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.3.6 b/builds/runtimes/python-3.3.6 deleted file mode 100755 index 2d7b93d77..000000000 --- a/builds/runtimes/python-3.3.6 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.3.6/Python-3.3.6.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.3.6 src -cd src - -./configure --prefix=$OUT_PREFIX --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.4.0 b/builds/runtimes/python-3.4.0 deleted file mode 100755 index 8e4d80e34..000000000 --- a/builds/runtimes/python-3.4.0 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.4.0/Python-3.4.0.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.4.0 src -cd src - -./configure --prefix=$OUT_PREFIX --with-ensurepip=no --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python diff --git a/builds/runtimes/python-3.4.1 b/builds/runtimes/python-3.4.1 deleted file mode 100755 index 352245a56..000000000 --- a/builds/runtimes/python-3.4.1 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.4.1/Python-3.4.1.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.4.1 src -cd src - -./configure --prefix=$OUT_PREFIX --with-ensurepip=no --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python - diff --git a/builds/runtimes/python-3.4.2 b/builds/runtimes/python-3.4.2 deleted file mode 100755 index 67858ad2e..000000000 --- a/builds/runtimes/python-3.4.2 +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build Path: /app/.heroku/python/ -# Build Deps: libraries/sqlite - -OUT_PREFIX=$1 - -echo "Building Python..." -SOURCE_TARBALL='http://python.org/ftp/python/3.4.2/Python-3.4.2.tgz' -curl -L $SOURCE_TARBALL | tar xz -mv Python-3.4.2 src -cd src - -./configure --prefix=$OUT_PREFIX --with-ensurepip=no --enable-shared -make -make install - -ln $OUT_PREFIX/bin/python3 $OUT_PREFIX/bin/python - diff --git a/builds/test_python_runtime.sh b/builds/test_python_runtime.sh new file mode 100755 index 000000000..adc701e4f --- /dev/null +++ b/builds/test_python_runtime.sh @@ -0,0 +1,85 @@ +#!/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 +# when relocating, so the Python binary (which itself contains very little) can find `libpython`. +INSTALL_DIR=$(mktemp -d) +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` + `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 + 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 +) + +# 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 + +if "${INSTALL_DIR}/bin/python3" -c "import $( + IFS=, + echo "${optional_stdlib_modules[*]}" +)"; then + echo "Successful 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 new file mode 100755 index 000000000..019bf60d3 --- /dev/null +++ b/etc/publish.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail +shopt -s inherit_errexit + +buildpack_registry_name="heroku/python" + +function abort() { + echo + echo "Error: ${1}" >&2 + exit 1 +} + +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 +# 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 Heroku Buildpack Registry..." +heroku buildpacks:publish "${buildpack_registry_name}" "${new_git_tag}" +echo +heroku buildpacks:versions "${buildpack_registry_name}" | head -n 3 diff --git a/hatchet.json b/hatchet.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/hatchet.json @@ -0,0 +1 @@ +{} diff --git a/hatchet.lock b/hatchet.lock new file mode 100644 index 000000000..dcd024e99 --- /dev/null +++ b/hatchet.lock @@ -0,0 +1 @@ +--- [] diff --git a/lib/build_data.sh b/lib/build_data.sh new file mode 100644 index 000000000..93434ac38 --- /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 "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 + # The `// empty` ensures we return the empty string rather than `null` if the key doesn't exist. + # We don't use `--exit-status` since `false` is a valid value for us to retrieve. + jq --raw-output ".${key} // empty" "${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..dae064b53 --- /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 `--reflinks=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..e6f222954 --- /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) + 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..68c002774 --- /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`s 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/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..dc26c39e5 --- /dev/null +++ b/lib/pip.sh @@ -0,0 +1,149 @@ +#!/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') +SETUPTOOLS_VERSION=$(utils::get_requirement_version 'setuptools') + +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 installed 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..d3e97631b --- /dev/null +++ b/lib/python_version.sh @@ -0,0 +1,742 @@ +#!/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.19" +LATEST_PYTHON_3_11="3.11.14" +LATEST_PYTHON_3_12="3.12.12" +LATEST_PYTHON_3_13="3.13.12" +LATEST_PYTHON_3_14="3.14.3" + +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.txt" + ".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 (eg 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}" + + 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..904532768 --- /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 (eg 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..927c84821 --- /dev/null +++ b/lib/uv.sh @@ -0,0 +1,177 @@ +#!/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 + # eg: `x86_64` or `aarch64`. + gnu_arch=$(arch) + local uv_url="https://github.com/astral-sh/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 GitHub: + ${uv_url} + + In some cases, this happens due to a temporary issue with + the network connection or GitHub's API/CDN. + + Try building again to see if the error resolves itself. + + If that doesn't help, check the status of GitHub here: + https://www.githubstatus.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" + + # 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" + 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.txt b/requirements.txt deleted file mode 100644 index 394d44ad5..000000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -bob-builder==0.0.5 diff --git a/requirements/pip.txt b/requirements/pip.txt new file mode 100644 index 000000000..0b3d671e6 --- /dev/null +++ b/requirements/pip.txt @@ -0,0 +1 @@ +pip==25.3 diff --git a/requirements/pipenv.txt b/requirements/pipenv.txt new file mode 100644 index 000000000..faed680ba --- /dev/null +++ b/requirements/pipenv.txt @@ -0,0 +1 @@ +pipenv==2026.0.3 diff --git a/requirements/poetry.txt b/requirements/poetry.txt new file mode 100644 index 000000000..f69508193 --- /dev/null +++ b/requirements/poetry.txt @@ -0,0 +1 @@ +poetry==2.3.2 diff --git a/requirements/setuptools.txt b/requirements/setuptools.txt new file mode 100644 index 000000000..d125505ce --- /dev/null +++ b/requirements/setuptools.txt @@ -0,0 +1 @@ +setuptools==70.3.0 diff --git a/requirements/uv.txt b/requirements/uv.txt new file mode 100644 index 000000000..cc5692614 --- /dev/null +++ b/requirements/uv.txt @@ -0,0 +1 @@ +uv==0.10.1 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_pip/app.json b/spec/fixtures/ci_pip/app.json new file mode 100644 index 000000000..59617c169 --- /dev/null +++ b/spec/fixtures/ci_pip/app.json @@ -0,0 +1,9 @@ +{ + "environments": { + "test": { + "scripts": { + "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/ci_pip/bin/detect b/spec/fixtures/ci_pip/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/ci_pip/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_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/ci_pipenv/Pipfile b/spec/fixtures/ci_pipenv/Pipfile new file mode 100644 index 000000000..6d6473eb4 --- /dev/null +++ b/spec/fixtures/ci_pipenv/Pipfile @@ -0,0 +1,10 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] +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_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/.heroku/collectstatic_disabled b/spec/fixtures/django_collectstatic_disabled_file/.heroku/collectstatic_disabled new file mode 100644 index 000000000..e69de29bb 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_collectstatic_disabled_file/requirements.txt b/spec/fixtures/django_collectstatic_disabled_file/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/spec/fixtures/django_collectstatic_disabled_file/requirements.txt @@ -0,0 +1 @@ +Django 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_invalid_settings_module/requirements.txt b/spec/fixtures/django_invalid_settings_module/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/spec/fixtures/django_invalid_settings_module/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/spec/fixtures/django_no_manage_py/requirements.txt b/spec/fixtures/django_no_manage_py/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/spec/fixtures/django_no_manage_py/requirements.txt @@ -0,0 +1 @@ +Django 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/django_staticfiles_app_not_enabled/testproject/__init__.py b/spec/fixtures/django_staticfiles_app_not_enabled/testproject/__init__.py new file mode 100644 index 000000000..e69de29bb 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/django_staticfiles_latest_django/backend/testapp/__init__.py b/spec/fixtures/django_staticfiles_latest_django/backend/testapp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/django_staticfiles_latest_django/backend/testapp/static/robots.txt b/spec/fixtures/django_staticfiles_latest_django/backend/testapp/static/robots.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/django_staticfiles_latest_django/backend/testproject/__init__.py b/spec/fixtures/django_staticfiles_latest_django/backend/testproject/__init__.py new file mode 100644 index 000000000..e69de29bb 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_staticfiles_legacy_django/manage.py b/spec/fixtures/django_staticfiles_legacy_django/manage.py new file mode 100644 index 000000000..97ed576b6 --- /dev/null +++ b/spec/fixtures/django_staticfiles_legacy_django/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) 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/django_staticfiles_legacy_django/testapp/__init__.py b/spec/fixtures/django_staticfiles_legacy_django/testapp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/django_staticfiles_legacy_django/testapp/static/robots.txt b/spec/fixtures/django_staticfiles_legacy_django/testapp/static/robots.txt new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/django_staticfiles_legacy_django/testproject/__init__.py b/spec/fixtures/django_staticfiles_legacy_django/testproject/__init__.py new file mode 100644 index 000000000..e69de29bb 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/django_staticfiles_misconfigured/testproject/__init__.py b/spec/fixtures/django_staticfiles_misconfigured/testproject/__init__.py new file mode 100644 index 000000000..e69de29bb 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_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_dependency_and_nltk_txt/nltk.txt b/spec/fixtures/nltk_dependency_and_nltk_txt/nltk.txt new file mode 100644 index 000000000..1e578bfa6 --- /dev/null +++ b/spec/fixtures/nltk_dependency_and_nltk_txt/nltk.txt @@ -0,0 +1,2 @@ +city_database +stopwords diff --git a/spec/fixtures/nltk_dependency_and_nltk_txt/requirements.txt b/spec/fixtures/nltk_dependency_and_nltk_txt/requirements.txt new file mode 100644 index 000000000..846929621 --- /dev/null +++ b/spec/fixtures/nltk_dependency_and_nltk_txt/requirements.txt @@ -0,0 +1 @@ +nltk diff --git a/spec/fixtures/nltk_dependency_only/requirements.txt b/spec/fixtures/nltk_dependency_only/requirements.txt new file mode 100644 index 000000000..846929621 --- /dev/null +++ b/spec/fixtures/nltk_dependency_only/requirements.txt @@ -0,0 +1 @@ +nltk diff --git a/spec/fixtures/nltk_txt_but_no_dependency/nltk.txt b/spec/fixtures/nltk_txt_but_no_dependency/nltk.txt new file mode 100644 index 000000000..1e578bfa6 --- /dev/null +++ b/spec/fixtures/nltk_txt_but_no_dependency/nltk.txt @@ -0,0 +1,2 @@ +city_database +stopwords diff --git a/spec/fixtures/nltk_txt_but_no_dependency/requirements.txt b/spec/fixtures/nltk_txt_but_no_dependency/requirements.txt new file mode 100644 index 000000000..bdec3dfdf --- /dev/null +++ b/spec/fixtures/nltk_txt_but_no_dependency/requirements.txt @@ -0,0 +1 @@ +# nltk mentioned in a comment, but the dependency is not present 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/no_python_project_files/README.md b/spec/fixtures/no_python_project_files/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/no_python_project_files/subdir/some-file b/spec/fixtures/no_python_project_files/subdir/some-file 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/pip_editable/bin/compile b/spec/fixtures/pip_editable/bin/compile new file mode 100755 index 000000000..df17e9401 --- /dev/null +++ b/spec/fixtures/pip_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/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/pip_editable/bin/post_compile b/spec/fixtures/pip_editable/bin/post_compile new file mode 100755 index 000000000..6e77d159a --- /dev/null +++ b/spec/fixtures/pip_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/pip_editable/bin/test-entrypoints.sh b/spec/fixtures/pip_editable/bin/test-entrypoints.sh new file mode 100755 index 000000000..0e66f44bb --- /dev/null +++ b/spec/fixtures/pip_editable/bin/test-entrypoints.sh @@ -0,0 +1,18 @@ +#!/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 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/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/pip_editable/packages/local_package_pyproject_toml/pyproject.toml b/spec/fixtures/pip_editable/packages/local_package_pyproject_toml/pyproject.toml new file mode 100644 index 000000000..f567c3777 --- /dev/null +++ b/spec/fixtures/pip_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/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/pip_editable/packages/local_package_setup_py/setup.cfg b/spec/fixtures/pip_editable/packages/local_package_setup_py/setup.cfg new file mode 100644 index 000000000..eff513964 --- /dev/null +++ b/spec/fixtures/pip_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/pip_editable/packages/local_package_setup_py/setup.py b/spec/fixtures/pip_editable/packages/local_package_setup_py/setup.py new file mode 100644 index 000000000..606849326 --- /dev/null +++ b/spec/fixtures/pip_editable/packages/local_package_setup_py/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/spec/fixtures/pip_editable/requirements.txt b/spec/fixtures/pip_editable/requirements.txt new file mode 100644 index 000000000..a514dabc1 --- /dev/null +++ b/spec/fixtures/pip_editable/requirements.txt @@ -0,0 +1,5 @@ +# The packages have to be nested under `packages/` to work around: +# 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@56b5ad87f8d72a674145c273ed8f547513c2b409#egg=gunicorn diff --git a/spec/fixtures/pip_gdal/requirements.txt b/spec/fixtures/pip_gdal/requirements.txt new file mode 100644 index 000000000..2642fd732 --- /dev/null +++ b/spec/fixtures/pip_gdal/requirements.txt @@ -0,0 +1 @@ +GDAL 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_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 new file mode 100644 index 000000000..8bb51ee30 --- /dev/null +++ b/spec/fixtures/pipenv_editable/Pipfile @@ -0,0 +1,10 @@ +[[source]] +url = "https://pypi.org/simple" +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} +pipenv-editable = {file = ".", editable = true} diff --git a/spec/fixtures/pipenv_editable/Pipfile.lock b/spec/fixtures/pipenv_editable/Pipfile.lock new file mode 100644 index 000000000..1ebf46629 --- /dev/null +++ b/spec/fixtures/pipenv_editable/Pipfile.lock @@ -0,0 +1,45 @@ +{ + "_meta": { + "hash": { + "sha256": "bedd8ea507283c5458c9c2cb1fd55a6e5e69fecba7814ffc96bf25e03feaeabf" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "gunicorn": { + "editable": true, + "git": "git+https://github.com/benoitc/gunicorn", + "markers": "python_version >= '3.7'", + "ref": "56b5ad87f8d72a674145c273ed8f547513c2b409" + }, + "local-package-pyproject-toml": { + "editable": true, + "file": "packages/local_package_pyproject_toml" + }, + "local-package-setup-py": { + "editable": true, + "file": "packages/local_package_setup_py" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "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 new file mode 100755 index 000000000..df17e9401 --- /dev/null +++ b/spec/fixtures/pipenv_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/pipenv_editable/bin/detect b/spec/fixtures/pipenv_editable/bin/detect new file mode 100755 index 000000000..68cdcc4a2 --- /dev/null +++ b/spec/fixtures/pipenv_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/pipenv_editable/bin/post_compile b/spec/fixtures/pipenv_editable/bin/post_compile new file mode 100755 index 000000000..6e77d159a --- /dev/null +++ b/spec/fixtures/pipenv_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.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 new file mode 100644 index 000000000..6ce02c341 --- /dev/null +++ b/spec/fixtures/pipenv_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/pipenv_editable/packages/local_package_pyproject_toml/pyproject.toml b/spec/fixtures/pipenv_editable/packages/local_package_pyproject_toml/pyproject.toml new file mode 100644 index 000000000..f567c3777 --- /dev/null +++ b/spec/fixtures/pipenv_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/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 new file mode 100644 index 000000000..8dccb8a0c --- /dev/null +++ b/spec/fixtures/pipenv_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/pipenv_editable/packages/local_package_setup_py/setup.cfg b/spec/fixtures/pipenv_editable/packages/local_package_setup_py/setup.cfg new file mode 100644 index 000000000..eff513964 --- /dev/null +++ b/spec/fixtures/pipenv_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/pipenv_editable/packages/local_package_setup_py/setup.py b/spec/fixtures/pipenv_editable/packages/local_package_setup_py/setup.py new file mode 100644 index 000000000..606849326 --- /dev/null +++ b/spec/fixtures/pipenv_editable/packages/local_package_setup_py/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() 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_lockfile_invalid_json/Pipfile b/spec/fixtures/pipenv_lockfile_invalid_json/Pipfile new file mode 100644 index 000000000..9bf60112c --- /dev/null +++ b/spec/fixtures/pipenv_lockfile_invalid_json/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +urllib3 = "*" + +[dev-packages] + +[requires] +python_version = "3.12" 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 new file mode 100644 index 000000000..c8c201a39 --- /dev/null +++ b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile @@ -0,0 +1,10 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +# This dependency isn't in the lockfile. +urllib3 = "*" + +[dev-packages] diff --git a/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock new file mode 100644 index 000000000..d2c2cba4c --- /dev/null +++ b/spec/fixtures/pipenv_lockfile_out_of_sync/Pipfile.lock @@ -0,0 +1,18 @@ +{ + "_meta": { + "hash": { + "sha256": "ebffa69a1fa192d1cef7cb42ad79231ca976565c5ce371a70160b3048d3cbc06" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "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_mismatched_python_version/Pipfile b/spec/fixtures/pipenv_mismatched_python_version/Pipfile new file mode 100644 index 000000000..f438ee65c --- /dev/null +++ b/spec/fixtures/pipenv_mismatched_python_version/Pipfile @@ -0,0 +1,10 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[requires] +python_version = "3.12" diff --git a/spec/fixtures/pipenv_mismatched_python_version/Pipfile.lock b/spec/fixtures/pipenv_mismatched_python_version/Pipfile.lock new file mode 100644 index 000000000..517924633 --- /dev/null +++ b/spec/fixtures/pipenv_mismatched_python_version/Pipfile.lock @@ -0,0 +1,30 @@ +{ + "_meta": { + "hash": { + "sha256": "9661ed313a79ccb68c7dc4e639068f86ddd91e307ec2ed60498858d002e9b547" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.12" + }, + "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_no_lockfile/Pipfile b/spec/fixtures/pipenv_no_lockfile/Pipfile new file mode 100644 index 000000000..a62d277f4 --- /dev/null +++ b/spec/fixtures/pipenv_no_lockfile/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] + +[requires] +python_version = "3.14" diff --git a/spec/fixtures/pipenv_python_full_version/Pipfile b/spec/fixtures/pipenv_python_full_version/Pipfile new file mode 100644 index 000000000..1b3a10cc1 --- /dev/null +++ b/spec/fixtures/pipenv_python_full_version/Pipfile @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] + +[requires] +# 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 new file mode 100644 index 000000000..8b1a4f32a --- /dev/null +++ b/spec/fixtures/pipenv_python_full_version/Pipfile.lock @@ -0,0 +1,31 @@ +{ + "_meta": { + "hash": { + "sha256": "e9ceff57b2002f3a53ac8e84fc449e7aacbb62139f45e8299bde332e3ef9f6b2" + }, + "pipfile-spec": 6, + "requires": { + "python_full_version": "3.10.0", + "python_version": "3.12" + }, + "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_full_version_invalid/Pipfile b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile new file mode 100644 index 000000000..4016fd876 --- /dev/null +++ b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] + +[requires] +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 new file mode 100644 index 000000000..983c376a9 --- /dev/null +++ b/spec/fixtures/pipenv_python_full_version_invalid/Pipfile.lock @@ -0,0 +1,30 @@ +{ + "_meta": { + "hash": { + "sha256": "254efba85c5858a7dfe232cb38f9ead91bf6e50bbf82f51a8a5a01904ca8712e" + }, + "pipfile-spec": 6, + "requires": { + "python_full_version": "3.9.*" + }, + "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_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_version_and_python_version_file/Pipfile b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile new file mode 100644 index 000000000..d9d1ea61b --- /dev/null +++ b/spec/fixtures/pipenv_python_version_and_python_version_file/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] + +[requires] +# 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_version_eol/Pipfile b/spec/fixtures/pipenv_python_version_eol/Pipfile new file mode 100644 index 000000000..40c03e309 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_eol/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] + +[requires] +python_version = "3.8" diff --git a/spec/fixtures/pipenv_python_version_eol/Pipfile.lock b/spec/fixtures/pipenv_python_version_eol/Pipfile.lock new file mode 100644 index 000000000..026a1b0b1 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_eol/Pipfile.lock @@ -0,0 +1,30 @@ +{ + "_meta": { + "hash": { + "sha256": "feaf6b91bd5a191f779b2c2199b3e328369c87e6a598f02a3ba6c76d877e176f" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "typing-extensions": { + "hashes": [ + "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", + "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.13.2" + } + }, + "develop": {} +} diff --git a/spec/fixtures/pipenv_python_version_invalid/Pipfile b/spec/fixtures/pipenv_python_version_invalid/Pipfile new file mode 100644 index 000000000..43fe5b889 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_invalid/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] + +[requires] +python_version = "^3.12" diff --git a/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock b/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock new file mode 100644 index 000000000..44f3e9fc6 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_invalid/Pipfile.lock @@ -0,0 +1,30 @@ +{ + "_meta": { + "hash": { + "sha256": "c86b61d10a1742dfbc75378ee86a81c24420506b154b9c19d69aa6e6c02c61b2" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "^3.12" + }, + "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_version_unspecified/Pipfile b/spec/fixtures/pipenv_python_version_unspecified/Pipfile new file mode 100644 index 000000000..496fa1558 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_unspecified/Pipfile @@ -0,0 +1,9 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +typing-extensions = "*" + +[dev-packages] diff --git a/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock b/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock new file mode 100644 index 000000000..2b7b232d6 --- /dev/null +++ b/spec/fixtures/pipenv_python_version_unspecified/Pipfile.lock @@ -0,0 +1,28 @@ +{ + "_meta": { + "hash": { + "sha256": "beb76460a63ef2f29eec7b281a3c7114d442db105096d7472b4b72a7504df8fe" + }, + "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": {} +} 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/procfile/Procfile b/spec/fixtures/procfile/Procfile new file mode 100644 index 000000000..31943b80f --- /dev/null +++ b/spec/fixtures/procfile/Procfile @@ -0,0 +1,2 @@ +web: true +example-worker: true diff --git a/spec/fixtures/procfile/requirements.txt b/spec/fixtures/procfile/requirements.txt new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/python_3.10/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.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 new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/python_3.11/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.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 new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/python_3.12/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.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_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_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..3e388a4ac --- /dev/null +++ b/spec/fixtures/python_version_outdated/.python-version @@ -0,0 +1 @@ +3.13.2 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 new file mode 100644 index 000000000..f6e499a84 --- /dev/null +++ b/spec/fixtures/python_version_unspecified/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/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/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 new file mode 100644 index 000000000..e69de29bb 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/uv_editable/bin/test-entrypoints.sh b/spec/fixtures/uv_editable/bin/test-entrypoints.sh new file mode 100755 index 000000000..20085b398 --- /dev/null +++ b/spec/fixtures/uv_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: " +uv-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/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 new file mode 100644 index 000000000..60319499b --- /dev/null +++ b/spec/hatchet/ci_spec.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Heroku CI' do + 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_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_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 new file mode 100644 index 000000000..2a44089c2 --- /dev/null +++ b/spec/hatchet/detect_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Buildpack detection' do + # This spec only tests cases where detection fails, since the success cases + # are already tested in the specs for general buildpack functionality. + + context 'when there are no recognised Python project files' do + let(:app) { Hatchet::Runner.new('spec/fixtures/no_python_project_files', allow_failure: true) } + + it 'fails detection' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> App not compatible with buildpack: #{DEFAULT_BUILDPACK_URL} + remote: + 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', + 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: ! + remote: ! If your app already has a package manager file, check that it: + remote: ! + 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: + remote: More info: https://devcenter.heroku.com/articles/buildpacks#detection-failure + OUTPUT + end + end + end +end diff --git a/spec/hatchet/django_spec.rb b/spec/hatchet/django_spec.rb new file mode 100644 index 000000000..664410dd7 --- /dev/null +++ b/spec/hatchet/django_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +# 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. 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 + 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 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 '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') } + + 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(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 .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 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 + + # 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 '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 + + # 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) } + + # 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 +end diff --git a/spec/hatchet/hooks_spec.rb b/spec/hatchet/hooks_spec.rb new file mode 100644 index 000000000..8cf91df8d --- /dev/null +++ b/spec/hatchet/hooks_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Compile hooks' do + # 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| + output = clean_output(app.output) + + expect(output).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + 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 +end diff --git a/spec/hatchet/nltk_spec.rb b/spec/hatchet/nltk_spec.rb new file mode 100644 index 000000000..56ed95ce4 --- /dev/null +++ b/spec/hatchet/nltk_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'NLTK corpora support' do + context 'when the NLTK package is installed and nltk.txt is present' do + let(:app) { Hatchet::Runner.new('spec/fixtures/nltk_dependency_and_nltk_txt') } + + it 'installs the specified NLTK corpora' do + app.deploy do |app| + 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\\] /app/.heroku/python/nltk_data... + remote: \\[nltk_data\\] Unzipping corpora/city_database.zip. + remote: \\[nltk_data\\] Downloading package stopwords to + remote: \\[nltk_data\\] /app/.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. + end + end + end + + context 'when the NLTK package is installed but there is no nltk.txt' do + let(:app) { Hatchet::Runner.new('spec/fixtures/nltk_dependency_only') } + + it 'warns that nltk.txt was not found' do + 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 + REGEX + end + end + end + + context 'when only nltk.txt is present' do + let(:app) { Hatchet::Runner.new('spec/fixtures/nltk_txt_but_no_dependency') } + + it 'does not try to install the specified NLTK corpora' do + app.deploy do |app| + 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 +end diff --git a/spec/hatchet/package_manager_spec.rb b/spec/hatchet/package_manager_spec.rb new file mode 100644 index 000000000..02f68b990 --- /dev/null +++ b/spec/hatchet/package_manager_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Package manager support' do + context 'when there are no supported package manager files' do + 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: ! Error: Couldn't find any supported Python package manager files. + remote: ! + 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: ! pyproject.toml + remote: ! subdir/ + remote: ! + remote: ! If your app already has a package manager file, check that it: + remote: ! + 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 new file mode 100644 index 000000000..5252aa9ee --- /dev/null +++ b/spec/hatchet/pip_spec.rb @@ -0,0 +1,457 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'pip support' do + context 'when requirements.txt is unchanged since the last build' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_basic', buildpacks:) } + + 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: -----> 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 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: -----> 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(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/pip_basic') } + + it 'clears the cache before installing the packages again' do + app.deploy do |app| + # 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 include(<<~OUTPUT) + remote: -----> Python app detected + 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 editable requirements (both VCS and local package)' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_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: -----> 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: __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.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'} + + 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 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: __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 + + # 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 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 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 requirements.txt contains an invalid requirement' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pip_invalid_requirement', allow_failure: true) } + + it 'aborts the build and displays the pip error' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Installing dependencies using 'pip install -r requirements.txt' + remote: ERROR: Invalid requirement: 'an-invalid-requirement!': Expected end or semicolon (after name and no valid version specifier) + 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 + end + end + end + + context 'when requirements.txt contains GDAL but the GDAL C++ library is missing' do + 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: 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 + end +end diff --git a/spec/hatchet/pipenv_spec.rb b/spec/hatchet/pipenv_spec.rb new file mode 100644 index 000000000..e0b46a839 --- /dev/null +++ b/spec/hatchet/pipenv_spec.rb @@ -0,0 +1,617 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Pipenv support' do + context 'with a Pipfile.lock that is unchanged since the last build' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_basic', buildpacks:) } + + 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: -----> 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(clean_output(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 'when Pipfile.lock has changed since the last build' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_basic') } + + 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: -----> 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 + + # 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 '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(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python 3.10.0 specified in Pipfile.lock + 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 Pipfile.lock 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 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 + + # TODO: Delete this test once pipenv_mismatched_python_version is re-enabled, + # since they mostly duplicate each other. + context 'when there is a both a Pipfile.lock python_version and a .python-version file' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_version_and_python_version_file') } + + it 'builds with the Python version from the .python-version file' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Python app detected + remote: -----> Using Python 3.13 specified in .python-version + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing 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: -----> 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} + remote: -----> Installing Pipenv #{PIPENV_VERSION} + remote: -----> Installing dependencies using 'pipenv install --deploy' + remote: -----> Saving cache + REGEX + end + end + end + + context 'without a Pipfile.lock' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_no_lockfile', allow_failure: true) } + + 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 invalid JSON' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_lockfile_invalid_json', allow_failure: true) } + + 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: + remote: ! Error: Can't parse Pipfile.lock. + remote: ! + remote: ! A Pipfile.lock file was found, however, it couldn't be parsed: + remote: ! (jq: )?parse error: Invalid numeric literal at line 1, column 8 + remote: ! + remote: ! This is likely due to it not being valid JSON. + 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' 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: + 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: ! Wildcards aren't supported. + remote: ! + 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 'with a Pipfile.lock containing an invalid python_full_version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_python_full_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: + 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: ! Wildcards aren't supported. + remote: ! + 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 '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 'aborts the build with an EOL message' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~OUTPUT)) + remote: -----> Python app detected + 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 the Pipenv and Python versions have changed since the last build' do + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v313'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_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 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 + end + + context 'when Pipfile contains editable requirements' do + let(:buildpacks) { [:default, 'heroku-community/inline'] } + let(:app) { Hatchet::Runner.new('spec/fixtures/pipenv_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 '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___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: _pipenv_editable.pth:/tmp/build_.+ + 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: __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: _pipenv_editable.pth:/tmp/build_.+ + 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 + + # 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'} + _pipenv_editable.pth:/app + + 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: -----> 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___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: _pipenv_editable.pth:/tmp/build_.+ + 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: __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: _pipenv_editable.pth:/tmp/build_.+ + 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 + # pyproject.toml 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: ! Push rejected, failed to compile Python app. + REGEX + end + end + end +end diff --git a/spec/hatchet/poetry_spec.rb b/spec/hatchet/poetry_spec.rb new file mode 100644 index 000000000..74832d7f1 --- /dev/null +++ b/spec/hatchet/poetry_spec.rb @@ -0,0 +1,357 @@ +# 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(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 + + context 'when the Poetry and Python versions have changed since the last build' 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 new file mode 100644 index 000000000..bb805a69f --- /dev/null +++ b/spec/hatchet/profile_d_scripts_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +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.) + # 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', + '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', + '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) + DYNO_RAM=512 + FORWARDED_ALLOW_IPS=* + GUNICORN_CMD_ARGS=this-should-be-preserved + LANG=C.UTF-8 + 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 + PIP_DISABLE_PIP_VERSION_CHECK=1 + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/this-should-be-preserved + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=this-should-be-preserved + OUTPUT + end + + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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 + WEB_CONCURRENCY=999 + OUTPUT + end + end + end +end diff --git a/spec/hatchet/python_version_spec.rb b/spec/hatchet/python_version_spec.rb new file mode 100644 index 000000000..2b70356ab --- /dev/null +++ b/spec/hatchet/python_version_spec.rb @@ -0,0 +1,739 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.shared_examples 'builds with the requested Python version' do |requested_version, resolved_version| + it "builds with Python #{requested_version}" do + app.deploy do |app| + if ['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(app.run('python -V')).to eq("Python #{resolved_version}\n") + expect($CHILD_STATUS.exitstatus).to eq(0) + end + end +end + +RSpec.describe 'Python version support' do + context 'when no Python version is specified' do + let(:buildpacks) { [:default] } + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_unspecified', buildpacks:) } + + context 'with a new app' do + it 'builds with the default Python version' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> No Python version was specified. Using the buildpack default: Python #{DEFAULT_PYTHON_MAJOR_VERSION} + remote: + 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 + end + + 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 by older buildpack versions. + # - If no Python version is specified, the same major version as the + # last build is used (sticky versioning). + # - Changes in the pip version are handled correctly. + let(:buildpacks) { ['https://github.com/heroku/heroku-buildpack-python#v267'] } + + it 'builds with the same 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 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: ! 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.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 Python version has changed from 3.12.7 to #{LATEST_PYTHON_3_12} + remote: - The pip version has changed from 24.0 to #{PIP_VERSION} + 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} + OUTPUT + expect(app.run('python -V')).to eq("Python #{LATEST_PYTHON_3_12}\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 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.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 + end + end + end + end + + context 'when .python-version contains Python 3.10' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_3.10') } + + 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 3.10 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: -----> 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(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 an invalid Python version message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Your .python-version file is spelled incorrectly. + remote: ! + remote: ! Your app's .python-version file currently has the filename: + remote: ! '.python-version ' + remote: ! + 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 .python-version contains an invalid Python version string' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_invalid_version', allow_failure: true) } + + it 'aborts the build with an invalid Python version message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: Invalid Python version in .python-version. + remote: ! + remote: ! The Python version specified in your .python-version file + remote: ! isn't in the correct format. + remote: ! + remote: ! The following version was found: + remote: ! ���python - ��3.12.0�[0m + 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: ! 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 .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) } + + 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 .python-version does not contain a Python version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_no_version', allow_failure: true) } + + it 'aborts the build with a no version string found message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: 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 .python-version contains multiple Python versions' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_file_multiple_versions', allow_failure: true) } + + it 'aborts the build with a multiple versions not supported message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: + remote: ! Error: 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 .python-version contains an EOL Python 3.x version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_eol', allow_failure: true) } + + it 'aborts the build with an EOL message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.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 .python-version contains an non-existent Python major version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_non_existent_major', allow_failure: true) } + + it 'aborts the build with an invalid .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 .python-version contains a non-existent Python patch version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_non_existent_patch', allow_failure: true) } + + it 'aborts the build with a version not available message' do + app.deploy do |app| + expect(clean_output(app.output)).to 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: 404.* + 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 .python-version contains an outdated Python patch version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/python_version_outdated') } + + it 'warns there is a Python update available' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 3.13.2 specified in .python-version + remote: + remote: ! Warning: A Python patch update is available! + remote: ! + remote: ! Your app is using Python 3.13.2, however, there is a newer + remote: ! patch release of Python 3.13 available: #{LATEST_PYTHON_3_13} + 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.13.2, and instead only specify + remote: ! the major Python version of 3.13 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.13.2 + 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 EOL Python 2.x version' do + let(:app) { Hatchet::Runner.new('spec/fixtures/runtime_txt_eol_version', allow_failure: true) } + + it 'aborts the build with an EOL message' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python 2.7.18 specified in runtime.txt + remote: + remote: ! Error: The requested Python version has reached end-of-life. + remote: ! + remote: ! Python 2.7 has reached its upstream end-of-life, and is + remote: ! therefore no longer receiving security updates: + remote: ! https://devguide.python.org/versions/#supported-versions + remote: ! + remote: ! As such, it'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 + + # 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') } + + 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.10') } + + it 'builds with the new Python version after removing the old install' do + app.deploy do |app| + File.write('.python-version', '3.13') + app.commit! + app.push! + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + 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 + end +end diff --git a/spec/hatchet/stack_spec.rb b/spec/hatchet/stack_spec.rb new file mode 100644 index 000000000..8fe115496 --- /dev/null +++ b/spec/hatchet/stack_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +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 + # 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') + 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 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: ! 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.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-22 to heroku-24 + 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_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') + 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 stack has changed from heroku-24 to heroku-22 + 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 + end +end diff --git a/spec/hatchet/uv_spec.rb b/spec/hatchet/uv_spec.rb new file mode 100644 index 000000000..766df4dca --- /dev/null +++ b/spec/hatchet/uv_spec.rb @@ -0,0 +1,472 @@ +# 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_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_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} + 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(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 + + context 'when the uv and Python versions have changed since the last build' 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: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. 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 new file mode 100644 index 000000000..5652c9ff3 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +ENV['HATCHET_BUILDPACK_BASE'] ||= 'https://github.com/heroku/heroku-buildpack-python.git' +ENV['HATCHET_DEFAULT_STACK'] ||= 'heroku-24' + +require 'English' # for $CHILD_STATUS +require 'rspec/core' +require 'rspec/retry' +require 'hatchet' + +FIXTURE_DIR = Pathname.new(__FILE__).parent.join('fixtures') + +LATEST_PYTHON_3_10 = '3.10.19' +LATEST_PYTHON_3_11 = '3.11.14' +LATEST_PYTHON_3_12 = '3.12.12' +LATEST_PYTHON_3_13 = '3.13.12' +LATEST_PYTHON_3_14 = '3.14.3' +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 +# 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. +def get_requirement_version(package_name) + requirement = File.read("requirements/#{package_name}.txt").strip + requirement.delete_prefix("#{package_name}==") +end + +PIP_VERSION = get_requirement_version('pip') +SETUPTOOLS_VERSION = get_requirement_version('setuptools') +PIPENV_VERSION = get_requirement_version('pipenv') +POETRY_VERSION = get_requirement_version('poetry') +UV_VERSION = get_requirement_version('uv') + +# Work around the return value for `default_buildpack` changing after deploy: +# https://github.com/heroku/hatchet/issues/180 +# Once we've updated to Hatchet release that includes the fix, consumers +# of this can switch back to using `app.class.default_buildpack` +DEFAULT_BUILDPACK_URL = Hatchet::App.default_buildpack + +RSpec.configure do |config| + # Disables the legacy rspec globals and monkey-patched `should` syntax. + config.disable_monkey_patching! + # Enable flags like --only-failures and --next-failure. + config.example_status_persistence_file_path = '.rspec_status' + # Allows limiting a spec run to individual examples or groups by tagging them + # with `:focus` metadata via the `fit`, `fcontext` and `fdescribe` aliases. + 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. + config.verbose_retry = true +end + +def clean_output(output) + 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 update_buildpacks(app, buildpacks) + # Updates the list of buildpacks for an existing app, until Hatchet supports this natively: + # https://github.com/heroku/hatchet/issues/166 + buildpack_list = buildpacks.map { |b| { buildpack: (b == :default ? DEFAULT_BUILDPACK_URL : b) } } + app.api_rate_limit.call.buildpack_installation.update(app.name, updates: buildpack_list) +end diff --git a/vendor/WEB_CONCURRENCY.sh b/vendor/WEB_CONCURRENCY.sh new file mode 100755 index 000000000..803b7217f --- /dev/null +++ b/vendor/WEB_CONCURRENCY.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +# This script was created by the Python buildpack to automatically set the `WEB_CONCURRENCY` +# environment variable at dyno boot (if it's not already set), based on the available memory +# and number of CPU cores. The env var is then used by some Python web servers (such as +# gunicorn and uvicorn) to control the default number of server processes that they launch. +# +# The default `WEB_CONCURRENCY` value is calculated as the lowest of either: +# - ` * 2 + 1` +# - ` / 256` (to ensure each process has at least 256 MB RAM) +# +# Currently, on Heroku dynos this results in the following concurrency values: +# - Eco / Basic / Standard-1X: 2 (capped by the 512 MB available memory) +# - Standard-2X / Private-S / Shield-S: 4 (capped by the 1 GB available memory) +# - Performance-M / Private-M / Shield-M: 5 (based on the 2 CPU cores) +# - Performance-L / Private-L / Shield-L: 17 (based on the 8 CPU cores) +# - Performance-L-RAM / Private-L-RAM / Shield-L-RAM: 9 (based on the 4 CPU cores) +# - Performance-XL / Private-XL / Shield-XL: 17 (based on the 8 CPU cores) +# - Performance-2XL / Private-2XL / Shield-2XL: 33 (based on the 16 CPU cores) +# +# To override these default values, either set `WEB_CONCURRENCY` as an explicit config var +# on the app, or pass `--workers ` when invoking gunicorn/uvicorn in your Procfile. + +# Note: Since this is a .profile.d/ script it will be sourced, meaning that we cannot enable +# 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' + + # 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 + + 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 +} + +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 +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 +fi + +output "Detected ${available_memory_in_mb} MB available memory and ${cpu_cores} CPU cores." + +# This env var is undocumented and not consistent with what other buildpacks set, however, +# GitHub code search shows there are Python apps in the wild that do rely upon it. +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 +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 +else + 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." +else + 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/bpwatch/bpwatch b/vendor/bpwatch/bpwatch deleted file mode 100755 index 8923f7295..000000000 --- a/vendor/bpwatch/bpwatch +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/python - - -import os -import sys - - -DEFAULT_PATH = '{0}.zip'.format(os.path.abspath(__file__)) -BPWATCH_DISTRO_PATH = os.environ.get('BPWATCH_DISTRO_PATH', DEFAULT_PATH) - -sys.path.insert(0, BPWATCH_DISTRO_PATH) - -import bp_cli -bp_cli.main() diff --git a/vendor/bpwatch/bpwatch.zip b/vendor/bpwatch/bpwatch.zip deleted file mode 100644 index 9d34563ae..000000000 Binary files a/vendor/bpwatch/bpwatch.zip and /dev/null differ diff --git a/vendor/buildpack-stdlib_v8.sh b/vendor/buildpack-stdlib_v8.sh new file mode 100755 index 000000000..8f82fc152 --- /dev/null +++ b/vendor/buildpack-stdlib_v8.sh @@ -0,0 +1,75 @@ +#!/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 + +# 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}" +} + +# 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}" +} + +# Usage: $ un-set-env key +# NOTICE: Expects PROFILE_PATH to be set! +un_set_env() { + 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})$" +} + +# 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 + 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 +# Runs a subshell of specified command with user-provided config. +# NOTICE: Expects ENV_DIR to be set. WHITELIST & BLACKLIST are optional. +# Examples: +# 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 + export_env "${ENV_DIR}" "${WHITELIST:-}" "${BLACKLIST:-}" + + "${@}" + ) +} diff --git a/vendor/pip-1.5.6.tar.gz b/vendor/pip-1.5.6.tar.gz deleted file mode 100644 index b2111358d..000000000 Binary files a/vendor/pip-1.5.6.tar.gz and /dev/null differ diff --git a/vendor/pip-pop/docopt.py b/vendor/pip-pop/docopt.py deleted file mode 100644 index 2e43f7cef..000000000 --- a/vendor/pip-pop/docopt.py +++ /dev/null @@ -1,581 +0,0 @@ -"""Pythonic command-line interface parser that will make you smile. - - * http://docopt.org - * Repository and issue-tracker: https://github.com/docopt/docopt - * Licensed under terms of MIT license (see LICENSE-MIT) - * Copyright (c) 2013 Vladimir Keleshev, vladimir@keleshev.com - -""" -import sys -import re - - -__all__ = ['docopt'] -__version__ = '0.6.1' - - -class DocoptLanguageError(Exception): - - """Error in construction of usage-message by developer.""" - - -class DocoptExit(SystemExit): - - """Exit in case user invoked program with incorrect arguments.""" - - usage = '' - - def __init__(self, message=''): - SystemExit.__init__(self, (message + '\n' + self.usage).strip()) - - -class Pattern(object): - - def __eq__(self, other): - return repr(self) == repr(other) - - def __hash__(self): - return hash(repr(self)) - - def fix(self): - self.fix_identities() - self.fix_repeating_arguments() - return self - - def fix_identities(self, uniq=None): - """Make pattern-tree tips point to same object if they are equal.""" - if not hasattr(self, 'children'): - return self - uniq = list(set(self.flat())) if uniq is None else uniq - for i, child in enumerate(self.children): - if not hasattr(child, 'children'): - assert child in uniq - self.children[i] = uniq[uniq.index(child)] - else: - child.fix_identities(uniq) - - def fix_repeating_arguments(self): - """Fix elements that should accumulate/increment values.""" - either = [list(child.children) for child in transform(self).children] - for case in either: - for e in [child for child in case if case.count(child) > 1]: - if type(e) is Argument or type(e) is Option and e.argcount: - if e.value is None: - e.value = [] - elif type(e.value) is not list: - e.value = e.value.split() - if type(e) is Command or type(e) is Option and e.argcount == 0: - e.value = 0 - return self - - -def transform(pattern): - """Expand pattern into an (almost) equivalent one, but with single Either. - - Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d) - Quirks: [-a] => (-a), (-a...) => (-a -a) - - """ - result = [] - groups = [[pattern]] - while groups: - children = groups.pop(0) - parents = [Required, Optional, OptionsShortcut, Either, OneOrMore] - if any(t in map(type, children) for t in parents): - child = [c for c in children if type(c) in parents][0] - children.remove(child) - if type(child) is Either: - for c in child.children: - groups.append([c] + children) - elif type(child) is OneOrMore: - groups.append(child.children * 2 + children) - else: - groups.append(child.children + children) - else: - result.append(children) - return Either(*[Required(*e) for e in result]) - - -class LeafPattern(Pattern): - - """Leaf/terminal node of a pattern tree.""" - - def __init__(self, name, value=None): - self.name, self.value = name, value - - def __repr__(self): - return '%s(%r, %r)' % (self.__class__.__name__, self.name, self.value) - - def flat(self, *types): - return [self] if not types or type(self) in types else [] - - def match(self, left, collected=None): - collected = [] if collected is None else collected - pos, match = self.single_match(left) - if match is None: - return False, left, collected - left_ = left[:pos] + left[pos + 1:] - same_name = [a for a in collected if a.name == self.name] - if type(self.value) in (int, list): - if type(self.value) is int: - increment = 1 - else: - increment = ([match.value] if type(match.value) is str - else match.value) - if not same_name: - match.value = increment - return True, left_, collected + [match] - same_name[0].value += increment - return True, left_, collected - return True, left_, collected + [match] - - -class BranchPattern(Pattern): - - """Branch/inner node of a pattern tree.""" - - def __init__(self, *children): - self.children = list(children) - - def __repr__(self): - return '%s(%s)' % (self.__class__.__name__, - ', '.join(repr(a) for a in self.children)) - - def flat(self, *types): - if type(self) in types: - return [self] - return sum([child.flat(*types) for child in self.children], []) - - -class Argument(LeafPattern): - - def single_match(self, left): - for n, pattern in enumerate(left): - if type(pattern) is Argument: - return n, Argument(self.name, pattern.value) - return None, None - - @classmethod - def parse(class_, source): - name = re.findall('(<\S*?>)', source)[0] - value = re.findall('\[default: (.*)\]', source, flags=re.I) - return class_(name, value[0] if value else None) - - -class Command(Argument): - - def __init__(self, name, value=False): - self.name, self.value = name, value - - def single_match(self, left): - for n, pattern in enumerate(left): - if type(pattern) is Argument: - if pattern.value == self.name: - return n, Command(self.name, True) - else: - break - return None, None - - -class Option(LeafPattern): - - def __init__(self, short=None, long=None, argcount=0, value=False): - assert argcount in (0, 1) - self.short, self.long, self.argcount = short, long, argcount - self.value = None if value is False and argcount else value - - @classmethod - def parse(class_, option_description): - short, long, argcount, value = None, None, 0, False - options, _, description = option_description.strip().partition(' ') - options = options.replace(',', ' ').replace('=', ' ') - for s in options.split(): - if s.startswith('--'): - long = s - elif s.startswith('-'): - short = s - else: - argcount = 1 - if argcount: - matched = re.findall('\[default: (.*)\]', description, flags=re.I) - value = matched[0] if matched else None - return class_(short, long, argcount, value) - - def single_match(self, left): - for n, pattern in enumerate(left): - if self.name == pattern.name: - return n, pattern - return None, None - - @property - def name(self): - return self.long or self.short - - def __repr__(self): - return 'Option(%r, %r, %r, %r)' % (self.short, self.long, - self.argcount, self.value) - - -class Required(BranchPattern): - - def match(self, left, collected=None): - collected = [] if collected is None else collected - l = left - c = collected - for pattern in self.children: - matched, l, c = pattern.match(l, c) - if not matched: - return False, left, collected - return True, l, c - - -class Optional(BranchPattern): - - def match(self, left, collected=None): - collected = [] if collected is None else collected - for pattern in self.children: - m, left, collected = pattern.match(left, collected) - return True, left, collected - - -class OptionsShortcut(Optional): - - """Marker/placeholder for [options] shortcut.""" - - -class OneOrMore(BranchPattern): - - def match(self, left, collected=None): - assert len(self.children) == 1 - collected = [] if collected is None else collected - l = left - c = collected - l_ = None - matched = True - times = 0 - while matched: - # could it be that something didn't match but changed l or c? - matched, l, c = self.children[0].match(l, c) - times += 1 if matched else 0 - if l_ == l: - break - l_ = l - if times >= 1: - return True, l, c - return False, left, collected - - -class Either(BranchPattern): - - def match(self, left, collected=None): - collected = [] if collected is None else collected - outcomes = [] - for pattern in self.children: - matched, _, _ = outcome = pattern.match(left, collected) - if matched: - outcomes.append(outcome) - if outcomes: - return min(outcomes, key=lambda outcome: len(outcome[1])) - return False, left, collected - - -class Tokens(list): - - def __init__(self, source, error=DocoptExit): - self += source.split() if hasattr(source, 'split') else source - self.error = error - - @staticmethod - def from_pattern(source): - source = re.sub(r'([\[\]\(\)\|]|\.\.\.)', r' \1 ', source) - source = [s for s in re.split('\s+|(\S*<.*?>)', source) if s] - return Tokens(source, error=DocoptLanguageError) - - def move(self): - return self.pop(0) if len(self) else None - - def current(self): - return self[0] if len(self) else None - - -def parse_long(tokens, options): - """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" - long, eq, value = tokens.move().partition('=') - assert long.startswith('--') - value = None if eq == value == '' else value - similar = [o for o in options if o.long == long] - if tokens.error is DocoptExit and similar == []: # if no exact match - similar = [o for o in options if o.long and o.long.startswith(long)] - if len(similar) > 1: # might be simply specified ambiguously 2+ times? - raise tokens.error('%s is not a unique prefix: %s?' % - (long, ', '.join(o.long for o in similar))) - elif len(similar) < 1: - argcount = 1 if eq == '=' else 0 - o = Option(None, long, argcount) - options.append(o) - if tokens.error is DocoptExit: - o = Option(None, long, argcount, value if argcount else True) - else: - o = Option(similar[0].short, similar[0].long, - similar[0].argcount, similar[0].value) - if o.argcount == 0: - if value is not None: - raise tokens.error('%s must not have an argument' % o.long) - else: - if value is None: - if tokens.current() in [None, '--']: - raise tokens.error('%s requires argument' % o.long) - value = tokens.move() - if tokens.error is DocoptExit: - o.value = value if value is not None else True - return [o] - - -def parse_shorts(tokens, options): - """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" - token = tokens.move() - assert token.startswith('-') and not token.startswith('--') - left = token.lstrip('-') - parsed = [] - while left != '': - short, left = '-' + left[0], left[1:] - similar = [o for o in options if o.short == short] - if len(similar) > 1: - raise tokens.error('%s is specified ambiguously %d times' % - (short, len(similar))) - elif len(similar) < 1: - o = Option(short, None, 0) - options.append(o) - if tokens.error is DocoptExit: - o = Option(short, None, 0, True) - else: # why copying is necessary here? - o = Option(short, similar[0].long, - similar[0].argcount, similar[0].value) - value = None - if o.argcount != 0: - if left == '': - if tokens.current() in [None, '--']: - raise tokens.error('%s requires argument' % short) - value = tokens.move() - else: - value = left - left = '' - if tokens.error is DocoptExit: - o.value = value if value is not None else True - parsed.append(o) - return parsed - - -def parse_pattern(source, options): - tokens = Tokens.from_pattern(source) - result = parse_expr(tokens, options) - if tokens.current() is not None: - raise tokens.error('unexpected ending: %r' % ' '.join(tokens)) - return Required(*result) - - -def parse_expr(tokens, options): - """expr ::= seq ( '|' seq )* ;""" - seq = parse_seq(tokens, options) - if tokens.current() != '|': - return seq - result = [Required(*seq)] if len(seq) > 1 else seq - while tokens.current() == '|': - tokens.move() - seq = parse_seq(tokens, options) - result += [Required(*seq)] if len(seq) > 1 else seq - return [Either(*result)] if len(result) > 1 else result - - -def parse_seq(tokens, options): - """seq ::= ( atom [ '...' ] )* ;""" - result = [] - while tokens.current() not in [None, ']', ')', '|']: - atom = parse_atom(tokens, options) - if tokens.current() == '...': - atom = [OneOrMore(*atom)] - tokens.move() - result += atom - return result - - -def parse_atom(tokens, options): - """atom ::= '(' expr ')' | '[' expr ']' | 'options' - | long | shorts | argument | command ; - """ - token = tokens.current() - result = [] - if token in '([': - tokens.move() - matching, pattern = {'(': [')', Required], '[': [']', Optional]}[token] - result = pattern(*parse_expr(tokens, options)) - if tokens.move() != matching: - raise tokens.error("unmatched '%s'" % token) - return [result] - elif token == 'options': - tokens.move() - return [OptionsShortcut()] - elif token.startswith('--') and token != '--': - return parse_long(tokens, options) - elif token.startswith('-') and token not in ('-', '--'): - return parse_shorts(tokens, options) - elif token.startswith('<') and token.endswith('>') or token.isupper(): - return [Argument(tokens.move())] - else: - return [Command(tokens.move())] - - -def parse_argv(tokens, options, options_first=False): - """Parse command-line argument vector. - - If options_first: - argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; - else: - argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; - - """ - parsed = [] - while tokens.current() is not None: - if tokens.current() == '--': - return parsed + [Argument(None, v) for v in tokens] - elif tokens.current().startswith('--'): - parsed += parse_long(tokens, options) - elif tokens.current().startswith('-') and tokens.current() != '-': - parsed += parse_shorts(tokens, options) - elif options_first: - return parsed + [Argument(None, v) for v in tokens] - else: - parsed.append(Argument(None, tokens.move())) - return parsed - - -def parse_defaults(doc): - defaults = [] - for s in parse_section('options:', doc): - # FIXME corner case "bla: options: --foo" - _, _, s = s.partition(':') # get rid of "options:" - split = re.split('\n[ \t]*(-\S+?)', '\n' + s)[1:] - split = [s1 + s2 for s1, s2 in zip(split[::2], split[1::2])] - options = [Option.parse(s) for s in split if s.startswith('-')] - defaults += options - return defaults - - -def parse_section(name, source): - pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', - re.IGNORECASE | re.MULTILINE) - return [s.strip() for s in pattern.findall(source)] - - -def formal_usage(section): - _, _, section = section.partition(':') # drop "usage:" - pu = section.split() - return '( ' + ' '.join(') | (' if s == pu[0] else s for s in pu[1:]) + ' )' - - -def extras(help, version, options, doc): - if help and any((o.name in ('-h', '--help')) and o.value for o in options): - print(doc.strip("\n")) - sys.exit() - if version and any(o.name == '--version' and o.value for o in options): - print(version) - sys.exit() - - -class Dict(dict): - def __repr__(self): - return '{%s}' % ',\n '.join('%r: %r' % i for i in sorted(self.items())) - - -def docopt(doc, argv=None, help=True, version=None, options_first=False): - """Parse `argv` based on command-line interface described in `doc`. - - `docopt` creates your command-line interface based on its - description that you pass as `doc`. Such description can contain - --options, , commands, which could be - [optional], (required), (mutually | exclusive) or repeated... - - Parameters - ---------- - doc : str - Description of your command-line interface. - argv : list of str, optional - Argument vector to be parsed. sys.argv[1:] is used if not - provided. - help : bool (default: True) - Set to False to disable automatic help on -h or --help - options. - version : any object - If passed, the object will be printed if --version is in - `argv`. - options_first : bool (default: False) - Set to True to require options precede positional arguments, - i.e. to forbid options and positional arguments intermix. - - Returns - ------- - args : dict - A dictionary, where keys are names of command-line elements - such as e.g. "--verbose" and "", and values are the - parsed values of those elements. - - Example - ------- - >>> from docopt import docopt - >>> doc = ''' - ... Usage: - ... my_program tcp [--timeout=] - ... my_program serial [--baud=] [--timeout=] - ... my_program (-h | --help | --version) - ... - ... Options: - ... -h, --help Show this screen and exit. - ... --baud= Baudrate [default: 9600] - ... ''' - >>> argv = ['tcp', '127.0.0.1', '80', '--timeout', '30'] - >>> docopt(doc, argv) - {'--baud': '9600', - '--help': False, - '--timeout': '30', - '--version': False, - '': '127.0.0.1', - '': '80', - 'serial': False, - 'tcp': True} - - See also - -------- - * For video introduction see http://docopt.org - * Full documentation is available in README.rst as well as online - at https://github.com/docopt/docopt#readme - - """ - argv = sys.argv[1:] if argv is None else argv - - usage_sections = parse_section('usage:', doc) - if len(usage_sections) == 0: - raise DocoptLanguageError('"usage:" (case-insensitive) not found.') - if len(usage_sections) > 1: - raise DocoptLanguageError('More than one "usage:" (case-insensitive).') - DocoptExit.usage = usage_sections[0] - - options = parse_defaults(doc) - pattern = parse_pattern(formal_usage(DocoptExit.usage), options) - # [default] syntax for argument is disabled - #for a in pattern.flat(Argument): - # same_name = [d for d in arguments if d.name == a.name] - # if same_name: - # a.value = same_name[0].value - argv = parse_argv(Tokens(argv), list(options), options_first) - pattern_options = set(pattern.flat(Option)) - for options_shortcut in pattern.flat(OptionsShortcut): - doc_options = parse_defaults(doc) - options_shortcut.children = list(set(doc_options) - pattern_options) - #if any_options: - # options_shortcut.children += [Option(o.short, o.long, o.argcount) - # for o in argv if type(o) is Option] - extras(help, version, argv, doc) - matched, left, collected = pattern.fix().match(argv) - if matched and left == []: # better error message if left? - return Dict((a.name, a.value) for a in (pattern.flat() + collected)) - raise DocoptExit() diff --git a/vendor/pip-pop/pip-diff b/vendor/pip-pop/pip-diff deleted file mode 100755 index a0d5f8b13..000000000 --- a/vendor/pip-pop/pip-diff +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Usage: - pip-diff (--fresh | --stale) - pip-diff (-h | --help) - -Options: - -h --help Show this screen. - --fresh List newly added packages. - --stale List removed packages. -""" -import os -from docopt import docopt -from pip.req import parse_requirements -from pip.index import PackageFinder - -class Requirements(object): - def __init__(self, reqfile=None): - super(Requirements, self).__init__() - self.path = reqfile - self.requirements = [] - - if reqfile: - self.load(reqfile) - - def __repr__(self): - return ''.format(self.path) - - def load(self, reqfile): - - if not os.path.exists(reqfile): - raise ValueError('The given requirements file does not exist.') - - finder = PackageFinder([], []) - for requirement in parse_requirements(reqfile, finder=finder): - if requirement.req: - self.requirements.append(requirement.req) - - - def diff(self, requirements, ignore_versions=False): - r1 = self - r2 = requirements - results = {'fresh': [], 'stale': []} - - # Generate fresh packages. - other_reqs = ( - [r.project_name for r in r1.requirements] - if ignore_versions else r1.requirements - ) - - for req in r2.requirements: - r = req.project_name if ignore_versions else req - - if r not in other_reqs: - results['fresh'].append(req) - - # Generate stale packages. - other_reqs = ( - [r.project_name for r in r2.requirements] - if ignore_versions else r2.requirements - ) - - for req in r1.requirements: - r = req.project_name if ignore_versions else req - - if r not in other_reqs: - results['stale'].append(req) - - return results - - - - - -def diff(r1, r2, include_fresh=False, include_stale=False): - - include_versions = True if include_stale else False - - try: - r1 = Requirements(r1) - r2 = Requirements(r2) - except ValueError: - print('There was a problem loading the given requirements files.') - exit(os.EX_NOINPUT) - - results = r1.diff(r2, ignore_versions=True) - - if include_fresh: - for line in results['fresh']: - print(line.project_name if include_versions else line) - - if include_stale: - for line in results['stale']: - print(line.project_name if include_versions else line) - - - -def main(): - args = docopt(__doc__, version='pip-diff') - - kwargs = { - 'r1': args[''], - 'r2': args[''], - 'include_fresh': args['--fresh'], - 'include_stale': args['--stale'] - } - - diff(**kwargs) - - - -if __name__ == '__main__': - main() diff --git a/vendor/pip-pop/pip-grep b/vendor/pip-pop/pip-grep deleted file mode 100755 index 9ce53e3a8..000000000 --- a/vendor/pip-pop/pip-grep +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Usage: - pip-grep [-s] ... - -Options: - -h --help Show this screen. -""" -import os -from docopt import docopt -from pip.req import parse_requirements -from pip.index import PackageFinder - - -class Requirements(object): - def __init__(self, reqfile=None): - super(Requirements, self).__init__() - self.path = reqfile - self.requirements = [] - - if reqfile: - self.load(reqfile) - - def __repr__(self): - return ''.format(self.path) - - def load(self, reqfile): - - if not os.path.exists(reqfile): - raise ValueError('The given requirements file does not exist.') - - finder = PackageFinder([], []) - for requirement in parse_requirements(reqfile, finder=finder): - self.requirements.append(requirement) - - - - -def grep(reqfile, packages, silent=False): - - try: - r = Requirements(reqfile) - except ValueError: - - if not silent: - print('There was a problem loading the given requirement file.') - - exit(os.EX_NOINPUT) - - for requirement in r.requirements: - - if requirement.req: - - if requirement.req.project_name in packages: - - if not silent: - print('Package {} found!'.format(requirement.req.project_name)) - - exit(0) - - if not silent: - print('Not found.'.format(requirement.req.project_name)) - - exit(1) - - -def main(): - args = docopt(__doc__, version='pip-grep') - - kwargs = {'reqfile': args[''], 'packages': args[''], 'silent': args['-s']} - - - grep(**kwargs) - - - -if __name__ == '__main__': - main() diff --git a/vendor/python.gunicorn.sh b/vendor/python.gunicorn.sh new file mode 100755 index 000000000..2ac5e9e1e --- /dev/null +++ b/vendor/python.gunicorn.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +# Note: Since this is a .profile.d/ script it will be sourced, meaning that we cannot enable +# exit on error, have to use return not exit, and returning non-zero doesn't have an effect. + +# Automatic configuration for Gunicorn's ForwardedAllowIPS setting. +export FORWARDED_ALLOW_IPS='*' + +# Automatic configuration for Gunicorn's stdout access log setting. +export GUNICORN_CMD_ARGS=${GUNICORN_CMD_ARGS:-"--access-logfile -"} diff --git a/vendor/setuptools-7.0.tar.gz b/vendor/setuptools-7.0.tar.gz deleted file mode 100644 index 33e584619..000000000 Binary files a/vendor/setuptools-7.0.tar.gz and /dev/null differ diff --git a/vendor/shunit2 b/vendor/shunit2 deleted file mode 100755 index 9ec6c88bb..000000000 --- a/vendor/shunit2 +++ /dev/null @@ -1,1048 +0,0 @@ -#! /bin/sh -# $Id: shunit2 329 2011-04-27 22:37:18Z kate.ward@forestent.com $ -# vim:et:ft=sh:sts=2:sw=2 -# -# Copyright 2008 Kate Ward. All Rights Reserved. -# Released under the LGPL (GNU Lesser General Public License) -# -# shUnit2 -- Unit testing framework for Unix shell scripts. -# http://code.google.com/p/shunit2/ -# -# Author: kate.ward@forestent.com (Kate Ward) -# -# shUnit2 is a xUnit based unit test framework for Bourne shell scripts. It is -# based on the popular JUnit unit testing framework for Java. - -# return if shunit already loaded -[ -n "${SHUNIT_VERSION:-}" ] && exit 0 - -SHUNIT_VERSION='2.1.6' - -SHUNIT_TRUE=0 -SHUNIT_FALSE=1 -SHUNIT_ERROR=2 - -# enable strict mode by default -SHUNIT_STRICT=${SHUNIT_STRICT:-${SHUNIT_TRUE}} - -_shunit_warn() { echo "shunit2:WARN $@" >&2; } -_shunit_error() { echo "shunit2:ERROR $@" >&2; } -_shunit_fatal() { echo "shunit2:FATAL $@" >&2; exit ${SHUNIT_ERROR}; } - -# specific shell checks -if [ -n "${ZSH_VERSION:-}" ]; then - setopt |grep "^shwordsplit$" >/dev/null - if [ $? -ne ${SHUNIT_TRUE} ]; then - _shunit_fatal 'zsh shwordsplit option is required for proper operation' - fi - if [ -z "${SHUNIT_PARENT:-}" ]; then - _shunit_fatal "zsh does not pass \$0 through properly. please declare \ -\"SHUNIT_PARENT=\$0\" before calling shUnit2" - fi -fi - -# -# constants -# - -__SHUNIT_ASSERT_MSG_PREFIX='ASSERT:' -__SHUNIT_MODE_SOURCED='sourced' -__SHUNIT_MODE_STANDALONE='standalone' -__SHUNIT_PARENT=${SHUNIT_PARENT:-$0} - -# set the constants readonly -shunit_constants_=`set |grep '^__SHUNIT_' |cut -d= -f1` -echo "${shunit_constants_}" |grep '^Binary file' >/dev/null && \ - shunit_constants_=`set |grep -a '^__SHUNIT_' |cut -d= -f1` -for shunit_constant_ in ${shunit_constants_}; do - shunit_ro_opts_='' - case ${ZSH_VERSION:-} in - '') ;; # this isn't zsh - [123].*) ;; # early versions (1.x, 2.x, 3.x) - *) shunit_ro_opts_='-g' ;; # all later versions. declare readonly globally - esac - readonly ${shunit_ro_opts_} ${shunit_constant_} -done -unset shunit_constant_ shunit_constants_ shunit_ro_opts_ - -# variables -__shunit_lineno='' # line number of executed test -__shunit_mode=${__SHUNIT_MODE_SOURCED} # operating mode -__shunit_reportGenerated=${SHUNIT_FALSE} # is report generated -__shunit_script='' # filename of unittest script (standalone mode) -__shunit_skip=${SHUNIT_FALSE} # is skipping enabled -__shunit_suite='' # suite of tests to execute - -# counts of tests -__shunit_testSuccess=${SHUNIT_TRUE} -__shunit_testsTotal=0 -__shunit_testsPassed=0 -__shunit_testsFailed=0 - -# counts of asserts -__shunit_assertsTotal=0 -__shunit_assertsPassed=0 -__shunit_assertsFailed=0 -__shunit_assertsSkipped=0 - -# macros -_SHUNIT_LINENO_='eval __shunit_lineno=""; if [ "${1:-}" = "--lineno" ]; then [ -n "$2" ] && __shunit_lineno="[$2] "; shift 2; fi' - -#----------------------------------------------------------------------------- -# assert functions -# - -# Assert that two values are equal to one another. -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertEquals() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "assertEquals() requires two or three arguments; $# given" - _shunit_error "1: ${1:+$1} 2: ${2:+$2} 3: ${3:+$3}${4:+ 4: $4}" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - shunit_expected_=$1 - shunit_actual_=$2 - - shunit_return=${SHUNIT_TRUE} - if [ "${shunit_expected_}" = "${shunit_actual_}" ]; then - _shunit_assertPass - else - failNotEquals "${shunit_message_}" "${shunit_expected_}" "${shunit_actual_}" - shunit_return=${SHUNIT_FALSE} - fi - - unset shunit_message_ shunit_expected_ shunit_actual_ - return ${shunit_return} -} -_ASSERT_EQUALS_='eval assertEquals --lineno "${LINENO:-}"' - -# Assert that two values are not equal to one another. -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertNotEquals() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "assertNotEquals() requires two or three arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - shunit_expected_=$1 - shunit_actual_=$2 - - shunit_return=${SHUNIT_TRUE} - if [ "${shunit_expected_}" != "${shunit_actual_}" ]; then - _shunit_assertPass - else - failSame "${shunit_message_}" "$@" - shunit_return=${SHUNIT_FALSE} - fi - - unset shunit_message_ shunit_expected_ shunit_actual_ - return ${shunit_return} -} -_ASSERT_NOT_EQUALS_='eval assertNotEquals --lineno "${LINENO:-}"' - -# Assert that a value is null (i.e. an empty string) -# -# Args: -# message: string: failure message [optional] -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertNull() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 1 -o $# -gt 2 ]; then - _shunit_error "assertNull() requires one or two arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 2 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - assertTrue "${shunit_message_}" "[ -z '$1' ]" - shunit_return=$? - - unset shunit_message_ - return ${shunit_return} -} -_ASSERT_NULL_='eval assertNull --lineno "${LINENO:-}"' - -# Assert that a value is not null (i.e. a non-empty string) -# -# Args: -# message: string: failure message [optional] -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertNotNull() -{ - ${_SHUNIT_LINENO_} - if [ $# -gt 2 ]; then # allowing 0 arguments as $1 might actually be null - _shunit_error "assertNotNull() requires one or two arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 2 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - shunit_actual_=`_shunit_escapeCharactersInString "${1:-}"` - test -n "${shunit_actual_}" - assertTrue "${shunit_message_}" $? - shunit_return=$? - - unset shunit_actual_ shunit_message_ - return ${shunit_return} -} -_ASSERT_NOT_NULL_='eval assertNotNull --lineno "${LINENO:-}"' - -# Assert that two values are the same (i.e. equal to one another). -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertSame() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "assertSame() requires two or three arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - assertEquals "${shunit_message_}" "$1" "$2" - shunit_return=$? - - unset shunit_message_ - return ${shunit_return} -} -_ASSERT_SAME_='eval assertSame --lineno "${LINENO:-}"' - -# Assert that two values are not the same (i.e. not equal to one another). -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertNotSame() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "assertNotSame() requires two or three arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_:-}$1" - shift - fi - assertNotEquals "${shunit_message_}" "$1" "$2" - shunit_return=$? - - unset shunit_message_ - return ${shunit_return} -} -_ASSERT_NOT_SAME_='eval assertNotSame --lineno "${LINENO:-}"' - -# Assert that a value or shell test condition is true. -# -# In shell, a value of 0 is true and a non-zero value is false. Any integer -# value passed can thereby be tested. -# -# Shell supports much more complicated tests though, and a means to support -# them was needed. As such, this function tests that conditions are true or -# false through evaluation rather than just looking for a true or false. -# -# The following test will succeed: -# assertTrue 0 -# assertTrue "[ 34 -gt 23 ]" -# The folloing test will fail with a message: -# assertTrue 123 -# assertTrue "test failed" "[ -r '/non/existant/file' ]" -# -# Args: -# message: string: failure message [optional] -# condition: string: integer value or shell conditional statement -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertTrue() -{ - ${_SHUNIT_LINENO_} - if [ $# -gt 2 ]; then - _shunit_error "assertTrue() takes one two arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 2 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - shunit_condition_=$1 - - # see if condition is an integer, i.e. a return value - shunit_match_=`expr "${shunit_condition_}" : '\([0-9]*\)'` - shunit_return=${SHUNIT_TRUE} - if [ -z "${shunit_condition_}" ]; then - # null condition - shunit_return=${SHUNIT_FALSE} - elif [ -n "${shunit_match_}" -a "${shunit_condition_}" = "${shunit_match_}" ] - then - # possible return value. treating 0 as true, and non-zero as false. - [ ${shunit_condition_} -ne 0 ] && shunit_return=${SHUNIT_FALSE} - else - # (hopefully) a condition - ( eval ${shunit_condition_} ) >/dev/null 2>&1 - [ $? -ne 0 ] && shunit_return=${SHUNIT_FALSE} - fi - - # record the test - if [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then - _shunit_assertPass - else - _shunit_assertFail "${shunit_message_}" - fi - - unset shunit_message_ shunit_condition_ shunit_match_ - return ${shunit_return} -} -_ASSERT_TRUE_='eval assertTrue --lineno "${LINENO:-}"' - -# Assert that a value or shell test condition is false. -# -# In shell, a value of 0 is true and a non-zero value is false. Any integer -# value passed can thereby be tested. -# -# Shell supports much more complicated tests though, and a means to support -# them was needed. As such, this function tests that conditions are true or -# false through evaluation rather than just looking for a true or false. -# -# The following test will succeed: -# assertFalse 1 -# assertFalse "[ 'apples' = 'oranges' ]" -# The folloing test will fail with a message: -# assertFalse 0 -# assertFalse "test failed" "[ 1 -eq 1 -a 2 -eq 2 ]" -# -# Args: -# message: string: failure message [optional] -# condition: string: integer value or shell conditional statement -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -assertFalse() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 1 -o $# -gt 2 ]; then - _shunit_error "assertFalse() quires one or two arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 2 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - shunit_condition_=$1 - - # see if condition is an integer, i.e. a return value - shunit_match_=`expr "${shunit_condition_}" : '\([0-9]*\)'` - shunit_return=${SHUNIT_TRUE} - if [ -z "${shunit_condition_}" ]; then - # null condition - shunit_return=${SHUNIT_FALSE} - elif [ -n "${shunit_match_}" -a "${shunit_condition_}" = "${shunit_match_}" ] - then - # possible return value. treating 0 as true, and non-zero as false. - [ ${shunit_condition_} -eq 0 ] && shunit_return=${SHUNIT_FALSE} - else - # (hopefully) a condition - ( eval ${shunit_condition_} ) >/dev/null 2>&1 - [ $? -eq 0 ] && shunit_return=${SHUNIT_FALSE} - fi - - # record the test - if [ ${shunit_return} -eq ${SHUNIT_TRUE} ]; then - _shunit_assertPass - else - _shunit_assertFail "${shunit_message_}" - fi - - unset shunit_message_ shunit_condition_ shunit_match_ - return ${shunit_return} -} -_ASSERT_FALSE_='eval assertFalse --lineno "${LINENO:-}"' - -#----------------------------------------------------------------------------- -# failure functions -# - -# Records a test failure. -# -# Args: -# message: string: failure message [optional] -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -fail() -{ - ${_SHUNIT_LINENO_} - if [ $# -gt 1 ]; then - _shunit_error "fail() requires zero or one arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 1 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - - _shunit_assertFail "${shunit_message_}" - - unset shunit_message_ - return ${SHUNIT_FALSE} -} -_FAIL_='eval fail --lineno "${LINENO:-}"' - -# Records a test failure, stating two values were not equal. -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -failNotEquals() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "failNotEquals() requires one or two arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - shunit_expected_=$1 - shunit_actual_=$2 - - _shunit_assertFail "${shunit_message_:+${shunit_message_} }expected:<${shunit_expected_}> but was:<${shunit_actual_}>" - - unset shunit_message_ shunit_expected_ shunit_actual_ - return ${SHUNIT_FALSE} -} -_FAIL_NOT_EQUALS_='eval failNotEquals --lineno "${LINENO:-}"' - -# Records a test failure, stating two values should have been the same. -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -failSame() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "failSame() requires two or three arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - - _shunit_assertFail "${shunit_message_:+${shunit_message_} }expected not same" - - unset shunit_message_ - return ${SHUNIT_FALSE} -} -_FAIL_SAME_='eval failSame --lineno "${LINENO:-}"' - -# Records a test failure, stating two values were not equal. -# -# This is functionally equivalent to calling failNotEquals(). -# -# Args: -# message: string: failure message [optional] -# expected: string: expected value -# actual: string: actual value -# Returns: -# integer: success (TRUE/FALSE/ERROR constant) -failNotSame() -{ - ${_SHUNIT_LINENO_} - if [ $# -lt 2 -o $# -gt 3 ]; then - _shunit_error "failNotEquals() requires one or two arguments; $# given" - return ${SHUNIT_ERROR} - fi - _shunit_shouldSkip && return ${SHUNIT_TRUE} - - shunit_message_=${__shunit_lineno} - if [ $# -eq 3 ]; then - shunit_message_="${shunit_message_}$1" - shift - fi - failNotEquals "${shunit_message_}" "$1" "$2" - shunit_return=$? - - unset shunit_message_ - return ${shunit_return} -} -_FAIL_NOT_SAME_='eval failNotSame --lineno "${LINENO:-}"' - -#----------------------------------------------------------------------------- -# skipping functions -# - -# Force remaining assert and fail functions to be "skipped". -# -# This function forces the remaining assert and fail functions to be "skipped", -# i.e. they will have no effect. Each function skipped will be recorded so that -# the total of asserts and fails will not be altered. -# -# Args: -# None -startSkipping() -{ - __shunit_skip=${SHUNIT_TRUE} -} - -# Resume the normal recording behavior of assert and fail calls. -# -# Args: -# None -endSkipping() -{ - __shunit_skip=${SHUNIT_FALSE} -} - -# Returns the state of assert and fail call skipping. -# -# Args: -# None -# Returns: -# boolean: (TRUE/FALSE constant) -isSkipping() -{ - return ${__shunit_skip} -} - -#----------------------------------------------------------------------------- -# suite functions -# - -# Stub. This function should contains all unit test calls to be made. -# -# DEPRECATED (as of 2.1.0) -# -# This function can be optionally overridden by the user in their test suite. -# -# If this function exists, it will be called when shunit2 is sourced. If it -# does not exist, shunit2 will search the parent script for all functions -# beginning with the word 'test', and they will be added dynamically to the -# test suite. -# -# This function should be overridden by the user in their unit test suite. -# Note: see _shunit_mktempFunc() for actual implementation -# -# Args: -# None -#suite() { :; } # DO NOT UNCOMMENT THIS FUNCTION - -# Adds a function name to the list of tests schedule for execution. -# -# This function should only be called from within the suite() function. -# -# Args: -# function: string: name of a function to add to current unit test suite -suite_addTest() -{ - shunit_func_=${1:-} - - __shunit_suite="${__shunit_suite:+${__shunit_suite} }${shunit_func_}" - __shunit_testsTotal=`expr ${__shunit_testsTotal} + 1` - - unset shunit_func_ -} - -# Stub. This function will be called once before any tests are run. -# -# Common one-time environment preparation tasks shared by all tests can be -# defined here. -# -# This function should be overridden by the user in their unit test suite. -# Note: see _shunit_mktempFunc() for actual implementation -# -# Args: -# None -#oneTimeSetUp() { :; } # DO NOT UNCOMMENT THIS FUNCTION - -# Stub. This function will be called once after all tests are finished. -# -# Common one-time environment cleanup tasks shared by all tests can be defined -# here. -# -# This function should be overridden by the user in their unit test suite. -# Note: see _shunit_mktempFunc() for actual implementation -# -# Args: -# None -#oneTimeTearDown() { :; } # DO NOT UNCOMMENT THIS FUNCTION - -# Stub. This function will be called before each test is run. -# -# Common environment preparation tasks shared by all tests can be defined here. -# -# This function should be overridden by the user in their unit test suite. -# Note: see _shunit_mktempFunc() for actual implementation -# -# Args: -# None -#setUp() { :; } - -# Note: see _shunit_mktempFunc() for actual implementation -# Stub. This function will be called after each test is run. -# -# Common environment cleanup tasks shared by all tests can be defined here. -# -# This function should be overridden by the user in their unit test suite. -# Note: see _shunit_mktempFunc() for actual implementation -# -# Args: -# None -#tearDown() { :; } # DO NOT UNCOMMENT THIS FUNCTION - -#------------------------------------------------------------------------------ -# internal shUnit2 functions -# - -# Create a temporary directory to store various run-time files in. -# -# This function is a cross-platform temporary directory creation tool. Not all -# OSes have the mktemp function, so one is included here. -# -# Args: -# None -# Outputs: -# string: the temporary directory that was created -_shunit_mktempDir() -{ - # try the standard mktemp function - ( exec mktemp -dqt shunit.XXXXXX 2>/dev/null ) && return - - # the standard mktemp didn't work. doing our own. - if [ -r '/dev/urandom' -a -x '/usr/bin/od' ]; then - _shunit_random_=`/usr/bin/od -vAn -N4 -tx4 "${_shunit_file_}" -#! /bin/sh -exit ${SHUNIT_TRUE} -EOF - chmod +x "${_shunit_file_}" - done - - unset _shunit_file_ -} - -# Final cleanup function to leave things as we found them. -# -# Besides removing the temporary directory, this function is in charge of the -# final exit code of the unit test. The exit code is based on how the script -# was ended (e.g. normal exit, or via Ctrl-C). -# -# Args: -# name: string: name of the trap called (specified when trap defined) -_shunit_cleanup() -{ - _shunit_name_=$1 - - case ${_shunit_name_} in - EXIT) _shunit_signal_=0 ;; - INT) _shunit_signal_=2 ;; - TERM) _shunit_signal_=15 ;; - *) - _shunit_warn "unrecognized trap value (${_shunit_name_})" - _shunit_signal_=0 - ;; - esac - - # do our work - rm -fr "${__shunit_tmpDir}" - - # exit for all non-EXIT signals - if [ ${_shunit_name_} != 'EXIT' ]; then - _shunit_warn "trapped and now handling the (${_shunit_name_}) signal" - # disable EXIT trap - trap 0 - # add 128 to signal and exit - exit `expr ${_shunit_signal_} + 128` - elif [ ${__shunit_reportGenerated} -eq ${SHUNIT_FALSE} ] ; then - _shunit_assertFail 'Unknown failure encountered running a test' - _shunit_generateReport - exit ${SHUNIT_ERROR} - fi - - unset _shunit_name_ _shunit_signal_ -} - -# The actual running of the tests happens here. -# -# Args: -# None -_shunit_execSuite() -{ - for _shunit_test_ in ${__shunit_suite}; do - __shunit_testSuccess=${SHUNIT_TRUE} - - # disable skipping - endSkipping - - # execute the per-test setup function - setUp - - # execute the test - echo "${_shunit_test_}" - eval ${_shunit_test_} - - # execute the per-test tear-down function - tearDown - - # update stats - if [ ${__shunit_testSuccess} -eq ${SHUNIT_TRUE} ]; then - __shunit_testsPassed=`expr ${__shunit_testsPassed} + 1` - else - __shunit_testsFailed=`expr ${__shunit_testsFailed} + 1` - fi - done - - unset _shunit_test_ -} - -# Generates the user friendly report with appropriate OK/FAILED message. -# -# Args: -# None -# Output: -# string: the report of successful and failed tests, as well as totals. -_shunit_generateReport() -{ - _shunit_ok_=${SHUNIT_TRUE} - - # if no exit code was provided one, determine an appropriate one - [ ${__shunit_testsFailed} -gt 0 \ - -o ${__shunit_testSuccess} -eq ${SHUNIT_FALSE} ] \ - && _shunit_ok_=${SHUNIT_FALSE} - - echo - if [ ${__shunit_testsTotal} -eq 1 ]; then - echo "Ran ${__shunit_testsTotal} test." - else - echo "Ran ${__shunit_testsTotal} tests." - fi - - _shunit_failures_='' - _shunit_skipped_='' - [ ${__shunit_assertsFailed} -gt 0 ] \ - && _shunit_failures_="failures=${__shunit_assertsFailed}" - [ ${__shunit_assertsSkipped} -gt 0 ] \ - && _shunit_skipped_="skipped=${__shunit_assertsSkipped}" - - if [ ${_shunit_ok_} -eq ${SHUNIT_TRUE} ]; then - _shunit_msg_='OK' - [ -n "${_shunit_skipped_}" ] \ - && _shunit_msg_="${_shunit_msg_} (${_shunit_skipped_})" - else - _shunit_msg_="FAILED (${_shunit_failures_}" - [ -n "${_shunit_skipped_}" ] \ - && _shunit_msg_="${_shunit_msg_},${_shunit_skipped_}" - _shunit_msg_="${_shunit_msg_})" - fi - - echo - echo ${_shunit_msg_} - __shunit_reportGenerated=${SHUNIT_TRUE} - - unset _shunit_failures_ _shunit_msg_ _shunit_ok_ _shunit_skipped_ -} - -# Test for whether a function should be skipped. -# -# Args: -# None -# Returns: -# boolean: whether the test should be skipped (TRUE/FALSE constant) -_shunit_shouldSkip() -{ - [ ${__shunit_skip} -eq ${SHUNIT_FALSE} ] && return ${SHUNIT_FALSE} - _shunit_assertSkip -} - -# Records a successful test. -# -# Args: -# None -_shunit_assertPass() -{ - __shunit_assertsPassed=`expr ${__shunit_assertsPassed} + 1` - __shunit_assertsTotal=`expr ${__shunit_assertsTotal} + 1` -} - -# Records a test failure. -# -# Args: -# message: string: failure message to provide user -_shunit_assertFail() -{ - _shunit_msg_=$1 - - __shunit_testSuccess=${SHUNIT_FALSE} - __shunit_assertsFailed=`expr ${__shunit_assertsFailed} + 1` - __shunit_assertsTotal=`expr ${__shunit_assertsTotal} + 1` - echo "${__SHUNIT_ASSERT_MSG_PREFIX}${_shunit_msg_}" - - unset _shunit_msg_ -} - -# Records a skipped test. -# -# Args: -# None -_shunit_assertSkip() -{ - __shunit_assertsSkipped=`expr ${__shunit_assertsSkipped} + 1` - __shunit_assertsTotal=`expr ${__shunit_assertsTotal} + 1` -} - -# Prepare a script filename for sourcing. -# -# Args: -# script: string: path to a script to source -# Returns: -# string: filename prefixed with ./ (if necessary) -_shunit_prepForSourcing() -{ - _shunit_script_=$1 - case "${_shunit_script_}" in - /*|./*) echo "${_shunit_script_}" ;; - *) echo "./${_shunit_script_}" ;; - esac - unset _shunit_script_ -} - -# Escape a character in a string. -# -# Args: -# c: string: unescaped character -# s: string: to escape character in -# Returns: -# string: with escaped character(s) -_shunit_escapeCharInStr() -{ - [ -n "$2" ] || return # no point in doing work on an empty string - - # Note: using shorter variable names to prevent conflicts with - # _shunit_escapeCharactersInString(). - _shunit_c_=$1 - _shunit_s_=$2 - - - # escape the character - echo ''${_shunit_s_}'' |sed 's/\'${_shunit_c_}'/\\\'${_shunit_c_}'/g' - - unset _shunit_c_ _shunit_s_ -} - -# Escape a character in a string. -# -# Args: -# str: string: to escape characters in -# Returns: -# string: with escaped character(s) -_shunit_escapeCharactersInString() -{ - [ -n "$1" ] || return # no point in doing work on an empty string - - _shunit_str_=$1 - - # Note: using longer variable names to prevent conflicts with - # _shunit_escapeCharInStr(). - for _shunit_char_ in '"' '$' "'" '`'; do - _shunit_str_=`_shunit_escapeCharInStr "${_shunit_char_}" "${_shunit_str_}"` - done - - echo "${_shunit_str_}" - unset _shunit_char_ _shunit_str_ -} - -# Extract list of functions to run tests against. -# -# Args: -# script: string: name of script to extract functions from -# Returns: -# string: of function names -_shunit_extractTestFunctions() -{ - _shunit_script_=$1 - - # extract the lines with test function names, strip of anything besides the - # function name, and output everything on a single line. - _shunit_regex_='^[ ]*(function )*test[A-Za-z0-9_]* *\(\)' - egrep "${_shunit_regex_}" "${_shunit_script_}" \ - |sed 's/^[^A-Za-z0-9_]*//;s/^function //;s/\([A-Za-z0-9_]*\).*/\1/g' \ - |xargs - - unset _shunit_regex_ _shunit_script_ -} - -#------------------------------------------------------------------------------ -# main -# - -# determine the operating mode -if [ $# -eq 0 ]; then - __shunit_script=${__SHUNIT_PARENT} - __shunit_mode=${__SHUNIT_MODE_SOURCED} -else - __shunit_script=$1 - [ -r "${__shunit_script}" ] || \ - _shunit_fatal "unable to read from ${__shunit_script}" - __shunit_mode=${__SHUNIT_MODE_STANDALONE} -fi - -# create a temporary storage location -__shunit_tmpDir=`_shunit_mktempDir` - -# provide a public temporary directory for unit test scripts -# TODO(kward): document this -SHUNIT_TMPDIR="${__shunit_tmpDir}/tmp" -mkdir "${SHUNIT_TMPDIR}" - -# setup traps to clean up after ourselves -trap '_shunit_cleanup EXIT' 0 -trap '_shunit_cleanup INT' 2 -trap '_shunit_cleanup TERM' 15 - -# create phantom functions to work around issues with Cygwin -_shunit_mktempFunc -PATH="${__shunit_tmpDir}:${PATH}" - -# make sure phantom functions are executable. this will bite if /tmp (or the -# current $TMPDIR) points to a path on a partition that was mounted with the -# 'noexec' option. the noexec command was created with _shunit_mktempFunc(). -noexec 2>/dev/null || _shunit_fatal \ - 'please declare TMPDIR with path on partition with exec permission' - -# we must manually source the tests in standalone mode -if [ "${__shunit_mode}" = "${__SHUNIT_MODE_STANDALONE}" ]; then - . "`_shunit_prepForSourcing \"${__shunit_script}\"`" -fi - -# execute the oneTimeSetUp function (if it exists) -oneTimeSetUp - -# execute the suite function defined in the parent test script -# deprecated as of 2.1.0 -suite - -# if no suite function was defined, dynamically build a list of functions -if [ -z "${__shunit_suite}" ]; then - shunit_funcs_=`_shunit_extractTestFunctions "${__shunit_script}"` - for shunit_func_ in ${shunit_funcs_}; do - suite_addTest ${shunit_func_} - done -fi -unset shunit_func_ shunit_funcs_ - -# execute the tests -_shunit_execSuite - -# execute the oneTimeTearDown function (if it exists) -oneTimeTearDown - -# generate the report -_shunit_generateReport - -# that's it folks -[ ${__shunit_testsFailed} -eq 0 ] -exit $? diff --git a/vendor/test-utils b/vendor/test-utils deleted file mode 100644 index 44d04def9..000000000 --- a/vendor/test-utils +++ /dev/null @@ -1,204 +0,0 @@ -#!/bin/sh - -# taken from -# https://github.com/ryanbrainard/heroku-buildpack-testrunner/blob/master/lib/test_utils.sh - -oneTimeSetUp() -{ - TEST_SUITE_CACHE="$(mktemp -d ${SHUNIT_TMPDIR}/test_suite_cache.XXXX)" -} - -oneTimeTearDown() -{ - rm -rf ${TEST_SUITE_CACHE} -} - -setUp() -{ - OUTPUT_DIR="$(mktemp -d ${SHUNIT_TMPDIR}/output.XXXX)" - STD_OUT="${OUTPUT_DIR}/stdout" - STD_ERR="${OUTPUT_DIR}/stderr" - BUILD_DIR="${OUTPUT_DIR}/build" - CACHE_DIR="${OUTPUT_DIR}/cache" - mkdir -p ${OUTPUT_DIR} - mkdir -p ${BUILD_DIR} - mkdir -p ${CACHE_DIR} -} - -tearDown() -{ - rm -rf ${OUTPUT_DIR} -} - -capture() -{ - resetCapture - - LAST_COMMAND="$@" - - $@ >${STD_OUT} 2>${STD_ERR} - RETURN=$? - rtrn=${RETURN} # deprecated -} - -resetCapture() -{ - if [ -f ${STD_OUT} ]; then - rm ${STD_OUT} - fi - - if [ -f ${STD_ERR} ]; then - rm ${STD_ERR} - fi - - unset LAST_COMMAND - unset RETURN - unset rtrn # deprecated -} - -detect() -{ - capture ${BUILDPACK_HOME}/bin/detect ${BUILD_DIR} -} - -compile() -{ - capture ${BUILDPACK_HOME}/bin/compile ${BUILD_DIR} ${CACHE_DIR} -} - -release() -{ - capture ${BUILDPACK_HOME}/bin/release ${BUILD_DIR} -} - -assertCapturedEquals() -{ - assertEquals "$@" "$(cat ${STD_OUT})" -} - -assertCapturedNotEquals() -{ - assertNotEquals "$@" "$(cat ${STD_OUT})" -} - -assertCaptured() -{ - assertFileContains "$@" "${STD_OUT}" -} - -assertNotCaptured() -{ - assertFileNotContains "$@" "${STD_OUT}" -} - -assertCapturedSuccess() -{ - assertEquals "Expected captured exit code to be 0; was <${RETURN}>" "0" "${RETURN}" - assertEquals "Expected STD_ERR to be empty; was <$(cat ${STD_ERR})>" "" "$(cat ${STD_ERR})" -} - -# assertCapturedError [[expectedErrorCode] expectedErrorMsg] -assertCapturedError() -{ - if [ $# -gt 1 ]; then - expectedErrorCode=${1} - shift - fi - - expectedErrorMsg=${1:-""} - - if [ -z ${expectedErrorCode} ]; then - assertTrue "Expected captured exit code to be greater than 0; was <${RETURN}>" "[ ${RETURN} -gt 0 ]" - else - assertTrue "Expected captured exit code to be <${expectedErrorCode}>; was <${RETURN}>" "[ ${RETURN} -eq ${expectedErrorCode} ]" - fi - - assertFileContains "Expected STD_OUT to contain error <${expectedErrorMsg}>" "${expectedErrorMsg}" "${STD_OUT}" - assertEquals "STD_ERR should always be empty" "" "$(cat ${STD_ERR})" -} - -assertAppDetected() -{ - expectedAppType=${1?"Must provide app type"} - - assertCapturedSuccess - assertEquals "${expectedAppType}" "$(cat ${STD_OUT})" -} - -assertNoAppDetected() -{ - assertEquals "1" "${RETURN}" - assertEquals "no" "$(cat ${STD_OUT})" - assertEquals "" "$(cat ${STD_ERR})" -} - -_assertContains() -{ - if [ 5 -eq $# ]; then - msg=$1 - shift - elif [ ! 4 -eq $# ]; then - fail "Expected 4 or 5 parameters; Receieved $# parameters" - fi - - needle=$1 - haystack=$2 - expectation=$3 - haystack_type=$4 - - case "${haystack_type}" in - "file") grep -q -F -e "${needle}" ${haystack} ;; - "text") echo "${haystack}" | grep -q -F -e "${needle}" ;; - esac - - if [ "${expectation}" != "$?" ]; then - case "${expectation}" in - 0) default_msg="Expected <${haystack}> to contain <${needle}>" ;; - 1) default_msg="Did not expect <${haystack}> to contain <${needle}>" ;; - esac - - fail "${msg:-${default_msg}}" - fi -} - -assertContains() -{ - _assertContains "$@" 0 "text" -} - -assertNotContains() -{ - _assertContains "$@" 1 "text" -} - -assertFileContains() -{ - _assertContains "$@" 0 "file" -} - -assertFileNotContains() -{ - _assertContains "$@" 1 "file" -} - -command_exists () { - type "$1" > /dev/null 2>&1 ; -} - -assertFileMD5() -{ - expectedHash=$1 - filename=$2 - - if command_exists "md5sum"; then - md5_cmd="md5sum ${filename}" - expected_md5_cmd_output="${expectedHash} ${filename}" - elif command_exists "md5"; then - md5_cmd="md5 ${filename}" - expected_md5_cmd_output="MD5 (${filename}) = ${expectedHash}" - else - fail "no suitable MD5 hashing command found on this system" - fi - - assertEquals "${expected_md5_cmd_output}" "`${md5_cmd}`" -}