Python Packaging: From pip.parse to uv.lock in Bazel

2026/02/11

BUILDbazelpythonuv

We had two requirements.txt files, two pip hubs, a shell script to sync them, and a Python script to generate the minimal one from the full one. Four artifacts, all doing the same job: telling Bazel which Python packages to fetch.

Then we replaced all of it with one line pointing at uv.lock.

This is what that migration looked like, what broke, and why the result is better in every way that matters.

The before: two hubs, two files, two problems

First, let me explain what a “hub” is in Bazel, because the word sounds like it should be obvious and it absolutely isn’t.

A hub is Bazel’s local mirror of PyPI packages. When you write deps = ["@pypi//requests"] in a BUILD file, Bazel doesn’t go to pypi.org at build time. It goes to a local repository — the hub — that was pre-populated during the fetch phase. The hub name is just the label prefix you give this local repo.

Our old setup had two of them:

# MODULE.bazel (before)

pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")

# Hub 1: Full — every package in the monorepo
pip.parse(
    hub_name = "pypi_full",
    requirements_lock = "//bazel:requirements.txt",
)

# Hub 2: Minimal — only packages needed for Bazel targets
pip.parse(
    hub_name = "pypi",
    requirements_lock = "//bazel:requirements-minimal.txt",
)

Why two? Performance. pip.parse with the full requirements.txt (600+ packages) was slow — it would resolve and validate every single package during the loading phase, even if you were only building one target that needed three packages. The minimal file was a hand-curated subset of packages that actually appeared in BUILD files.

The maintenance burden was exactly what you’d imagine:

  1. Developer adds a new dependency to a package
  2. uv add whatever updates uv.lock and pyproject.toml
  3. Nothing updates requirements.txt because requirements.txt is a separate file
  4. requirements.txt gets updated by running update_requirements.sh
  5. requirements-minimal.txt gets updated by running generate_minimal_requirements.py, which reads the full file and filters to packages used in BUILD deps
  6. Someone forgets step 4 or 5
  7. CI breaks with a cryptic “package not found in @pypi” error
  8. 20 minutes of debugging to realize the requirements files are stale

This happened about once a week.

The sync scripts

Here’s what we were maintaining to keep everything aligned:

File Purpose Updated by
bazel/requirements.txt Full package list for @pypi_full update_requirements.sh (runs uv pip compile)
bazel/requirements-minimal.txt Subset for @pypi generate_minimal_requirements.py
bazel/update_requirements.sh Compiles uv.lock → requirements.txt Human (manual)
bazel/generate_minimal_requirements.py Filters full → minimal Human (manual)

Four files, two of which are automation scripts that automate converting one format to another format that exists solely because the build system’s extension couldn’t read the original format.

This is what happens when a build system’s package manager (pip.parse) only speaks requirements.txt, but your actual dependency manager (uv) speaks uv.lock. You build a bridge. Then you maintain the bridge. Then you write scripts to maintain the bridge. Then the bridge has bugs.

The after: one hub, zero scripts

The aspect_rules_py project ships a uv extension that reads uv.lock directly. No conversion, no intermediate files, no sync scripts.

# MODULE.bazel (after)

uv = use_extension("@aspect_rules_py//uv/unstable:extension.bzl", "uv")

uv.declare_hub(hub_name = "pypi")
uv.lockfile(
    hub_name = "pypi",
    venv_name = "default",
    src = "//:uv.lock",
)

That’s it. The hub is populated directly from uv.lock. The same file that uv add and uv sync maintain is the same file that Bazel reads. One source of truth. No conversion step. No scripts to forget to run.

Deleted files:

Four files gone. The maintenance burden of keeping them in sync — gone. The “requirements.txt is stale” CI failures — gone. The confused developer staring at two different package lists wondering which one is canonical — gone.

What actually changed in the hub

The hub concept didn’t change. @pypi//requests still resolves to the same thing — a local Bazel repository containing the requests wheel. What changed is how the hub gets populated.

