Gazelle: How import yaml Becomes @pypi//pyyaml

2026/02/08

BUILDbazelgazellepython
Series: Gazelle (6 posts)
  1. Gazelle: How import yaml Becomes @pypi//pyyaml
  2. Gazelle: Python Manifest and modules_mapping
  3. Gazelle: ASPECT_GAZELLE_CACHE Internals
  4. Gazelle: Python in a Monorepo, the Invisible Machinery
  5. Gazelle: When It Can’t Help, Hand-Writing BUILD Files
  6. Gazelle: Reading the Source Code

Pop quiz. You write import yaml in a Python file. What pip package provides that?

If you said yaml — wrong. It’s PyYAML. The package name is PyYAML, the import name is yaml, and they share exactly zero characters in common.

This is the fundamental problem Gazelle has to solve when generating BUILD files for Python. It reads your source code, finds your imports, and needs to turn each import into a Bazel dependency label like @pypi//pyyaml. But Python’s import system and its package naming system are two completely different namespaces with no formal mapping between them.

Here’s how that actually works.

The namespace mismatch

In most languages, the import path is the package identity. import "github.com/foo/bar" in Go tells you exactly which module you need. import com.google.common.collect in Java maps directly to the Maven artifact.

Python doesn’t do this. Python’s import system is based on what’s on sys.path at runtime — which could be anything. A package installed via pip can expose any module name it wants. The setup.py or pyproject.toml declares the distribution name (what you pip install), and the wheel contents declare the importable modules. These are two independent decisions made by the package author.

The greatest hits of this mismatch:

You write You install Ratio of shared characters
import yaml pip install PyYAML 4/10
import cv2 pip install opencv-python 0/14
import PIL pip install pillow 0/6
import bs4 pip install beautifulsoup4 2/16
import sklearn pip install scikit-learn 5/13
import gi pip install PyGObject 0/8
import attr pip install attrs 4/5

attr vs attrs is my favorite — one character off, maximum confusion. The package was renamed but the import wasn’t. A tale as old as Python packaging.

Where the two names actually live

If you want to see this for yourself, here’s where each name is defined. Using PyYAML as the example:

Distribution name lives in setup.py (or pyproject.toml):

# https://github.com/yaml/pyyaml/blob/main/setup.py
setup(
    name='PyYAML',       # ← this is what `pip install` sees
    version='6.0.2',
    ...
)

Import name is just the directory structure that ends up inside the wheel:

pyyaml/
├── setup.py
└── lib/
    └── yaml/            # ← this folder name IS the import name
        ├── __init__.py
        ├── parser.py
        ├── scanner.py
        └── ...

You can verify this by unzipping any wheel. They’re just zip files:

$ unzip -l PyYAML-6.0.2-*.whl | head -10

yaml/__init__.py
yaml/parser.py
yaml/scanner.py
yaml/composer.py
...
PyYAML-6.0.2.dist-info/METADATA    # ← distribution name here
PyYAML-6.0.2.dist-info/RECORD

The yaml/ directory goes into site-packages/ as-is. The dist-info/METADATA file says Name: PyYAML. Two different places, two independent decisions by the package author.

The Pillow case is even better. The original package was called PIL, abandoned in 2011. Pillow forked it, renamed the distribution, but kept the folder as PIL/ for backwards compatibility:

$ unzip -l Pillow-11.0.0-*.whl | head -5

PIL/__init__.py          # ← still PIL, not Pillow
PIL/Image.py
PIL/ImageFilter.py
Pillow-11.0.0.dist-info/METADATA   # ← says "Name: Pillow"

So pip install Pillow, import PIL. The distribution was renamed, the import was frozen in time. Nobody coordinated this. Nobody had to. That’s the root of the whole problem Gazelle’s manifest exists to solve.

The reverse index

Gazelle solves this with a file called gazelle_python.yaml. It’s a manifest — a reverse index that maps import names back to package names. Here’s a slice of what it looks like:

manifest:
  modules_mapping:
    yaml: pyyaml
    PIL: pillow
    cv2: opencv_python
    bs4: beautifulsoup4
    sklearn: scikit_learn
    attr: attrs
    # ... 860+ entries ...
  pip_repository:
    name: pypi

860-plus entries. Each one is a mapping from “what Python sees at import time” to “what Bazel should depend on.” When Gazelle encounters import yaml in your source, it looks up yaml in this mapping, finds pyyaml, and generates deps = ["@pypi//pyyaml"] in your BUILD file.

The pip_repository.name field tells Gazelle which hub repo to reference. If your MODULE.bazel declares hub_name = "pypi", then the label becomes @pypi//pyyaml. Change the hub name, change the label prefix.

Where the mapping comes from

The mapping is built by inspecting the contents of installed wheels. When you pip install PyYAML, the wheel contains a directory called yaml/. That directory name is the importable module. The distribution metadata says the package name is PyYAML. Connect the two and you have your mapping: yaml → pyyaml.

In theory, Gazelle can auto-generate this manifest by scanning all your installed packages. The command is:

gazelle update-repos --from_file=requirements.txt --to_macro=...

