Skip to content

Commit

Permalink
Initial experimental uv support
Browse files Browse the repository at this point in the history
  • Loading branch information
edmorley committed Jan 31, 2025
1 parent 17b87c0 commit 5f5a0a8
Show file tree
Hide file tree
Showing 44 changed files with 965 additions and 36 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name: CI
on:
push:
# Avoid duplicate builds on PRs.
branches:
- main
# branches:
# - main
pull_request:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the [Getting Started on Heroku with Python](https://devcenter.heroku.com/art

## Application Requirements

A `requirements.txt`, `Pipfile` or `poetry.lock` file must be present in the root (top-level) directory of your app's source code.
A `requirements.txt`, `Pipfile`, `poetry.lock`, or `uv.lock` file must be present in the root (top-level) directory of your app's source code.

## Configuration

Expand Down
11 changes: 9 additions & 2 deletions bin/compile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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"
source "${BUILDPACK_DIR}/lib/uv.sh"

compile_start_time=$(nowms)

Expand Down Expand Up @@ -184,6 +185,9 @@ case "${package_manager}" in
poetry)
poetry::install_poetry "${CACHE_DIR}" "${EXPORT_PATH}"
;;
uv)
uv::install_uv "${CACHE_DIR}" "${EXPORT_PATH}" "${python_home}"
;;
*)
utils::abort_internal_error "Unhandled package manager: ${package_manager}"
;;
Expand All @@ -193,8 +197,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 or when using Poetry, as a first step towards removing this feature.
if [[ "${python_major_version}" == +(3.9|3.10|3.11|3.12) && "${package_manager}" != "poetry" ]]; then
# We skip this step on Python 3.13 or when using Poetry/uv, as a first step towards removing this feature.
if [[ "${python_major_version}" == +(3.9|3.10|3.11|3.12) && "${package_manager}" != +(poetry|uv) ]]; then
install_sqlite_start_time=$(nowms)
source "${BUILDPACK_DIR}/bin/steps/sqlite3"
buildpack_sqlite3_install
Expand All @@ -213,6 +217,9 @@ case "${package_manager}" in
poetry)
poetry::install_dependencies
;;
uv)
uv::install_dependencies
;;
*)
utils::abort_internal_error "Unhandled package manager: ${package_manager}"
;;
Expand Down
2 changes: 1 addition & 1 deletion bin/report
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ STRING_FIELDS=(
python_version_reason
python_version
setuptools_version
uv_version
wheel_version
)

Expand All @@ -96,7 +97,6 @@ ALL_OTHER_FIELDS=(
setup_py_only
sqlite_install_duration
total_duration
uv_lockfile
)