Before (pip.parse):

  1. Read requirements.txt
  2. Parse each line: package==version --hash=sha256:...
  3. For each package, create a Bazel repo rule that downloads the wheel
  4. Register all repos under the hub namespace

After (uv extension):

  1. Read uv.lock
  2. Parse the TOML structure (richer than requirements.txt — has metadata, sources, resolution info)
  3. For each resolved package, create a Bazel repo rule that downloads the wheel
  4. Register all repos under the hub namespace

Same output, different input format. But the uv lockfile carries more information — platform markers, optional dependency groups, source URLs, hashes. The extension can make smarter decisions about which wheels to fetch for which platforms.

The performance improvement from eliminating the “full” hub was a bonus. With pip.parse, having 600+ packages in the requirements file meant 600+ repo rules registered during loading, even if you only needed 50. The uv extension is lazier — it registers repos on demand, so you only pay for packages you actually use in your build graph.

The migration checklist

If you’re doing this yourself, here’s the sequence:

1. Verify your uv.lock is complete.

Run uv lock and make sure every package your BUILD files reference exists in the lockfile. The uv extension will error on missing packages, which is the right behavior — but you want to discover that locally, not in CI.

2. Swap the MODULE.bazel config.

Replace the pip.parse calls with the uv.declare_hub + uv.lockfile calls. Keep the same hub_name — if your BUILD files reference @pypi//, the hub must be called pypi.

3. Update hub references.

If you had two hubs (like we did), grep for the old hub name and update:

# Find references to the old full hub
grep -r "pypi_full" --include="*.bzl" --include="BUILD*"

In our case, @pypi_full was only used by Gazelle’s manifest generation. Everything else used @pypi. So this was a one-line change in the Gazelle config.

4. Delete the dead files.

Remove requirements.txt, requirements-minimal.txt, and any sync scripts. If they’re referenced in CI or pre-commit hooks, remove those references too.

5. Test the whole thing.

bazel fetch //...
bazel build //...
bazel test //...

The fetch is the moment of truth. If the uv extension can resolve every package in your build graph from uv.lock, you’re done. If it can’t, you’ll get clear errors about which packages are missing.

Edge cases that bit us

Private registry packages without hashes. Our internal PyPI registry doesn’t serve PEP 503 hashes. pip.parse didn’t care. The uv extension did — it expected hashes by default and failed on private packages. This led to upstream issue #790 and a patch. More on that in a later post.

Editable packages. uv.lock can contain editable installs (local packages under development). The extension was including them in the hub, creating dangling dependency references. Issue #791.

Multi-version collisions. When uv resolves different versions of the same package for different platforms, the repo naming could collide. Issue #792.

All three were fixable — patched locally, PRs filed upstream. But they’re the kind of thing you only discover by running the migration on a real codebase with real complexity. The extension is marked unstable in the import path for a reason.

Why this matters beyond the cleanup

The obvious win is less maintenance — four fewer files, zero sync scripts, no more stale-requirements CI failures. But the deeper win is about the dependency graph becoming a single source of truth.

Before, there were three representations of our Python dependencies: uv.lock (the real one), requirements.txt (the Bazel translation), and requirements-minimal.txt (the optimized Bazel translation). Any tool that wanted to answer “what depends on what?” had to pick a source — and they didn’t always agree.

Now there’s one: uv.lock. Bazel reads it. uv maintains it. Security scanners audit it. Developers update it. One file, one format, one truth.

The migration took about a day. Debugging the three upstream edge cases took another day. Deleting four files and their associated CI wiring took an hour. The weekly “requirements.txt is stale” messages in Slack took about zero seconds to not happen anymore.

Some migrations are like renovating a house — months of work, uncertain payoff. This one was like removing a load-bearing wall and discovering the house stands up fine without it. The wall was never load-bearing. We just assumed it was because it had always been there.