diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cef03271..012a9cc52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- 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 diff --git a/README.md b/README.md index 235dffcd4..f2767f7db 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ See the [Getting Started on Heroku with Python](https://devcenter.heroku.com/art ## Application Requirements -A `requirements.txt` or `Pipfile` file must be present in the root (top-level) directory of your app's source code. +A `requirements.txt`, `Pipfile` or `poetry.lock` file must be present in the root (top-level) directory of your app's source code. ## Configuration diff --git a/bin/compile b/bin/compile index ce78b794c..9ce9cb60f 100755 --- a/bin/compile +++ b/bin/compile @@ -28,6 +28,7 @@ source "${BUILDPACK_DIR}/lib/package_manager.sh" source "${BUILDPACK_DIR}/lib/pip.sh" source "${BUILDPACK_DIR}/lib/pipenv.sh" source "${BUILDPACK_DIR}/lib/python_version.sh" +source "${BUILDPACK_DIR}/lib/poetry.sh" compile_start_time=$(nowms) @@ -166,6 +167,9 @@ case "${package_manager}" in pip::install_pip_setuptools_wheel "${python_home}" "${python_major_version}" pipenv::install_pipenv ;; + poetry) + poetry::install_poetry "${python_home}" "${CACHE_DIR}" "${EXPORT_PATH}" + ;; *) utils::abort_internal_error "Unhandled package manager: ${package_manager}" ;; @@ -175,8 +179,8 @@ meta_time "package_manager_install_duration" "${package_manager_install_start_ti # SQLite3 support. # Installs the sqlite3 dev headers and sqlite3 binary but not the # libsqlite3-0 library since that exists in the base image. -# We skip this step on Python 3.13, as a first step towards removing this feature. -if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) ]]; then +# We skip this step on Python 3.13 or when using Poetry, as a first step towards removing this feature. +if [[ "${python_major_version}" == +(3.8|3.9|3.10|3.11|3.12) && "${package_manager}" != "poetry" ]]; then install_sqlite_start_time=$(nowms) source "${BUILDPACK_DIR}/bin/steps/sqlite3" buildpack_sqlite3_install @@ -192,6 +196,9 @@ case "${package_manager}" in pipenv) pipenv::install_dependencies ;; + poetry) + poetry::install_dependencies + ;; *) utils::abort_internal_error "Unhandled package manager: ${package_manager}" ;; diff --git a/bin/detect b/bin/detect index 9f6303298..4b5a535eb 100755 --- a/bin/detect +++ b/bin/detect @@ -49,9 +49,9 @@ output::error <<EOF Error: Your app is configured to use the Python buildpack, but we couldn't find any supported Python project files. -A Python app on Heroku must have either a 'requirements.txt' or -'Pipfile' package manager file in the root directory of its -source code. +A Python app on Heroku must have either a 'requirements.txt', +'Pipfile' or 'poetry.lock' package manager file in the root +directory of its source code. Currently the root directory of your app contains: diff --git a/bin/report b/bin/report index ecc08f0fa..dcba72ef2 100755 --- a/bin/report +++ b/bin/report @@ -68,6 +68,7 @@ STRING_FIELDS=( package_manager_multiple_found pip_version pipenv_version + poetry_version python_version_major python_version_reason python_version diff --git a/bin/steps/collectstatic b/bin/steps/collectstatic index 326974036..f4b621af5 100755 --- a/bin/steps/collectstatic +++ b/bin/steps/collectstatic @@ -5,7 +5,7 @@ # automatically be executed as part of the build process. If collectstatic # fails, your build fails. -# This functionality will only activate if Django is in requirements.txt. +# This functionality will only activate if Django is installed. # Runtime arguments: # - $DISABLE_COLLECTSTATIC: disables this functionality. diff --git a/lib/cache.sh b/lib/cache.sh index 3e814f56e..bacebc999 100644 --- a/lib/cache.sh +++ b/lib/cache.sh @@ -104,6 +104,14 @@ function cache::restore() { cache_invalidation_reasons+=("The Pipenv version has changed from ${cached_pipenv_version} to ${PIPENV_VERSION}") fi ;; + poetry) + local cached_poetry_version + cached_poetry_version="$(meta_prev_get "poetry_version")" + # Poetry support was added after the metadata store, so we'll always have the version here. + if [[ "${cached_poetry_version}" != "${POETRY_VERSION:?}" ]]; then + cache_invalidation_reasons+=("The Poetry version has changed from ${cached_poetry_version:-"unknown"} to ${POETRY_VERSION}") + fi + ;; *) utils::abort_internal_error "Unhandled package manager: ${package_manager}" ;; @@ -119,6 +127,7 @@ function cache::restore() { rm -rf \ "${cache_dir}/.heroku/python" \ + "${cache_dir}/.heroku/python-poetry" \ "${cache_dir}/.heroku/python-stack" \ "${cache_dir}/.heroku/python-version" \ "${cache_dir}/.heroku/src" \ diff --git a/lib/package_manager.sh b/lib/package_manager.sh index a06d212e0..48fa73d1c 100644 --- a/lib/package_manager.sh +++ b/lib/package_manager.sh @@ -27,6 +27,15 @@ function package_manager::determine_package_manager() { package_managers_found+=(pip) fi + # This must be after the requirements.txt check, so that the requirements.txt exported by + # `python-poetry-buildpack` takes precedence over poetry.lock, for consistency with the + # behaviour prior to this buildpack supporting Poetry natively. In the future the presence + # of multiple package manager files will be turned into an error, at which point the + # ordering here won't matter. + if [[ -f "${build_dir}/poetry.lock" ]]; then + package_managers_found+=(poetry) + fi + # TODO: Deprecate/sunset this fallback, since using setup.py declared dependencies is # not a best practice, and we can only guess as to which package manager to use. if ((${#package_managers_found[@]} == 0)) && [[ -f "${build_dir}/setup.py" ]]; then @@ -47,9 +56,9 @@ function package_manager::determine_package_manager() { output::error <<-EOF Error: Couldn't find any supported Python package manager files. - A Python app on Heroku must have either a 'requirements.txt' or - 'Pipfile' package manager file in the root directory of its - source code. + A Python app on Heroku must have either a 'requirements.txt', + 'Pipfile' or 'poetry.lock' package manager file in the root + directory of its source code. Currently the root directory of your app contains: @@ -76,8 +85,7 @@ function package_manager::determine_package_manager() { # TODO: Turn this case into an error since it results in support tickets from users # who don't realise they have multiple package manager files and think their changes # aren't taking effect. (We'll need to wait until after Poetry support has landed, - # and people have had a chance to migrate from the third-party Poetry buildpack, - # since using it results in both a requirements.txt and a poetry.lock.) + # and people have had a chance to migrate from the Poetry buildpack mentioned above.) echo "${package_managers_found[0]}" meta_set "package_manager_multiple_found" "$( IFS=, diff --git a/lib/poetry.sh b/lib/poetry.sh new file mode 100644 index 000000000..bcceccd59 --- /dev/null +++ b/lib/poetry.sh @@ -0,0 +1,133 @@ +#!/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 cache_dir="${2}" + local export_file="${3}" + + # 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" + + meta_set "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}" + + python -m venv --without-pip "${poetry_venv_dir}" + + # 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). + local bundled_pip_module_path + bundled_pip_module_path="$(utils::bundled_pip_module_path "${python_home}")" + + if ! { + python "${bundled_pip_module_path}" \ + --python "${poetry_venv_dir}" \ + install \ + --disable-pip-version-check \ + --no-cache-dir \ + --no-input \ + --quiet \ + "poetry==${POETRY_VERSION}" + }; then + output::error <<-EOF + Error: Unable to install Poetry. + + Try building again to see if the error resolves itself. + + If that does not help, check the status of PyPI (the Python + package repository service), here: + https://status.python.org + EOF + meta_set "failure_reason" "install-poetry" + return 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}" + echo "export PATH=\"${poetry_bin_dir}:\${PATH}\"" >>"${export_file}" + # Force Poetry to manage the system Python site-packages instead of using venvs. + export POETRY_VIRTUALENVS_CREATE="false" + echo 'export POETRY_VIRTUALENVS_CREATE="false"' >>"${export_file}" +} + +# Note: We cache site-packages since: +# - It results in faster builds than only caching Poetry's download/wheel cache. +# - It's safe to do so, since `poetry install --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 + install + --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[*]}'" + + # `--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 "${WARNINGS_LOG:?}" \ + |& grep --invert-match 'Skipping virtualenv creation' \ + |& output::indent + }; then + show-warnings + + output::error <<-EOF + Error: Unable to install dependencies using Poetry. + + See the log output above for more information. + EOF + meta_set "failure_reason" "install-dependencies::poetry" + return 1 + fi +} diff --git a/requirements/poetry.txt b/requirements/poetry.txt new file mode 100644 index 000000000..8e44691cb --- /dev/null +++ b/requirements/poetry.txt @@ -0,0 +1 @@ +poetry==1.8.4 diff --git a/spec/fixtures/ci_poetry/.python-version b/spec/fixtures/ci_poetry/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/ci_poetry/.python-version @@ -0,0 +1 @@ +3.12 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..15362f6b1 --- /dev/null +++ b/spec/fixtures/ci_poetry/bin/post_compile @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +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..9e0bebe6b --- /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|BUILD_DIR|CACHE_DIR|CI_NODE_.+|DYNO|ENV_DIR|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..a727a186c --- /dev/null +++ b/spec/fixtures/ci_poetry/poetry.lock @@ -0,0 +1,85 @@ +# This file is automatically @generated by Poetry 1.8.4 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" +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.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "2e71a7976f439ce69fc771708b83dcfc6f795072ea73a7c2de0241878cbd378a" diff --git a/spec/fixtures/ci_poetry/pyproject.toml b/spec/fixtures/ci_poetry/pyproject.toml new file mode 100644 index 000000000..99aa593b8 --- /dev/null +++ b/spec/fixtures/ci_poetry/pyproject.toml @@ -0,0 +1,9 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +typing-extensions = "*" + +[tool.poetry.group.test.dependencies] +pytest = "*" diff --git a/spec/fixtures/pipenv_basic/bin/compile b/spec/fixtures/pipenv_basic/bin/compile index db00975d6..f73ce9e2f 100755 --- a/spec/fixtures/pipenv_basic/bin/compile +++ b/spec/fixtures/pipenv_basic/bin/compile @@ -5,7 +5,11 @@ set -euo pipefail -printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)=' +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)=' echo python -c 'import pprint, sys; pprint.pp(sys.path)' diff --git a/spec/fixtures/poetry_basic/.python-version b/spec/fixtures/poetry_basic/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/poetry_basic/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/spec/fixtures/poetry_basic/bin/compile b/spec/fixtures/poetry_basic/bin/compile new file mode 100755 index 000000000..55423bd97 --- /dev/null +++ b/spec/fixtures/poetry_basic/bin/compile @@ -0,0 +1,23 @@ +#!/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}" + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)=' +echo + +python -c 'import pprint, sys; pprint.pp(sys.path)' +echo + +# 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)' 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/poetry.lock b/spec/fixtures/poetry_basic/poetry.lock new file mode 100644 index 000000000..a727a186c --- /dev/null +++ b/spec/fixtures/poetry_basic/poetry.lock @@ -0,0 +1,85 @@ +# This file is automatically @generated by Poetry 1.8.4 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" +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.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "2e71a7976f439ce69fc771708b83dcfc6f795072ea73a7c2de0241878cbd378a" diff --git a/spec/fixtures/poetry_basic/pyproject.toml b/spec/fixtures/poetry_basic/pyproject.toml new file mode 100644 index 000000000..1b21050ab --- /dev/null +++ b/spec/fixtures/poetry_basic/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +typing-extensions = "*" + +# This group shouldn't be installed due to us passing `--only main`. +[tool.poetry.group.test.dependencies] +pytest = "*" 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..fc941ed3f --- /dev/null +++ b/spec/fixtures/poetry_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 .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/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..b86d791d2 --- /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 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..e98d42373 --- /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 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..4047b0d15 --- /dev/null +++ b/spec/fixtures/poetry_editable/poetry.lock @@ -0,0 +1,76 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.5" +files = [] +develop = true + +[package.dependencies] +setuptools = ">=3.0" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[package.source] +type = "git" +url = "https://github.com/benoitc/gunicorn.git" +reference = "20.1.0" +resolved_reference = "61ccfd6c38d477a908e0f376757bbb884438053a" + +[[package]] +name = "local_package_pyproject_toml" +version = "0.0.1" +description = "" +optional = false +python-versions = "*" +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 = "*" +files = [] +develop = true + +[package.source] +type = "directory" +url = "packages/local_package_setup_py" + +[[package]] +name = "setuptools" +version = "75.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, + {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "f362eb3b600ebb153055e8c4ed85f1cdeb18ffd8528710d5e1631c448a2f8adc" 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..e69de29bb diff --git a/spec/fixtures/poetry_editable/pyproject.toml b/spec/fixtures/poetry_editable/pyproject.toml new file mode 100644 index 000000000..7bcc6ab80 --- /dev/null +++ b/spec/fixtures/poetry_editable/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "poetry-editable" +version = "0.0.1" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "^3.12" +gunicorn = { git = "https://github.com/benoitc/gunicorn.git", tag = "20.1.0", 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 } + +[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..8738e5fc8 --- /dev/null +++ b/spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "34e39677d8527182346093002688d17a5d2fc204b9eb3e094b2e6ac519028228" 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..f0367e35e --- /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.12" + +# This dependency isn't in the lockfile. +typing-extensions = "*" diff --git a/spec/fixtures/requirements_basic/bin/compile b/spec/fixtures/requirements_basic/bin/compile index 68d243e7b..29cce648e 100755 --- a/spec/fixtures/requirements_basic/bin/compile +++ b/spec/fixtures/requirements_basic/bin/compile @@ -5,7 +5,11 @@ set -euo pipefail -printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)=' +BUILD_DIR="${1}" + +cd "${BUILD_DIR}" + +printenv | sort | grep -vE '^(_|BUILDPACK_LOG_FILE|DYNO|HOME|OLDPWD|PWD|REQUEST_ID|SHLVL|SOURCE_VERSION|STACK)=' echo python -c 'import pprint, sys; pprint.pp(sys.path)' diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/.python-version b/spec/fixtures/requirements_txt_and_poetry_lock/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/spec/fixtures/requirements_txt_and_poetry_lock/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock b/spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock new file mode 100644 index 000000000..7022f9054 --- /dev/null +++ b/spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock @@ -0,0 +1,17 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "2600e9ec45a8acb482a004ff092cb1453c2550aeec843430255a623cad8f7f86" diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml b/spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml new file mode 100644 index 000000000..b6077bf2c --- /dev/null +++ b/spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +typing-extensions = "*" diff --git a/spec/fixtures/requirements_txt_and_poetry_lock/requirements.txt b/spec/fixtures/requirements_txt_and_poetry_lock/requirements.txt new file mode 100644 index 000000000..eec3a2223 --- /dev/null +++ b/spec/fixtures/requirements_txt_and_poetry_lock/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.12.2 diff --git a/spec/hatchet/ci_spec.rb b/spec/hatchet/ci_spec.rb index 098fcdfca..06594b0a1 100644 --- a/spec/hatchet/ci_spec.rb +++ b/spec/hatchet/ci_spec.rb @@ -66,7 +66,6 @@ REGEX test_run.run_again - expect(test_run.output).to include(<<~OUTPUT) -----> Python app detected -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version @@ -142,7 +141,6 @@ REGEX test_run.run_again - expect(test_run.output).to match(Regexp.new(<<~REGEX)) -----> Python app detected -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version @@ -160,4 +158,87 @@ 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| + expect(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 Poetry #{POETRY_VERSION} + -----> Installing dependencies using 'poetry install --sync' + Installing dependencies from lock file + + Package operations: 5 installs, 0 updates, 0 removals + + - Installing iniconfig .+ + - Installing packaging .+ + - Installing pluggy .+ + - Installing pytest .+ + - Installing typing-extensions .+ + -----> Skipping Django collectstatic since the env var DISABLE_COLLECTSTATIC is set. + -----> Running bin/post_compile hook + CI=true + CPLUS_INCLUDE_PATH=/app/.heroku/python/include + C_INCLUDE_PATH=/app/.heroku/python/include + DISABLE_COLLECTSTATIC=1 + 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.+/.heroku/python-poetry/bin:/app/.heroku/python/bin::/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + PIP_NO_PYTHON_VERSION_WARNING=1 + PKG_CONFIG_PATH=/app/.heroku/python/lib/pkg-config + POETRY_VIRTUALENVS_CREATE=false + PYTHONUNBUFFERED=1 + -----> 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.+/.heroku/python-poetry/bin:/usr/local/bin:/usr/bin:/bin:/app/.sprettur/bin/ + POETRY_VIRTUALENVS_CREATE=false + PYTHONHASHSEED=random + 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/ + PYTHONHASHSEED=random + PYTHONHOME=/app/.heroku/python + PYTHONPATH=/app + PYTHONUNBUFFERED=true + WEB_CONCURRENCY=5 + pytest 8.3.3 + -----> test command `./bin/print-env-vars.sh && pytest --version` completed successfully + REGEX + + test_run.run_again + expect(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 install --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 end diff --git a/spec/hatchet/detect_spec.rb b/spec/hatchet/detect_spec.rb index dbe7f1e93..044b0010a 100644 --- a/spec/hatchet/detect_spec.rb +++ b/spec/hatchet/detect_spec.rb @@ -17,9 +17,9 @@ remote: ! Error: Your app is configured to use the Python buildpack, remote: ! but we couldn't find any supported Python project files. remote: ! - remote: ! A Python app on Heroku must have either a 'requirements.txt' or - remote: ! 'Pipfile' package manager file in the root directory of its - remote: ! source code. + remote: ! A Python app on Heroku must have either a 'requirements.txt', + remote: ! 'Pipfile' or 'poetry.lock' package manager file in the root + remote: ! directory of its source code. remote: ! remote: ! Currently the root directory of your app contains: remote: ! diff --git a/spec/hatchet/package_manager_spec.rb b/spec/hatchet/package_manager_spec.rb index cde1f154b..e390d62e8 100644 --- a/spec/hatchet/package_manager_spec.rb +++ b/spec/hatchet/package_manager_spec.rb @@ -13,9 +13,9 @@ remote: remote: ! Error: Couldn't find any supported Python package manager files. remote: ! - remote: ! A Python app on Heroku must have either a 'requirements.txt' or - remote: ! 'Pipfile' package manager file in the root directory of its - remote: ! source code. + remote: ! A Python app on Heroku must have either a 'requirements.txt', + remote: ! 'Pipfile' or 'poetry.lock' package manager file in the root + remote: ! directory of its source code. remote: ! remote: ! Currently the root directory of your app contains: remote: ! diff --git a/spec/hatchet/poetry_spec.rb b/spec/hatchet/poetry_spec.rb new file mode 100644 index 000000000..5aa092068 --- /dev/null +++ b/spec/hatchet/poetry_spec.rb @@ -0,0 +1,226 @@ +# 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 include(<<~OUTPUT) + 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 install --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.12.2) + 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: PYTHONHASHSEED=random + remote: PYTHONHOME=/app/.heroku/python + remote: PYTHONPATH=/app + remote: PYTHONUNBUFFERED=true + remote: + remote: ['', + remote: '/app', + remote: '/app/.heroku/python/lib/python312.zip', + remote: '/app/.heroku/python/lib/python3.12', + remote: '/app/.heroku/python/lib/python3.12/lib-dynload', + remote: '/app/.heroku/python/lib/python3.12/site-packages'] + remote: + remote: Skipping virtualenv creation, as specified in config file. + remote: typing-extensions 4.12.2 Backported and Experimental Type Hints for Python ... + remote: + remote: <module 'typing_extensions' from '/app/.heroku/python/lib/python3.12/site-packages/typing_extensions.py'> + OUTPUT + 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: -----> Restoring cache + remote: -----> Using cached install of Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Using cached Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry install --sync --only main' + remote: Installing dependencies from lock file + remote: + remote: No dependencies to install or update + remote: -----> Inline app detected + OUTPUT + end + end + end + + # TODO: Make this also test the Poetry version changing, the next (first) time we update Poetry, + # by using an older buildpack version for the initial build. + context 'when the requested Python version has changed since the last build' do + let(:app) { Hatchet::Runner.new('spec/fixtures/poetry_basic') } + + it 'clears the cache before installing' 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_12} to #{LATEST_PYTHON_3_13} + remote: -----> Installing Python #{LATEST_PYTHON_3_13} + remote: -----> Installing Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry install --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.12.2) + remote: -----> Discovering process types + OUTPUT + end + end + end + + context 'when the package manager has changed from pip to Poetry since the last build' do + let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_basic') } + + it 'clears the cache before installing with Poetry' do + app.deploy do |app| + FileUtils.rm('requirements.txt') + FileUtils.cp(FIXTURE_DIR.join('poetry_basic/pyproject.toml'), '.') + FileUtils.cp(FIXTURE_DIR.join('poetry_basic/poetry.lock'), '.') + 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 package manager has changed from pip to poetry + remote: -----> Installing Python #{DEFAULT_PYTHON_FULL_VERSION} + remote: -----> Installing Poetry #{POETRY_VERSION} + remote: -----> Installing dependencies using 'poetry install --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.12.2) + remote: -----> Discovering process types + 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, .egg-link and finder paths correctly for hooks, later buildpacks, runtime and cached builds' do + app.deploy do |app| + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.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 pyproject.toml-based local package: Hello pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: -----> Inline app detected + remote: __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.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 pyproject.toml-based local package: Hello pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + REGEX + + # Test rewritten paths work at runtime. + expect(app.run('bin/test-entrypoints.sh')).to include(<<~OUTPUT) + __editable___gunicorn_20_1_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 pyproject.toml-based local package: Hello pyproject.toml! + Running entrypoint for the setup.py-based local package: Hello setup.py! + Running entrypoint for the VCS package: gunicorn (version 20.1.0) + OUTPUT + + # Test that the cached .pth files work correctly. + app.commit! + app.push! + expect(clean_output(app.output)).to match(Regexp.new(<<~REGEX)) + remote: -----> Running bin/post_compile hook + remote: __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.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 pyproject.toml-based local package: Hello pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + remote: -----> Inline app detected + remote: __editable___gunicorn_20_1_0_finder.py:/tmp/build_.+/.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 pyproject.toml-based local package: Hello pyproject.toml! + remote: Running entrypoint for the setup.py-based local package: Hello setup.py! + remote: Running entrypoint for the VCS package: gunicorn \\(version 20.1.0\\) + REGEX + end + end + end + + 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 install --sync --only main' + remote: Installing dependencies from lock file + remote: + remote: pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock [--no-update]` 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 + + # This case will be turned into an error in the future, but for now is required for backwards compatibility. + context 'when there is both a poetry.lock and a requirements.txt' do + let(:app) { Hatchet::Runner.new('spec/fixtures/requirements_txt_and_poetry_lock') } + + it 'build using pip rather than Poetry' do + app.deploy do |app| + expect(clean_output(app.output)).to include(<<~OUTPUT) + remote: -----> Python app detected + remote: -----> Using Python #{DEFAULT_PYTHON_MAJOR_VERSION} specified in .python-version + remote: -----> Installing Python #{LATEST_PYTHON_3_12} + remote: -----> Installing pip #{PIP_VERSION}, setuptools #{SETUPTOOLS_VERSION} and wheel #{WHEEL_VERSION} + remote: -----> Installing SQLite3 + remote: -----> Installing requirements with pip + OUTPUT + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4fddb3764..52860db92 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -31,6 +31,7 @@ def get_requirement_version(package_name) SETUPTOOLS_VERSION = get_requirement_version('setuptools') WHEEL_VERSION = get_requirement_version('wheel') PIPENV_VERSION = get_requirement_version('pipenv') +POETRY_VERSION = get_requirement_version('poetry') # Work around the return value for `default_buildpack` changing after deploy: # https://github.com/heroku/hatchet/issues/180