Skip to content

Feature - add uv support - #1260

Open
dominikmuellerdmde wants to merge 14 commits into
cloudfoundry:masterfrom
dominikmuellerdmde:feature/uv
Open

dominikmuellerdmde wants to merge 14 commits into
cloudfoundry:masterfrom
dominikmuellerdmde:feature/uv

Conversation

@dominikmuellerdmde

@dominikmuellerdmde dominikmuellerdmde commented Oct 30, 2026 •

Copy link
Copy Markdown

Warning

This PR was written largely with AI assistance (Claude Code). That includes the code, the tests and this description. I have reviewed the changes and tested them on our own Cloud Foundry foundation, but please review and test it as carefully as any change you didn't write yourself. Don't assume the AI got it right.

A short explanation of the proposed change:

This adds support for apps managed with uv (pyproject.toml + uv.lock):

  • bin/detect recognises apps that contain both a uv.lock and a pyproject.toml.
  • The uv wheel already listed in manifest.yml is installed with pip into /tmp/uv, so uv stays out of the droplet.
  • The Python version is read from .python-version unless a runtime.txt exists. uv never downloads its own Python.
  • Dependencies are installed with uv sync --frozen --no-dev --no-editable. Every package is checked against the hashes in uv.lock, and staging fails on a mismatch. Indexes configured via [[tool.uv.index]] are used, with credentials passed as UV_INDEX_<NAME>_* environment variables.
  • Vendored (offline) apps are installed from vendor/ with uv pip install --offline, also checked against the hashes in uv.lock.
  • A requirements.txt exported from uv.lock is used for the existing package-specific steps (libffi, libmemcached, Django collectstatic). Package names in requirements are now matched case-insensitively and with -, _ and . treated as equal, as PEP 503 specifies.
  • If the app also contains a requirements.txt, it takes precedence: uv.lock and .python-version are ignored and pip is used as before. Pipfile apps are unchanged.
  • The README documents the behaviour.

Why the existing uv support is not sufficient:

manifest.yml already contains a uv dependency (since 0.12.23, now 0.13.5 for cflinuxfs4 and cflinuxfs5), but nothing in the buildpack uses it. The commits that added it only touch manifest.yml. As a result, uv apps don't work today:

  • Detection: bin/detect only looks for requirements.txt, setup.py, environment.yml and Pipfile, so an app with just pyproject.toml and uv.lock is not detected as a Python app.
  • Installation: if the buildpack is forced with -b, Python and pip are installed but the dependency step is skipped because there is no requirements.txt. Staging succeeds and the app crashes at start with ModuleNotFoundError.
  • The uv dependency itself: the manifest entry is a pip wheel inside a .tgz, not the standalone uv binary. It has to be installed with pip before it can run, which this PR does.

An explanation of the use cases your change solves:

uv is becoming one of the standard tools for Python projects. Today, teams using it have to export a requirements.txt before every push (uv export) and keep it in sync with uv.lock, which is easy to forget and loses the hash checking from the lock file. With this change, a uv project can be pushed as-is:

  • dependencies are installed exactly as locked, with hashes checked;
  • the Python version pinned with uv python pin is respected;
  • private indexes work with credentials from the app environment;
  • air-gapped foundations are supported through vendored wheels;
  • Django apps get collectstatic as with pip.

Testing:

  • Unit tests for supply, finalize and requirements.

  • New integration tests in src/python/integration/uv_test.go:

    • a basic uv app;
    • a uv.lock with a wrong hash, which must fail staging;
    • a Django app, checking that collectstatic runs;
    • an app with both uv.lock and requirements.txt, checking that pip is used.
  • The full integration suite was run against a local CF on cflinuxfs4, cached and uncached. The uv tests and all other suites pass. The only failures were the AppDynamics, Dynatrace and Proxy tests, which also fail with an unmodified master build on that foundation.

  • I have viewed signed and have submitted the Contributor License Agreement

  • I have made this pull request to the master branch

  • I have added an integration test

When an app provides a uv.lock but no requirements.txt, install uv and
run 'uv export --frozen' to generate a temporary requirements.txt, just
like it is done for Pipfile.lock. The regular pip flow then installs the
dependencies, so vendoring, libffi/libmemcache detection and stale
package removal keep working for uv apps.

Only the default (non-dev) dependencies are exported. The project itself
is emitted only when it is a package and is then installed non-editable.
uv projects usually pin their interpreter in .python-version instead of
runtime.txt. For apps with a uv.lock, translate the first CPython version
in that file into runtime.txt. An explicit runtime.txt still wins, and
apps without a uv.lock keep ignoring .python-version.
'uv export' does not emit the indexes configured via [[tool.uv.index]],
so pip would silently fall back to PyPI for apps that use a private
package index. Read the registry URLs from uv.lock and prepend them as
-i/--extra-index-url options, as is already done for Pipfile.lock
sources. PyPI stays the primary index when it is used.

Indexes configured via PIP_INDEX_URL or PIP_EXTRA_INDEX_URL take
precedence, as uv strips credentials from the URLs in uv.lock.
Add a uv-managed Flask fixture that pins its Python version via
.python-version and has a dev dependency group, and verify that it
deploys with the dependencies exported from uv.lock. Also verify that
an existing requirements.txt takes precedence over uv.lock.
Package names are normalized as defined in PEP 503 before comparing
them. Generated requirements files, such as those exported by uv, use
lowercase names, so packages like 'PyNaCl' or 'pyOpenSSL' were not
detected and libffi was not installed for them.
For apps using Pipfile.lock or uv.lock, supply generates a temporary
requirements.txt and deletes it at the end of the supply phase. Finalize
then no longer found Django in it and silently skipped collectstatic.

