Gazelle: Python in a Monorepo, the Invisible Machinery

2026/03/05

BUILDbazeldependenciesmonorepopythontesting
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

Gazelle is the best thing about Bazel for Python. Run it, and your BUILD files write themselves — imports become deps, test files become test targets, and you move on with your life. Until something doesn’t resolve. Or a test collects wrong. Or CI fails with an error message about “configurable attributes” that makes you feel like you opened the wrong terminal.

I just spent a week trying to merge two test macros (py_test and py_torch_test) so that torch tests would “just work” after running gazelle. No manual BUILD editing, no special-case workflows. Auto-detection. The idea was clean. The execution hit every sharp edge gazelle has.

Here’s what I learned about the invisible machinery — the parts you don’t see until they break.

How gazelle resolves Python imports

When gazelle scans a .py file, it’s doing something deceptively simple: reading your import statements and turning them into Bazel deps. import numpy becomes @pypi//numpy. from PIL import Image becomes @pypi//pillow.

But how does it know PIL maps to pillow? They’re completely different strings.

That’s the manifest. Gazelle maintains a gazelle_python.yaml file — a lookup table generated from your uv.lock (or pip requirements). The generator cracks open every .whl file in your dependency set, reads which Python modules are inside, and builds the mapping:

modules_mapping:
  cv2: opencv_python_headless
  PIL: pillow
  yaml: pyyaml

For each import, gazelle checks three things in order:

  1. Is there a local source file that provides this module? (Internal dep)
  2. Is it in the manifest? (External PyPI dep)
  3. Is there a gazelle:resolve directive that manually maps it?

If none of those match and you have python_validate_import_statements set to true — which you should — gazelle errors. It refuses to generate a BUILD file with an import it can’t trace. That’s the hermeticity promise.

The torch catch-22

Here’s where it gets interesting. Our monorepo installs torch as a system package in Docker — not from PyPI via uv.lock. This is intentional: PyTorch wheels are massive (2GB+), platform-specific, and we need exact CUDA version alignment with the Docker image.

But if torch isn’t in uv.lock, it’s not in the manifest. And if it’s not in the manifest, gazelle sees import torch and goes: “I have no idea what package provides this.”

