Project Setup

Pinning a Python CLI’s Dependencies with Hashes

Make every install of a Python CLI tamper-evident: locked hashes with uv, require-hashes installs, Docker images, constraints for users, and cooldowns.

Updated

A version pin says which release to install. It does not say which file. If a mirror, a caching proxy or a private index ever serves a different click-8.5.0-py3-none-any.whl than the one you tested — through misconfiguration or compromise — a version pin installs it without complaint. A hash pin closes that gap: the installer computes the SHA-256 of every downloaded file and refuses anything that does not match the value recorded when you locked. This guide applies hash pinning to every place a Python CLI gets installed under your control — development, CI, release builds and container images — and covers the harder case of end users, who install from version ranges. It belongs to the supply-chain security topic.

Prerequisites

What a hash pin protects against

Does this install check hashes? Which installation methods verify file hashes from a lock and which resolve version ranges without them. Does this install check hashes? Install with Hashes checked? Source of versions uv sync --locked yes, automatically uv.lock pip -r … --require-hashes yes, all required exported file pip -r … (no --hash lines) no pins only pipx / uv tool install no ranges, resolved fresh The lock already has the hashes; the question is whether the installer checks them.

A lock file already records hashes. uv.lock stores the SHA-256 of every wheel and sdist for every locked version, so the question is not whether you have hashes but whether every installer checks them. There are three situations:

  • uv sync from uv.lock verifies hashes automatically. Running it with --locked additionally fails if the lock file is out of date with pyproject.toml, so CI never silently re-resolves.
  • pip or uv pip with a requirements file verifies hashes only when the file contains --hash lines. With --require-hashes, it also refuses any requirement that lacks one — including transitive dependencies you forgot to list.
  • pipx, uv tool install, pip install mytool — the way end users install — resolve dependency ranges fresh and check only what PyPI's metadata says. Your lock file plays no part.

Hash checking costs nothing measurable. The real cost is discipline: every dependency must be locked, including the ones pulled in transitively, and the lock must be updated deliberately.

The recipe

Development and CI: sync from the lock

uv sync --locked                 # install exactly what uv.lock says; fail if it is stale
uv run --locked pytest           # same guarantee for one-off commands

--locked is the important flag in CI. Without it, a pyproject.toml change that was not followed by uv lock triggers a fresh resolution on the CI runner, and you test something no one reviewed. With it, the job fails with a clear message instead.

Anywhere pip is used: export with hashes

Some environments do not have uv — a minimal base image, a deployment system that only speaks pip. Export a fully hashed requirements file from the lock:

uv export --frozen --no-dev --no-emit-project --format requirements.txt -o requirements.txt
python -m pip install --require-hashes --no-deps -r requirements.txt

The exported file lists every package with all of its acceptable file hashes:

click==8.5.0 \
    --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
    --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
    # via mytool

Each package has several hashes — one per wheel and one for the sdist — and the installer accepts any of them, so the same file works across platforms. --no-deps is belt and braces: the file is already complete, so pip should not resolve anything further. If the file is tampered with or a mirror serves a different artefact, the install stops:

A substituted file is refused Terminal session where installing with require-hashes fails because a downloaded file does not match any recorded hash. A substituted file is refused bash $ uv pip install --require-hashes -r requirements.txt error: Hash mismatch for `click==8.5.0` Expected: sha256:0000c9599cf7748b4b1a446ccc735421bd08a2ae… Computed: sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae… A pin alone would have installed the file; the hash makes substitution an error.

Installing your own wheel by hash

The CLI itself should be installed with the same guarantee. Build the wheel once, record its hash, and install that exact file:

uv build --wheel
WHEEL=$(ls dist/mytool-*.whl)
echo "mytool @ file://$PWD/$WHEEL --hash=sha256:$(sha256sum "$WHEEL" | cut -d' ' -f1)" >> requirements.txt
python -m pip install --require-hashes --no-deps -r requirements.txt

This is exactly what smoke-testing the built wheel in CI needs: the artefact you test is provably the one you publish.

Container images

A Docker image of a CLI is a frozen environment, and the ideal place for full hash pinning. A two-stage build keeps uv out of the final image:

# Dockerfile
FROM python:3.13-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/uv
WORKDIR /src
COPY pyproject.toml uv.lock ./
RUN uv export --frozen --no-dev --no-emit-project --format requirements.txt -o /requirements.txt
COPY . .
RUN uv build --wheel --out-dir /dist

