Uv support in buildout 5.3.0a1

I have released zc.buildout 5.3.0a1. If you like buildout and would like to see it modernized, this is a first step with a performance improvement.

## TLDR

zc.buildout 5.3.0a1 adds opt-in uv support: buildout now delegates downloads and version resolution to uv instead of pip. Enable it with installer = uv in the [buildout] section; pip remains the default. Expect a large speedup — for Plone on my Mac M2, from 7 minutes down to under 2 on a cold cache, and seconds on a warm one.

## How it came about

Besides adding uv support, my goal was to learn how to let the agents write all the code for me. The work happened in three steps:

1. rewriting the legacy doctest test suite into a pytest suite;

2. improving the verification harness for the agents' work: lowering cyclomatic complexity, adding type annotations, reducing the use of Any;

3. replacing pip and setuptools.package_index usage with uv only.

I used the pstack poteto-mode skill as a driver to build a verify-buildout skill. I left every step to the agents through a series of prompts.

The first step ported the legacy doctest suite to pytest. That alone gave the test suite a solid performance boost thanks to pytest-xdist parallelization. The two suites have very similar coverage. From then on, development ran against the pytest suite; I ran the legacy suite only to confirm there were no regressions.

The second step made the codebase easier for agents to refactor. Reducing cyclomatic complexity made the code easier to understand. It exposed cases that only integration tests might have covered, so the agents added unit tests for them. Ruff linting raised the overall quality of the code. The agents added type annotations across the codebase to make the semantics explicit. They also made a deliberate effort to shrink the use of the Any type. They split large files into shorter ones that they can read and write more cheaply. All this tooling supplements the test suites. Together they form a verification harness that gives me confidence few regression slipped through.

With the codebase cleaned up, it was time for the heart of the move: adding uv support, to take advantage of the speed of uv's version resolution and parallel downloads.

That took two refactorings: replacing the pip install subprocesses with uv ones, and delegating version resolution to uv instead of setuptools.package_index. The delegation also reduces how many subprocesses buildout launches.

## What uv support actually does

In uv mode, a single `uv pip compile` handles package discovery and version resolution. After the environment check, uv resolves everything still open in one pass, with the buildout's develop projects riding along as overrides. One batched `uv pip install` then installs everything. The per-requirement pip subprocess fan-out is gone.

I kept uv support opt-in, behind a new `buildout:installer` option. Add `installer = uv` to the `[buildout]` section of your config, or override it on the command line with the usual assignment syntax, for example `bin/buildout buildout:installer=uv`. The default value remains `pip`. That makes it easy to compare the two installers and debug any regression that slipped through anyway. uv mode requires uv 0.12.11 or newer, and `uv` is now a declared dependency of zc.buildout; the pip code path is unchanged.

## What else is in the release

- New `--interpolated` option on the `query` and `annotate` commands: print values after applying the `${...}` substitutions, exactly the way recipes see them. Raw values remain the default output.

- Repeated buildout runs are faster: buildout no longer reinstalls the sources listed in the `develop` option on every run. It only redoes an editable install when `setup.py`, `setup.cfg` or `pyproject.toml` is newer than the egg-link.

- Bug fixes worth mentioning: spurious uninstall/reinstall cycles of parts whose recipe resolves to a develop egg; offline mode now reuses wheel-installed distributions; a `TypeError` that made zc.buildout unimportable on Python 3.9; and several Windows fixes, including local package indexes spelled as drive paths now correctly converting to `file://` URIs.

For the development infrastructure aficionados: I rebuilt CI and the development environment around devenv and Dagger. `dagger call ci` runs the whole CI job table locally or in containers. CI also gained a parallel uv test set: it reruns the legacy suite through the uv install pipeline, across the full platform matrix — including Windows — and across a matrix of uv versions.

## Tests done with the new release

I have run Plone 6.2.0 through 6.2.2, 6.1.0 through 6.1.5, and 6.0.11 through 6.0.15 under Python 3.10. I also tested the 6.2.x series with 3.14, and 6.1.x with 3.13.

In each case, downloading from scratch (nothing cached) shows a good performance boost. On my M2 with a bad internet connection, installation went from roughly 7 minutes with pip down to 1 minute 45 seconds.