In practice, this is blocked by upstream bug #784 in aspect_rules_py. The auto-generation doesn’t work reliably for all packages — some wheels have non-standard layouts, namespace packages split across multiple distributions, or conditional imports that don’t appear in the top-level __init__.py.

So the manifest is maintained semi-manually. Someone adds a new dependency, notices Gazelle can’t resolve the import, and adds the mapping. It’s not glamorous. It works.

What happens when the mapping is wrong

Gazelle has a setting called python_validate_import_statements that controls what happens when an import can’t be resolved:

Setting Behavior Risk
true Unresolvable import → BUILD ERROR Blocks builds on missing mappings
false Unresolvable import → silently skipped Dep missing from BUILD, runtime ImportError

With true, you get build-time feedback: “I found import yaml but I don’t know what package provides it.” Fix the manifest, re-run Gazelle, move on. Annoying but safe.

With false, Gazelle shrugs and omits the dependency. Your BUILD file compiles fine. Your code fails at runtime with ModuleNotFoundError: No module named 'yaml'. You spend 20 minutes debugging before realizing it’s a missing BUILD dep. I’ve seen this happen more times than I want to admit.

We run with true. The cost is that every new third-party import needs a manifest entry before Gazelle will generate a clean BUILD file. The benefit is that we never ship code with missing dependencies. That tradeoff is worth it in a monorepo where “works on my machine” means “my virtualenv has it but the Bazel sandbox doesn’t.”

The special case: deps that aren’t in your lockfile

Some packages exist in your code but not in your dependency lockfile. The poster child is torch — we run GPU tests in Docker containers where torch is pre-installed as a system package. It’s not in uv.lock because you can’t (sanely) resolve the CUDA-flavored torch wheel on a standard pip index.

Gazelle encounters import torch, consults the manifest, finds nothing (because torch isn’t in the pypi hub), and either errors or silently drops the dep. Neither is what you want.

The escape hatch is a per-file directive:

# gazelle:resolve py torch @pypi//torch

This tells Gazelle: “When you see import torch in this file, map it to @pypi//torch, and don’t consult the manifest.” It’s a manual override for imports that live outside the normal resolution path.

You can also set this at the BUILD level or in the Gazelle config for broader scope. Per-file is the most precise — it only affects the file that actually needs the override, leaving Gazelle’s normal resolution intact everywhere else.

The architecture under the hood

It’s worth understanding the resolution pipeline, because it explains why certain failure modes happen:

Source file: main.py
  │
  ├─ Parse AST → extract import statements
  │    import yaml
  │    import os
  │    import torch
  │    from mypackage import utils
  │
  ├─ Classify each import:
  │    yaml     → third-party (not stdlib, not first-party)
  │    os       → stdlib (skip)
  │    torch    → third-party (but check gazelle:resolve first)
  │    mypackage.utils → first-party (resolve within repo)
  │
  ├─ Resolve third-party via manifest:
  │    yaml  → modules_mapping["yaml"] → pyyaml → @pypi//pyyaml
  │    torch → gazelle:resolve directive → @pypi//torch
  │
  ├─ Resolve first-party via repo structure:
  │    mypackage.utils → //mypackage:mypackage (py_library)
  │
  └─ Generate BUILD deps:
       deps = [
           "//mypackage",
           "@pypi//pyyaml",
           "@pypi//torch",
       ]

The classification step is where things get interesting. Gazelle maintains a list of stdlib modules (per Python version) and considers anything that matches a local package path as first-party. Everything else is third-party and goes through the manifest.

First-party resolution is where Gazelle’s real power shows. It walks the repository structure, finds py_library targets, matches import paths to package directories, and generates cross-package deps. This is the part that npm-style lockfiles can’t do — intra-monorepo dependency tracking based on actual import analysis.

Namespace packages: the edge case that never ends

Python has two kinds of packages: regular packages (with __init__.py) and namespace packages (without). Namespace packages can be split across multiple directories — or multiple pip distributions.

For Gazelle, this is a nightmare. If google.cloud.storage is provided by google-cloud-storage and google.auth is provided by google-auth, the import prefix google maps to multiple packages. The manifest needs entries for the full import path, not just the top-level module:

google.cloud.storage: google_cloud_storage
google.auth: google_auth
google.protobuf: protobuf

If you only map google → ???, Gazelle can’t disambiguate. The mapping has to be deep enough to uniquely identify the distribution. This is why the manifest has 860+ entries instead of, say, 200 — namespace packages blow up the mapping space.

The lesson

Python’s packaging is two systems pretending to be one. The distribution system (pip, PyPI, wheels) speaks one language. The import system (sys.path, __init__.py, namespace packages) speaks another. Every tool that bridges these two systems — Gazelle, pipreqs, importlib.metadata — is essentially maintaining a lookup table between two namespaces that were never designed to be related.

Gazelle’s manifest is that lookup table made explicit. 860 entries, semi-manually maintained, critical to the entire build. It’s not elegant. But it’s honest about the problem — which is more than most Python tooling manages.

The next time someone asks why Python packaging is complicated, show them this: the import statement in your code and the package name in your lockfile are two different strings, connected by a YAML file that someone had to write by hand. That’s the state of the art.