You can’t gazelle:resolve your way out of it either — well, you can (gazelle:resolve py torch @pypi//torch), but then gazelle generates @pypi//torch in your deps, and Bazel tries to download a torch wheel that doesn’t exist for your platform. We’ll get to why that’s a problem in a minute.

The escape hatch is a comment in your source file:

import torch  # gazelle:ignore torch

This tells gazelle: “Yes, I’m importing torch. No, you don’t need to resolve it. Trust me.” It’s the manual override for when the automatic system doesn’t match reality.

py_test vs py_venv_test — two execution models

This is the fundamental split that makes torch testing complicated in Bazel.

py_test (from aspect_rules_py) runs hermetic Python. Bazel manages every dependency. The test runs with -I (isolated mode) — no system packages, no site-packages, nothing leaks in. This is the Bazel ideal. Pure, reproducible, deterministic.

py_venv_test creates a virtual environment. Set include_system_site_packages = True and suddenly your test can see packages installed in the Docker image’s system Python — including torch.

These aren’t interchangeable. Different entry points, different dep handling, different capabilities. py_test supports pytest_main = True (auto-generated pytest runner). py_venv_test doesn’t — you need a manual pytest_runner.py. They use different mechanisms for getting packages to the test runtime.

The whole point of my macro merge was to hide this split. Developer writes a normal test, imports torch, runs gazelle, done. Under the hood, the macro detects @pypi//torch in deps and auto-switches to the venv path.

The pytest_main mystery

Here’s a weird one. aspect_rules_py’s py_test has a pytest_main = True option that auto-generates a pytest entry point. Convenient — you don’t need to write a conftest.py or a runner script.

The generated file is named something like test_foo.pytest_main.py.

See the problem? That dotted filename. When pytest does test discovery, it sees test_foo.pytest_main.py and thinks it’s a submodule of test_foo. It tries to import it as a module, fails, and you get cryptic collection errors that say nothing useful.

The fix is a --ignore-glob=*pytest_main* in every test invocation. But if you’re writing a custom pytest_runner.py for venv tests, you need to remember this — or you’ll spend an hour debugging why pytest can’t find tests that are obviously right there in the directory.

# pytest_runner.py — the manual entry point for py_venv_test
import sys
import pytest

args = [
    "--verbose",
    "--no-header",
    "-p", "no:cacheprovider",
    "--ignore-glob=*pytest_main*",  # ← this one
]

# CI reporting: JUnit XML output
import os
xml_output = os.environ.get("XML_OUTPUT_FILE")
if xml_output:
    args.extend(["--junitxml", xml_output])

# Bazel's --test_filter bridge
test_filter = os.environ.get("TESTBRIDGE_TEST_ONLY")
if test_filter:
    args.extend(["-k", test_filter])

args.extend(sys.argv[1:])
sys.exit(pytest.main(args))

Why @pypi//torch breaks on Linux

This is the one that really got me.

Say your macro detects torch and generates a dep on @pypi//torch. You restrict the test to Linux only with target_compatible_with. You think: “Even if there’s no wheel, the target won’t run on the wrong platform.”

Wrong. Bazel needs to analyze all targets, even ones that won’t execute. Analysis happens before execution. And during analysis, Bazel evaluates @pypi//torch — which is a select() over platform-specific wheel labels. If your uv.lock doesn’t contain Linux x86_64 wheels for torch (because it’s system-installed), the select() has no matching condition. Bazel errors:

configurable attribute "actual" in @pypi//torch doesn't match this configuration

This happens during bazel build //... or bazel test //.... Before any test runs. Before anything executes. The mere existence of @pypi//torch in a dep list causes analysis failure on any platform where the wheel isn’t in uv.lock.

This is a subtlety that trips up everyone who tries to bridge system-installed packages into Bazel’s hermetic world.

The escape hatches: # keep and # gazelle:ignore

Two directives. Different layers. Both essential.

# gazelle:ignore torch goes on your Python source file, next to the import. It tells gazelle’s import resolver: “Don’t try to map this import to a dep. I’ll handle it.”

# keep goes on a dep in your BUILD file:

deps = [
    "@pypi//torch",  # keep
]

It tells gazelle’s dependency pruner: “Don’t remove this dep, even though you can’t find a source file that imports it.” Without # keep, gazelle would see an “unused” dep and helpfully delete it on the next run.

They solve different problems:

Together, they let you have an import that gazelle ignores in source while keeping the corresponding dep that gazelle would otherwise prune in BUILD.

The CI gauntlet

Here’s the actual sequence of failures from my PR. This is the part nobody writes about in Bazel docs.

Attempt 1: Macro merge works locally. Tests pass. Push.

CI fail: buildifier. My macro refactor left an unused load() statement for py_venv_test in the macro file. Buildifier (Bazel’s formatter/linter) catches unused imports. A Python linter would too — same principle. Fix: clean up the load.

Attempt 2: Push again.

CI fail: gazelle check. I’d removed gazelle:ignore torch from source files, thinking my macro would handle everything. But torch isn’t in the manifest (see catch-22 above). Without the ignore directive, gazelle sees import torch and can’t resolve it. The gazelle --check CI step fails because it would produce different BUILD files than what’s committed. Fix: put the ignore directives back.

Attempt 3: Push again.

CI fail: Bazel analysis. I’d added gazelle:resolve py torch @pypi//torch to solve the resolution issue differently. Gazelle was happy. But now @pypi//torch was in the deps, and Bazel analysis failed on Linux — no wheel, broken select(). Fix: back out the resolve, go back to the # keep approach.

Final solution that actually works:

Developer writes: import torch
    ↓
Source file has: # gazelle:ignore torch
    (because torch isn't in the manifest — it's system-installed)
    ↓
BUILD test dep has: @pypi//torch  # keep
    (manually added once per test, gazelle won't prune it)
    ↓
py_test macro detects @pypi//torch in deps → auto-switches to:
  - py_venv_test with system site-packages
  - strips @pypi//torch from runtime deps (it's system-installed)
  - adds "torch" tag
  - restricts to Linux only (target_compatible_with)
  - uses pytest_runner.py as entry point

Three CI failures. Three different layers of the machinery catching different problems. Each one was doing its job correctly — I just didn’t understand how they interconnected.

The full picture

Here’s what’s actually happening, end to end, when you write a Python test that imports torch in a Bazel monorepo where torch is system-installed:

  1. You write import torch in a test file
  2. You add # gazelle:ignore torch because the manifest can’t map it
  3. You run gazelle — it generates a py_test target, skipping the torch import
  4. You manually add @pypi//torch # keep to the test’s deps in BUILD
  5. Your py_test macro sees torch in deps and switches execution model entirely
  6. On CI, the test runs inside Docker where torch is system-installed
  7. The macro creates a venv that sees system site-packages, strips the @pypi//torch dep (it would break analysis), and runs via pytest_runner.py

That’s a lot of machinery for import torch. And every piece exists for a reason — hermeticity, reproducibility, platform portability. The complexity isn’t accidental. It’s the cost of bridging two worlds: Python’s “import anything from anywhere” philosophy and Bazel’s “every dependency is explicit and traced” philosophy.

The frustrating part isn’t that it’s complex. It’s that the complexity is invisible until you step on it. Gazelle makes the happy path effortless — and that’s exactly why the unhappy path feels like falling through a trap door.

You don’t need to understand any of this to write a normal Python test in Bazel. But the moment you have a dependency that exists outside Bazel’s hermetic world — system packages, GPU libraries, anything not in your lockfile — every layer of the invisible machinery becomes visible. All at once. Usually via a CI failure.

Now you know what’s down there.