FROM python:3.13-slim
COPY --from=build /requirements.txt /dist/ /tmp/install/
RUN python -m pip install --no-cache-dir --require-hashes --no-deps -r /tmp/install/requirements.txt \
 && python -m pip install --no-cache-dir --no-deps /tmp/install/*.whl \
 && rm -rf /tmp/install
USER 65534
ENTRYPOINT ["mytool"]

Every third-party package in the final image matches a hash from uv.lock; the only unhashed install is your own wheel, built moments earlier in the same build. Pin the base images by digest (python:3.13-slim@sha256:…) for the same reason you pin packages. Running as an unprivileged user limits what a compromised dependency could do at runtime.

A hash-locked container image A two-stage Docker build exports hashed requirements and builds the wheel, then installs both into a slim runtime image as an unprivileged user. A hash-locked container image uv.lock committed build stage export + uv build runtime stage --require-hashes USER 65534 no root at runtime req + wheel Pin the base images by digest for the same reason you pin packages.

End users: constraints and cooldowns

When someone runs pipx install mytool, the resolver chooses dependency versions from your declared ranges at that moment. You cannot force hashes on them through package metadata — and you should not pin exact versions in pyproject.toml, which would block security fixes and cause conflicts. Instead, offer reproducibility as an option and reduce the exposure window for everyone.

Publish a constraints file with each release — the hashed export from the lock, attached to the GitHub release. Users who need the exact tested set can install with it:

curl -LO https://github.com/acme/mytool/releases/download/v1.6.0/constraints.txt
uv tool install mytool==1.6.0 --constraints constraints.txt

Recommend a cooldown. uv's --exclude-newer accepts a duration such as "7 days" and ignores any file uploaded more recently; pipx offers --cooldown DAYS on install and upgrade. Malicious releases of popular packages are usually detected and removed within hours or days, so a week's delay protects users from most of them at the cost of receiving genuine fixes a few days later. Mention it in your installation docs next to the plain command.

Ship a locked artefact for users who need full control: a container image as above, or a standalone binary that bundles the exact locked dependencies.

Options for end users Ways to give end users more reproducible or safer installs of a command line tool, and their trade-offs. Options for end users Option Gives Costs Plain pipx / uv tool fixes arrive automatically untested combinations Cooldown (7 days) skips brand-new releases fixes a week later Release constraints file the exact tested set manual upgrades Binary or image fully locked artefact a bigger download Offer reproducibility as an option without taking fixes away from everyone else.

UX considerations

  • Make the CI failure obvious. A --locked failure should say "run uv lock and commit the result"; add that hint to the workflow step name or a wrapper script.
  • Keep lock updates reviewable. Upgrade with uv lock --upgrade-package NAME for targeted fixes, and let a bot propose broader upgrades on a schedule, so each lock diff is small enough to read.
  • Document the paranoid install. A short "verified install" section with the constraints and cooldown commands helps security teams adopt the tool.
  • Do not hand-edit exported files. Regenerate from the lock; a hand-edited hash is either wrong or a mistake waiting to happen.

Testing the behaviour

The property worth testing is that installs fail when they should. A small CI job tampers with one hash and asserts the install is refused:

#!/usr/bin/env bash
# scripts/check-hash-enforcement.sh
set -euo pipefail
uv export --frozen --no-dev --no-emit-project --format requirements.txt -o /tmp/req.txt
# Corrupt every hash of the first package in the file.
python - <<'PY'
import re
text = open("/tmp/req.txt").read()
start = re.search(r"^[A-Za-z0-9_.-]+==", text, re.M).start()
end = text.index("# via", start)
block = re.sub(r"sha256:[0-9a-f]{8}", "sha256:00000000", text[start:end])
open("/tmp/req.txt", "w").write(text[:start] + block + text[end:])
PY
uv venv -q /tmp/hash-check
if VIRTUAL_ENV=/tmp/hash-check uv pip install --no-cache --require-hashes -r /tmp/req.txt; then
  echo "ERROR: install succeeded with corrupted hashes" >&2
  exit 1
fi
echo "hash enforcement OK"

Corrupting every hash of a package matters: each package lists several acceptable hashes (one per file), and changing only one leaves the others valid. A second check — uv sync --locked after editing pyproject.toml without re-locking — should also fail; run it once by hand when setting up CI to see the message your contributors will get.

Conclusion

Hashes turn "the right version" into "the right file". Use uv sync --locked in development and CI, export a hashed requirements file for pip-based installs and container images, install your own wheel by hash, and test that tampering is refused. For end users, publish a constraints file, recommend a cooldown and offer locked artefacts — reproducibility as an option, without taking security fixes away from everyone else.

Frequently asked questions

Do hashes slow installs down?

Not noticeably. Installers hash files as they download them anyway for caching; comparing against a recorded value is free. The cost is only in maintaining the lock file.

Why does each package have several hashes?

A release can contain many files — wheels for different platforms and Python versions, plus an sdist. The lock records all of them so the same file works on Linux, macOS and Windows; the installer accepts whichever file it downloads, as long as its hash is on the list.

Should I commit the exported requirements.txt?

Usually not — generate it from uv.lock when needed, so there is one source of truth. Commit it only if a downstream system requires a static file, and add a CI check that it matches a fresh export.

Does pip install --require-hashes work with editable installs?

No; editable installs and local directories cannot be hashed. Install your own project as a built wheel by hash, as shown above, or install it separately with --no-deps after the hashed dependencies.