From 895a004fc022a30502bd21d60e5a0ff225c7b122 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Wed, 6 Nov 2024 09:12:33 +0000 Subject: [PATCH] Add support for Poetry (#1682) After many refactoring/preparation PRs, we're now ready to add support for the package manager Poetry: https://python-poetry.org To use Poetry, apps must have a `poetry.lock` lockfile, which can be created by running `poetry lock` locally, after adding Poetry config to `pyproject.toml` (which can be done either manually or by using `poetry init`). For now, if a `requirements.txt` or `Pipfile` is found it will take precedence over `poetry.lock` for backwards compatibility (in the future this will become a warning then an error). This means users of the third-party `python-poetry-buildpack` will need to remove that buildpack in order to use the new native Poetry support, since it exports a `requirements.txt` file during the build. Poetry is installed into the build cache rather than the slug, so is not available at run-time (since it's not typically needed at run-time and doing so reduces the slug size). The entrypoints of installed dependencies are available on `PATH`, so use of `poetry run` or `poetry shell` is not required at run-time to use dependencies in the environment. When using Poetry, pip is not installed since Poetry includes its own internal vendored copy that it will use instead (for the small number of Poetry operations for which it still calls out to pip, such as package uninstalls). During normal (non-CI) builds, the `poetry install --sync` command is run using `--only main` so as to only install the main `[tool.poetry.dependencies]` dependencies group from `pyproject.toml` and not any of the app's other dependency groups (such as test/dev/... groups, eg `[tool.poetry.group.test.dependencies]`). On Heroku CI, all default Poetry dependency groups are installed (i.e. all groups minus those marked as `optional = true`). Relevant Poetry docs: - https://python-poetry.org/docs/cli/#install - https://python-poetry.org/docs/configuration/ - https://python-poetry.org/docs/managing-dependencies/#dependency-groups See also the Python CNB equivalent of this PR: - https://github.com/heroku/buildpacks-python/pull/261 Note: We don't support controlling the Python version via Poetry's `tool.poetry.dependencies.python` field, since that field typically contains a version range, which is not safe to use. Use the newly added `.python-version` file support instead. For more on this, see the longer explanation over in the Python CNB repo: https://github.com/heroku/buildpacks-python/issues/260 Closes #796. Closes #835. GUS-W-16810914. --- CHANGELOG.md | 1 + README.md | 2 +- bin/compile | 11 +- bin/detect | 6 +- bin/report | 1 + bin/steps/collectstatic | 2 +- lib/cache.sh | 9 + lib/package_manager.sh | 18 +- lib/poetry.sh | 133 +++++++++++ requirements/poetry.txt | 1 + spec/fixtures/ci_poetry/.python-version | 1 + spec/fixtures/ci_poetry/app.json | 9 + spec/fixtures/ci_poetry/bin/compile | 12 + spec/fixtures/ci_poetry/bin/detect | 7 + spec/fixtures/ci_poetry/bin/post_compile | 5 + spec/fixtures/ci_poetry/bin/print-env-vars.sh | 5 + spec/fixtures/ci_poetry/poetry.lock | 85 +++++++ spec/fixtures/ci_poetry/pyproject.toml | 9 + spec/fixtures/pipenv_basic/bin/compile | 6 +- spec/fixtures/poetry_basic/.python-version | 1 + spec/fixtures/poetry_basic/bin/compile | 23 ++ spec/fixtures/poetry_basic/bin/detect | 7 + spec/fixtures/poetry_basic/poetry.lock | 85 +++++++ spec/fixtures/poetry_basic/pyproject.toml | 10 + spec/fixtures/poetry_editable/bin/compile | 12 + spec/fixtures/poetry_editable/bin/detect | 7 + .../fixtures/poetry_editable/bin/post_compile | 5 + .../poetry_editable/bin/test-entrypoints.sh | 18 ++ .../local_package_pyproject_toml/__init__.py | 2 + .../pyproject.toml | 6 + .../local_package_setup_py/__init__.py | 2 + .../packages/local_package_setup_py/setup.cfg | 10 + .../packages/local_package_setup_py/setup.py | 3 + spec/fixtures/poetry_editable/poetry.lock | 76 ++++++ .../poetry_editable/__init__.py | 0 spec/fixtures/poetry_editable/pyproject.toml | 15 ++ .../poetry_lockfile_out_of_sync/poetry.lock | 7 + .../pyproject.toml | 8 + spec/fixtures/requirements_basic/bin/compile | 6 +- .../.python-version | 1 + .../poetry.lock | 17 ++ .../pyproject.toml | 6 + .../requirements.txt | 2 + spec/hatchet/ci_spec.rb | 85 ++++++- spec/hatchet/detect_spec.rb | 6 +- spec/hatchet/package_manager_spec.rb | 6 +- spec/hatchet/poetry_spec.rb | 226 ++++++++++++++++++ spec/spec_helper.rb | 1 + 48 files changed, 954 insertions(+), 22 deletions(-) create mode 100644 lib/poetry.sh create mode 100644 requirements/poetry.txt create mode 100644 spec/fixtures/ci_poetry/.python-version create mode 100644 spec/fixtures/ci_poetry/app.json create mode 100755 spec/fixtures/ci_poetry/bin/compile create mode 100755 spec/fixtures/ci_poetry/bin/detect create mode 100644 spec/fixtures/ci_poetry/bin/post_compile create mode 100755 spec/fixtures/ci_poetry/bin/print-env-vars.sh create mode 100644 spec/fixtures/ci_poetry/poetry.lock create mode 100644 spec/fixtures/ci_poetry/pyproject.toml create mode 100644 spec/fixtures/poetry_basic/.python-version create mode 100755 spec/fixtures/poetry_basic/bin/compile create mode 100755 spec/fixtures/poetry_basic/bin/detect create mode 100644 spec/fixtures/poetry_basic/poetry.lock create mode 100644 spec/fixtures/poetry_basic/pyproject.toml create mode 100755 spec/fixtures/poetry_editable/bin/compile create mode 100755 spec/fixtures/poetry_editable/bin/detect create mode 100755 spec/fixtures/poetry_editable/bin/post_compile create mode 100755 spec/fixtures/poetry_editable/bin/test-entrypoints.sh create mode 100644 spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/local_package_pyproject_toml/__init__.py create mode 100644 spec/fixtures/poetry_editable/packages/local_package_pyproject_toml/pyproject.toml create mode 100644 spec/fixtures/poetry_editable/packages/local_package_setup_py/local_package_setup_py/__init__.py create mode 100644 spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.cfg create mode 100644 spec/fixtures/poetry_editable/packages/local_package_setup_py/setup.py create mode 100644 spec/fixtures/poetry_editable/poetry.lock create mode 100644 spec/fixtures/poetry_editable/poetry_editable/__init__.py create mode 100644 spec/fixtures/poetry_editable/pyproject.toml create mode 100644 spec/fixtures/poetry_lockfile_out_of_sync/poetry.lock create mode 100644 spec/fixtures/poetry_lockfile_out_of_sync/pyproject.toml create mode 100644 spec/fixtures/requirements_txt_and_poetry_lock/.python-version create mode 100644 spec/fixtures/requirements_txt_and_poetry_lock/poetry.lock create mode 100644 spec/fixtures/requirements_txt_and_poetry_lock/pyproject.toml create mode 100644 spec/fixtures/requirements_txt_and_poetry_lock/requirements.txt create mode 100644 spec/hatchet/poetry_spec.rb 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 <>"${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: + 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