Keep a copy of the generated requirements.txt in the deps dir and let
finalize check it as well.
Install the packages of uv apps with 'uv sync --frozen' directly into
the Python installation of the buildpack instead of exporting them for
pip. uv verifies every package against the hashes in uv.lock and
supports all of uv's features, like workspaces, [tool.uv.sources] and
index configuration including credentials via UV_INDEX_* variables.
Reading the package indexes from uv.lock for pip is no longer needed.

uv.lock is still exported to a temporary requirements.txt, now with
hashes, so that packages which need libffi, libmemcached or collectstatic
are detected as before.

uv uses the system certificate store like pip does, and its cache is
kept in the app cache. Vendored uv apps are still installed with pip.
'uv sync' only downloads the files referenced in uv.lock and cannot
install from a vendor directory. For vendored uv apps, install the
packages exported from uv.lock with 'uv pip install --offline
--no-index --find-links vendor' instead. The exported requirements
contain the hashes from uv.lock, so modified or mismatching files in
the vendor directory are rejected.
Verify that uv apps are installed with uv sync, that staging fails when
a package does not match the hash in uv.lock, and that collectstatic
runs for a Django app managed with uv.
The buildpack manifest now provides uv as a pip wheel built by the
buildpacks pipeline (for cflinuxfs4 and cflinuxfs5) instead of the
upstream release binary. Install that wheel with pip into /tmp/uv, so
uv stays out of the droplet, and run uv from /tmp/uv/bin/uv.
- Ignore .python-version when the app provides a requirements.txt, as
  uv.lock is ignored in that case as well.
- Only keep a copy of the generated requirements.txt in the deps dir for
  uv apps. Pipfile apps keep their previous behaviour, so collectstatic
  does not suddenly run for Pipfile Django apps.
- Only detect uv apps that provide both uv.lock and pyproject.toml.
- Start from an empty /tmp/uv when installing uv.
- Mention the build backend when a vendored uv install fails.
- Compile the package name regex once.
@linux-foundation-easycla

linux-foundation-easycla Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@dominikmuellerdmde dominikmuellerdmde changed the title Feature/uv Feature - add uv support Oct 30, 2026
@benjaminguttmann-avtq

Copy link
Copy Markdown

Hi @dominikmuellerdmde ,

first of all thank you for your contribution. I asked AI for a review and provided you the output down below.

[MEDIUM] src/python/supply/supply.go (HandleUvPythonVersion, parsePythonVersionFile) — error handling

  • Scenario: An invalid or unsupported .python-version (e.g. 3.12.*, pypy3.10, >=3.11) only logs a warning and falls back to the default version. With UV_PYTHON_DOWNLOADS=never and requires-python in pyproject.toml, uv sync / uv export later fails with a hard-to-understand message.
  • Fix: Abort early with a clear error, or extend the warning to say that runtime.txt or a supported version must be set.
  • Side note: regexp.MustCompile is recompiled on every call; move it to package level like packageNameSeparatorRegex.

[MEDIUM] src/python/supply/supply.go (installUv, uvDir = /tmp/uv) — robustness

  • Scenario: The fixed directory /tmp/uv is deleted with os.RemoveAll and recreated. Parallel staging on the same cell, or another buildpack using /tmp/uv, makes runs overwrite each other.
  • Fix: Use os.MkdirTemp and derive uvBin from it (e.g. a Supplier field). The global uvDir/uvBin vars also hinder testing.

[MEDIUM] src/python/supply/supply.go (RunUvSync, RunUvVendored) — consistency

  • Scenario: uv export (in InstallUv) and uv sync run separately. Both use --frozen, but the package list used for detection (ffi, pylibmc, Django) and the list actually installed can differ (markers, extras, dependency groups; --no-dev is the only group selection). The vendored path installs the exported requirements.txt rather than uv.lock, so the two paths can install different packages.
  • Fix: Add an integration test with a marker or dependency group comparing both paths, or document the divergence in the README.

[MEDIUM] src/python/finalize/finalize.go (HandleCollectstatic) — backwards compatibility / tests

  • Scenario: FindAnyPackage(dir, "django", "Django") became FindAnyPackage(dir, "django"), and the search now also covers DepDir(). Pipfile behaviour is unchanged (only uv copies the file to the deps dir). PEP 503 normalisation changes matching for all apps: Django_Foo no longer matches, while django-foo now matches django_foo.
  • Consequence: Harmless for django itself, but the function is public and also used for ffi and pylibmc; additional matches there are untested.
  • Fix: Add underscore and dot cases for these callers in requirements_test.go.

[LOW] src/python/requirements/requirements.go — parsing

  • parseRequirementsWithoutVersion does not strip extras ([\w\-\w\[\]]+). django[argon2] still does not match django. Pre-existing, but the normalisation can be mistaken for a fix.
  • Fix: Strip extras before normalising.

[LOW] bin/detect — consistency

  • uv.lock without pyproject.toml (or the reverse) is not detected; consistent with InstallUv, but HandleUvPythonVersion only checks uv.lock.
  • Apps with pyproject.toml + uv.lock that also have a requirements.txt are silently treated as pip apps. This is logged but not documented in the README.

[LOW] Tests

  • Lockfiles in fixtures/uv* should be generated with the pinned uv version (0.13.5).
  • Missing unit tests for InstallUv when uv export fails, and for usesUv && vendored with an empty vendor directory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants