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.tomlflexible (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→ flexiblepip 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:
uv lockupdatesuv.lock.- setuptools’
finalize_distribution_options()runs during the lock/build - If our hook writes optional-deps back to metadata,
uvsees these changes and rewritesuv.lock. - 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
pinnedcontains 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!
Leave a Reply