Pin UV dependencies directly in Python Wheels

Modern Python dependency tools like uv give us pinned lockfiles! 🎉 These lockfiles are great for reproducible production builds and are an essential part of avoiding breakages from regressions like this caused by package dependencies being changed between runs.

For productionising some ETL at work, we needed these pinned dependencies directly in the package wheel, so they would be present wherever the wheel was installed, whether via uv or just regular pip.

Wheels built from pyproject.toml still carry loose constraints (e.g. pandas>=2.0) instead of the exact versions in uv.lock and unfortunately, currently there is no way to build a wheel with the the pinned dependencies from uv.lock.

The workaround using extras

After a lot of trial error, we settled on the approach that the hatch-pinned-extra plugin (by Edgar Mondragón) takes for Hatch:

  • Keep dependencies in pyproject.toml flexible (pandas>=2.0).
  • Ship a wheel with an extra that lists pinned versions (from uv.lock) so production installs are exact (pandas==2.2.2):
    • pip install myapp → flexible
    • pip install 'myapp[pinned]' → exact, reproducible

Internally we use setuptools, so I adapted it to do the same. Here’s how we solved the issue for our use-case:

The circular dependency problem

At first, adding a finalise hook in setuptools failed because:

  1. uv lock updates uv.lock.
  2. setuptools’ finalize_distribution_options() runs during the lock/build
  3. If our hook writes optional-deps back to metadata, uv sees these changes and rewrites uv.lock.
  4. Repeat

The two-pass solution

We break the loop by skipping pinned generation on the first lock, then adding the pinned extra in a second pass.

Pass 1 — create the lockfile

export SETUPTOOLS_SKIP_PINNED=1
uv lock

Pass 2 — inject pinned extras

unset SETUPTOOLS_SKIP_PINNED
uv lock

On Pass 1, the setuptools hook returns early (no export), so uv.lock gets created. On Pass 2, the hook runs, uv export provides pins, setuptools injects the [pinned] extra, uv does one final update, and the cycle ends.

Minimal Setuptools hook

In setup.cfg / setup.py or a small plugin, we can call finalize_distribution_options() and do:

def finalize_distribution_options(dist):
    if os.environ.get("SETUPTOOLS_SKIP_PINNED") == "1":
        return dist
    if not os.path.exists("uv.lock"):
        return dist

    result = subprocess.run(
        ["uv","export","--format","requirements-txt","--no-dev","--frozen","--no-emit-project"],
        capture_output=True, text=True, timeout=10
    )
    pinned_deps = [l.strip() for l in result.stdout.splitlines() if l.strip() and not l.startswith("#") and "==" in l]
    dist.extras_require = getattr(dist, "extras_require", {})
    dist.extras_require["pinned"] = pinned_deps
    return dist

Freeze lockfile at build time

We also had to remember to tell uv not to change the lockfile during the actual wheel build:

uv run --frozen python -m build --wheel

--frozen ensures the build uses the lockfile exactly as-is.

What you get in the wheel

  • Base loose constraints remain: Requires-Dist: pandas>=2.0
  • Extra pinned contains exact pins: Provides-Extra: pinned Requires-Dist: pandas==2.2.2; extra == "pinned" Requires-Dist: numpy==1.26.4; extra == "pinned" ...

Installing with pip install 'myapp[pinned]' satisfies both constraints and yields the exact pinned versions.

I’m sharing this with the hope that this helps someone at some point, including my future self! Have something to add? Please post below!

Further Reading


    Leave a Reply

    Your email address will not be published. Required fields are marked *