As soon as the uv cache is hot, it gets much faster: under 10 seconds to install any of those Plone setups.

This is still an alpha. I would love to hear from you what works and what breaks for you. Enjoy.

2 Likes

I created an agentic (Claude Opus 5.5) benchmark with buildout.coredev#6.2 on my Mac. This is the result (spoiler - there might be a problem with allow-picked-versions = false in this release. see "Compatibility findings" at the bottom):

TL;DR

Scenario buildout (pip) buildout (uv) mxdev make install
Cold: empty download caches, fresh project 318 s 75 s 52 s
Warm: download caches filled, fresh project 303 s 46 s 29 s
Shared eggs: filled shared eggs-directory, fresh project 24 s 23 s n/a
No-op rerun: nothing changed 10.4 s 10.1 s 0.3 s ¹

Environment

Machine Apple M2, 8 cores, 8 GB RAM, macOS 26
Disk Project, caches and eggs on an external USB disk (HFS+), which is also the normal working disk
Python CPython 3.13.9 (uv-managed), same interpreter for all methods
buildout.coredev 6.2 @ c0b03f092 (identical to origin/6.2), exported with git archive, no local.cfg
zc.buildout 5.3.0a1 for both buildout modes
uv 0.12.19, both for buildout (pulled into the venv as a zc.buildout dependency) and for mxmake (global uv)
mxdev / mxmake as installed by the repository's Makefile (PYTHON_PACKAGE_INSTALLER=uv)
Date 2026-09-28/29

Because the disk is HFS+, uv cannot use APFS clones (reflinks) when installing from its cache.
On an APFS system disk the uv based numbers are probably lower.

Method

Each run starts from a fresh copy of the exported repository. The 6 auto-checkout packages from checkouts.cfg (mockup, Plone, plone.app.locales, plone.app.upgrade, Products.CMFPlone, plone.restapi) are cloned once from GitHub on the branches from sources.cfg and copied into src/ before every run, so the initial clone is not measured. The git fetch/update of these checkouts that both tools do on every run is measured: mr.developer runs with always-checkout = force, mxdev with mxdev -f.

Every method gets its own isolated caches (PIP_CACHE_DIR, UV_CACHE_DIR, cookiecutter dir). Runs were strictly sequential, with the methods interleaved to spread network variance.

Commands

buildout (pip) and buildout (uv)

python3.13 -m venv .venv
.venv/bin/pip install pip==26.2.1 setuptools==81.0.0 wheel==0.48.0 \
    horse-with-no-namespace==20260202.0 zc.buildout==5.3.0a1 uv==0.12.19
.venv/bin/buildout [buildout:installer=uv] \
    versions:zc.buildout=5.3.0a1 versions:uv=0.12.19 \
    versions:httpcore=1.0.9 versions:grpcio=1.81.0 \
    versions:grpcio-tools=1.81.0 versions:protobuf=6.33.6

The versions: overrides are the only change to the 6.2 configuration. versions.cfg pins zc.buildout 5.2.0, and the other four pins are explained below. Both buildout modes get exactly the same overrides. In the "shared eggs" scenario, buildout:eggs-directory=<persistent dir> is added.

mxdev

make install PRIMARY_PYTHON=/path/to/python3.13

This uses the unchanged Makefile, mx.ini, mxsources.ini and mxcheckouts.ini from 6.2.

Scenarios

Scenario Download caches Project dir Runs
Cold deleted before the run fresh 2
Warm kept from earlier runs fresh (new venv / new eggs/) 3
Shared eggs (buildout only) kept fresh, but eggs-directory points to a persistent, already filled directory, like a typical ~/.buildout/default.cfg 3 (after 1 priming run)
No-op rerun kept same dir, directly after each warm run 3

Results

Wall-clock time, all runs

Scenario buildout (pip) buildout (uv) mxdev
Cold 325.9, 309.6 72.4, 77.6 52.2, 50.9
Warm 313.4, 303.4, 299.2 46.0, 49.2, 43.8 28.5, 30.2, 26.4
Shared eggs 23.9, 23.7, 23.6 22.5, 22.9, 22.3 n/a
No-op rerun 10.6, 10.2, 10.4 10.2, 10.1, 9.8 0.21, 0.27, 0.31