for field in "${STRING_FIELDS[@]}"; do
Expand Down
9 changes: 9 additions & 0 deletions lib/cache.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ function cache::restore() {
cache_invalidation_reasons+=("The Poetry version has changed from ${cached_poetry_version:-"unknown"} to ${POETRY_VERSION}")
fi
;;
uv)
local cached_uv_version
cached_uv_version="$(meta_prev_get "uv_version")"
# uv support was added after the metadata store, so we'll always have the version here.
if [[ "${cached_uv_version}" != "${UV_VERSION:?}" ]]; then
cache_invalidation_reasons+=("The uv version has changed from ${cached_uv_version:-"unknown"} to ${UV_VERSION}")
fi
;;
*)
utils::abort_internal_error "Unhandled package manager: ${package_manager}"
;;
Expand All @@ -128,6 +136,7 @@ function cache::restore() {
"${cache_dir}/.heroku/python" \
"${cache_dir}/.heroku/python-poetry" \
"${cache_dir}/.heroku/python-stack" \
"${cache_dir}/.heroku/python-uv" \
"${cache_dir}/.heroku/python-version" \
"${cache_dir}/.heroku/src" \
"${cache_dir}/.heroku/requirements.txt"
Expand Down
27 changes: 16 additions & 11 deletions lib/package_manager.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ function package_manager::determine_package_manager() {
package_managers_found_display_text+=("poetry.lock (Poetry)")
fi

if [[ -f "${build_dir}/uv.lock" ]]; then
package_managers_found+=(uv)
package_managers_found_display_text+=("uv.lock (uv)")
fi

# 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
Expand All @@ -59,10 +64,6 @@ function package_manager::determine_package_manager() {
meta_set "setup_py_only" "false"
fi

if [[ -f "${build_dir}/uv.lock" ]]; then
meta_set "uv_lockfile" "true"
fi

local num_package_managers_found=${#package_managers_found[@]}

case "${num_package_managers_found}" in
Expand All @@ -75,8 +76,8 @@ function package_manager::determine_package_manager() {
Error: Couldn't find any supported Python package manager files.
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.
'Pipfile', 'poetry.lock' or 'uv.lock' package manager file in
the root directory of its source code.
Currently the root directory of your app contains:
Expand All @@ -91,11 +92,6 @@ function package_manager::determine_package_manager() {
Otherwise, add a package manager file to your app. If your app has
no dependencies, then create an empty 'requirements.txt' file.
If you would like to see support for the package manager uv,
please vote and comment on these GitHub issues:
https://github.com/heroku/heroku-buildpack-python/issues/1616
https://github.com/heroku/roadmap/issues/323
For help with using Python on Heroku, see:
https://devcenter.heroku.com/articles/getting-started-with-python
https://devcenter.heroku.com/articles/python-support
Expand Down Expand Up @@ -136,6 +132,15 @@ function package_manager::determine_package_manager() {
EOF
fi

if [[ "${package_managers_found[*]}" == *"uv"* ]]; then
output::notice <<-EOF
Note: We recently added support for the package manager uv.
If you are using a third-party uv buildpack you must remove
it, otherwise the requirements.txt file it generates will cause
the warning above.
EOF
fi

meta_set "package_manager_multiple_found" "$(
IFS=,
echo "${package_managers_found[*]}"
Expand Down
2 changes: 2 additions & 0 deletions lib/python_version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ function python_version::read_requested_python_version() {
fi
fi

# TODO: Decide whether to make specifying a Python version mandatory when using uv.

# Protect against unsupported (eg PyPy) or invalid versions being found in the cache metadata.
if [[ "${cached_python_full_version}" =~ ^${PYTHON_FULL_VERSION_REGEX}$ ]]; then
local cached_python_major_version="${cached_python_full_version%.*}"
Expand Down
141 changes: 141 additions & 0 deletions lib/uv.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env bash

# This is technically redundant, since all consumers of this lib will have enabled these,
# however, it helps Shellcheck realise the options under which these functions will run.
set -euo pipefail

UV_VERSION=$(utils::get_requirement_version 'uv')

function uv::install_uv() {
local cache_dir="${1}"
local export_file="${2}"
local python_home="${3}"

# We store uv in the build cache, since we only need it during the build.
local uv_dir="${cache_dir}/.heroku/python-uv"

meta_set "uv_version" "${UV_VERSION}"

# The earlier buildpack cache invalidation step will have already handled the case where
# the uv version has changed, so here we only need to check whether the uv binary exists.
if [[ -f "${uv_dir}/uv" ]]; then
output::step "Using cached uv ${UV_VERSION}"
else
output::step "Installing uv ${UV_VERSION}"
mkdir -p "${uv_dir}"

local gnu_arch
# eg: `x86_64` or `aarch64`.
gnu_arch=$(arch)
local uv_url="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${gnu_arch}-unknown-linux-gnu.tar.gz"

if ! {
curl \
--connect-timeout 10 \
--fail \
--location \
--retry 3 \
--retry-connrefused \
--show-error \
--silent \
"${uv_url}" \
| tar \
--directory "${uv_dir}" \
--extract \
--gzip \
--no-anchored \
--strip-components 1 \
uv
}; then
output::error <<-EOF
Error: Unable to install uv.
In some cases, this happens due to an unstable network connection.
Try building again to see if the error resolves itself.
EOF
meta_set "failure_reason" "install-package-manager::uv"
exit 1
fi
fi

export PATH="${uv_dir}:${PATH}"
# Make uv manage the system site-packages of our Python install instead of creating a venv.
export UV_PROJECT_ENVIRONMENT="${python_home}"
# Force uv to use our Python install instead of performing discovery (which could potentially
# use distro Python if the app doesn't have a .python-version and `requires-python` matches
# the distro Python version). However, setting this option makes the error message worse for
# apps that have a `.python-version` with a version that conflicts with `requires-python`.
# TODO: Consider only setting `UV_PYTHON` when the version origin wasn't .python-version,
# or make .python-version mandatory when using uv and stop setting `UV_PYTHON` entirely.
export UV_PYTHON="${python_home}"
# Prevent uv from downloading/using its own Python installation.
export UV_PYTHON_DOWNLOADS="never"
export UV_PYTHON_PREFERENCE="only-system"

# TODO: Open upstream issue about improving default behaviour here.
# (It's not currently possible to say "hardlink or reflink", only one or
# the other, which doesn't work well given Kodon vs Heroku CI filesystems.)
export UV_LINK_MODE="copy"

# Set the same env vars in the environment used by later buildpacks.
cat >>"${export_file}" <<-EOF
export PATH="${uv_dir}:\${PATH}"
export UV_LINK_MODE="copy"
export UV_PROJECT_ENVIRONMENT="${python_home}"
export UV_PYTHON="${python_home}"
export UV_PYTHON_DOWNLOADS="never"
export UV_PYTHON_PREFERENCE="only-system"
EOF
}

# Note: We cache site-packages since:
# - It results in faster builds than only caching uv's download/wheel cache.
# - It improves the UX of the build log, since uv will display which packages were
# added/removed since the last successful build.
# - It's safe to do so, since `uv sync` fully manages the environment (including
# e.g. uninstalling packages when they are removed from the lockfile).
#
# With site-packages cached there is no need to persist uv's cache in the build cache, so we let
# uv write it to the home directory where it will be discarded at the end of the build. We don't
# use `--no-cache` since all it does is make uv switch to a temporary cache which uv will delete
# after the command has run - which would both be a waste of I/O and also mean if users happen to
# manually run any uv commands later in the build they will have a cold cache.
function uv::install_dependencies() {
local uv_install_command=(
uv
sync
--locked
)

# Unless we're building on Heroku CI, we omit the default dependency groups (such as `dev`):
# https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-groups
if [[ ! -v INSTALL_TEST ]]; then
uv_install_command+=(--no-default-groups)
fi

# We only display the most relevant command args here, to improve the signal to noise ratio.
output::step "Installing dependencies using '${uv_install_command[*]}'"

# TODO: Expose app config vars to the install command as part of doing so for all package managers.
# `--compile-bytecode`: Improves app boot times (pip does this by default).
# shellcheck disable=SC2310 # This function is invoked in an 'if' condition so set -e will be disabled.
if ! {
"${uv_install_command[@]}" \
--color always \
--compile-bytecode \
--no-progress \
|& tee "${WARNINGS_LOG:?}" \
|& output::indent
}; then
# TODO: Overhaul warnings and combine them with error handling (for all package managers).
show-warnings

output::error <<-EOF
Error: Unable to install dependencies using uv.
See the log output above for more information.
EOF
meta_set "failure_reason" "install-dependencies::uv"
exit 1
fi
}
1 change: 1 addition & 0 deletions requirements/uv.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uv==0.5.26
1 change: 1 addition & 0 deletions spec/fixtures/ci_uv/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
9 changes: 9 additions & 0 deletions spec/fixtures/ci_uv/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"environments": {
"test": {
"scripts": {
"test": "./bin/print-env-vars.sh && pytest --version"
}
}
}
}
12 changes: 12 additions & 0 deletions spec/fixtures/ci_uv/bin/compile
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions spec/fixtures/ci_uv/bin/detect
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash

# This file is run by the inline buildpack.

set -euo pipefail

echo "Inline"
5 changes: 5 additions & 0 deletions spec/fixtures/ci_uv/bin/post_compile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash

set -euo pipefail

exec bin/print-env-vars.sh
5 changes: 5 additions & 0 deletions spec/fixtures/ci_uv/bin/print-env-vars.sh
Original file line number Diff line number Diff line change
@@ -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)='
12 changes: 12 additions & 0 deletions spec/fixtures/ci_uv/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "ci-uv"
version = "0.0.0"
requires-python = ">=3.13"
dependencies = [
"typing-extensions",
]

[dependency-groups]
dev = [
"pytest",
]
Loading

0 comments on commit 5f5a0a8

Please sign in to comment.