Run-to-run variation stays within about ±8 %.

Phase breakdown, median in seconds

buildout

Phase pip cold uv cold pip warm uv warm pip shared uv shared
Bootstrap (venv + pip install zc.buildout) 8.3 8.3 5.5 5.7 5.5 5.5
Setup: extensions, mr.developer git update, develop eggs, recipes 81.6 24.7 79.3 17.5 13.7 12.1
Installing parts 227.9 42.0 218.5 23.0 4.7 4.7
Total 317.7 75.0 303.4 46.0 23.7 22.5

mxdev make install

Phase cold warm
mxenv (uv venv, mxdev, mxmake) 3.6 1.8
plone.releaser + manage buildout2pip / versions2constraints 3.0 2.1
git sources (mxdev -f) 7.3 6.3
mxfiles (mxdev -n) 0.6 0.3
packages (uv pip install -r requirements-mxdev.txt) 31.9 14.0
zope-testrunner, cookiecutter, zope instance 5.2 3.8
Total 51.5 28.5

Size

buildout (pip) buildout (uv) mxdev
Distributions installed 358 358 349
Installed size (eggs + develop-eggs + venv, or venv) 491 MB 419 MB 372 MB
Download cache after a run 24 MB ² 425 MB 424 MB

² In pip mode, buildout downloads distributions itself and hands local files to pip, so the pip cache stays almost empty. Without download-cache or a shared eggs-directory, pip mode has nothing it can reuse, which is why "warm" is barely faster than "cold" there.

The distribution sets are not identical: buildout also installs the releaser, z3c_checkversions,
dependencies, zodbupdate, robot etc. parts. Both cover Plone, the test dependencies and the dev tools.

Interpretation

  1. uv changes buildout a lot. Resolution and installation go from ~310 s to ~75 s (cold) and ~46 s (warm).
    The per-requirement pip subprocess fan-out is the main cost in pip mode.
  2. mxdev is still faster than buildout with uv. The gap comes from:
    • buildout's setup phase (extensions, mr.developer, develop eggs, recipes): 17–25 s vs. ~10 s for git and venv in mxdev;
    • buildout resolving and installing per part in several batches, while mxdev does one uv pip install;
    • buildout's pip-based bootstrap (~8 s cold) vs. uv venv in mxmake (~3.6 s).
  3. A shared eggs directory hides the installer. When all eggs are already there, both buildout modes take about 23 s.
    Most of that is bootstrap and git updates.

No-op reruns

These numbers are not comparable. make install only checks sentinel files (0.3 s) and does not touch git or packages. buildout always updates the git checkouts (always-checkout = force in checkouts.cfg), re-evaluates all parts and regenerates scripts (~10 s), and here the installer makes no difference.

Compatibility finding: installer = uv and picked versions

With the unchanged 6.2 configuration (allow-picked-versions = false), pip mode succeeds and uv mode fails:

While:
  Installing.
  Loading extensions.
  Getting distribution for 'mr.developer==3.0.0'.
Error: Picked: httpcore = 1.0.9

The `httpcore` egg does not have a version pin and `allow-picked-versions = false`.

After pinning httpcore, it fails again in the test part:

While:
  Installing test.
  Getting distribution for 'plone.app.robotframework[test]==3.0.0'.
Error: Picked: grpcio = 1.81.0

Checking every installed distribution against versions.cfg, versions-extra.cfg and the Zope 6.2 version files
found exactly four unpinned distributions:

Distribution Why it is unpinned pip mode uv mode
httpcore Dependency of the plone.versioncheck extension (via httpx) accepted "Picked" error
grpcio Deliberately unpinned in 6.2, exact == pin in robotframework-browser metadata (see [versionannotations]) accepted "Picked" error
grpcio-tools same accepted "Picked" error
protobuf same accepted "Picked" error

So there are two behaviour differences between the modes:

  • pip mode does not enforce allow-picked-versions for the dependencies of buildout extensions, uv mode does;
  • pip mode does not treat a version fixed by an exact == pin in a dependency's metadata as "picked", uv mode does.

The benchmark works around this by pinning the four distributions to the versions pip mode picks, in both modes.