I spent an afternoon chasing a circular dependency that Gazelle kept generating. The fix was two lines. But the path to those two lines involved three wrong hypotheses, a Ruff formatting gotcha, and a fundamental lesson about why py_binary exists.
This is the debugging story, wrong turns included.
The cycle
Package ci has a library target (:ci) and a tools target (:tools). There’s also cli.py — a command-line entry point that imports from both.
Here’s the structure:
ci/
├── BUILD.bazel
├── src/
│ └── ci/
│ ├── __init__.py
│ ├── core.py → in :ci library
│ ├── cli.py → the problem child
│ └── tools/
│ ├── __init__.py
│ └── runner.py → in :tools library
cli.py imports ci.tools. The tools library depends on :ci (because runner.py imports ci.core). If cli.py is a source file in the :ci library, here’s what Gazelle generates:
# BUILD.bazel (Gazelle output)
py_library(
name = "ci",
srcs = ["src/ci/__init__.py", "src/ci/core.py", "src/ci/cli.py"],
deps = [":tools"], # ← because cli.py imports ci.tools
)
py_library(
name = "tools",
srcs = glob(["src/ci/tools/**/*.py"]),
deps = [":ci"], # ← because runner.py imports ci.core
)
:ci depends on :tools. :tools depends on :ci. Cycle. Bazel refuses to build.
The dependency is real in both directions — cli.py genuinely imports tools, and tools genuinely imports core. You can’t just delete one edge. The question is how to break the cycle without lying about dependencies.
Wrong hypothesis 1: gazelle:ignore
My first instinct: tell Gazelle to ignore the ci.tools import in cli.py.
# cli.py
import ci.tools # gazelle:ignore
The directive gazelle:ignore tells Gazelle: “Don’t resolve this import. Don’t add a dep for it.” So Gazelle wouldn’t add :tools as a dep of :ci, breaking the cycle.
It sort of worked. The cycle was gone. But cli.py was still a source in the :ci library, which meant any other imports in cli.py would still influence :ci’s dependency list. As the file grew, every new import in cli.py potentially added a dependency to the library that every other target in the repo depended on. The ignore directive was a bandaid on one import, not a fix for the structural problem.
Also — and this is the part that cost me 30 minutes — Ruff reformatted my inline comment.
The Ruff interaction
This is the kind of gotcha that only exists in the intersection of two tools that don’t know about each other.
I wrote:
from ci.tools import runner # gazelle:ignore
Ruff’s formatter saw a long line and reformatted it:
from ci.tools import (
runner,
) # gazelle:ignore
Gazelle reads gazelle:ignore directives on the import line. After Ruff’s reformatting, the directive is on the closing parenthesis line, not the import line. Does Gazelle still see it?
Actually — yes. Gazelle handles this case. It scans for the directive on the same logical statement, including the closing paren. So the reformatting wasn’t the issue I thought it was.
The real issue was that gazelle:ignore only suppresses the dep for the annotated import. Other imports in cli.py — like import argparse, import sys, import ci.config — still get resolved normally. And ci.config pulled in another transitive dependency that also created problems. I was playing whack-a-mole: ignore one import, another one causes trouble.
gazelle:ignore is a scalpel. I needed a machete.
Wrong hypothesis 2: more ignores
Okay, what if I just ignore everything in cli.py? Stack multiple directives:
import ci.tools # gazelle:ignore
import ci.config # gazelle:ignore
from ci.utils import helper # gazelle:ignore
This works mechanically. Every third-party and internal import gets suppressed. Gazelle generates a :ci library with no deps from cli.py.
But now the :ci library has cli.py in its sources with none of its actual dependencies declared. Anything that depends on :ci and transitively needs cli.py to be importable would fail at runtime, because the deps aren’t there.
“Ignoring all the imports” is the dependency equivalent of covering the check engine light with tape. The dashboard looks clean. The engine is still on fire.
The right tool: gazelle:exclude
The directive I needed was gazelle:exclude, not gazelle:ignore.
# cli.py
# gazelle:exclude
gazelle:ignore = suppress resolution of one specific import. File stays in srcs. Other imports still managed.
gazelle:exclude = remove this file from Gazelle’s view entirely. Not in any py_library srcs. Not parsed for imports. Gazelle pretends it doesn’t exist.
With cli.py excluded from the :ci library, Gazelle no longer adds :tools as a dep of :ci. The cycle is broken — not by suppressing an edge, but by removing the node that created it.
But wait — cli.py still needs to be built. It’s a command-line tool. It needs to run. Where does it go?
py_binary: the leaf node
# BUILD.bazel
py_binary(
name = "cli",
srcs = ["src/ci/cli.py"],
deps = [
":ci",
":tools",
],
)
py_binary is a leaf node in the dependency graph. Nothing depends on a binary — it’s an entry point, a thing you run, not a thing you import. Dependencies flow into py_binary but never out.
This is exactly the property that breaks cycles. When cli.py was in py_library, its imports added edges to the library’s dep graph, and those edges could create cycles. In py_binary, those same imports add edges that terminate — they point at libraries, but no library points back at the binary.
Before (cycle):
:ci ──→ :tools ──→ :ci (via cli.py's import)
After (no cycle):
:ci ──→ (nothing from cli.py)
:tools ──→ :ci
:cli (binary) ──→ :ci
:cli (binary) ──→ :tools
The binary can depend on both :ci and :tools without creating a cycle, because nothing depends on the binary. It’s a one-way valve. Dependencies flow in, nothing flows out.
The debugging method
Here’s what I should have done from the start, and what I do now every time Gazelle generates something unexpected:
- Clean slate. Delete the BUILD file for the affected package.
- Seed a stub. Create a minimal BUILD file with just the
python_rootdirective. - Run Gazelle once.
bazel run //:gazelle -- ci/ - Read the diff. The generated BUILD file is ground truth — it tells you exactly what Gazelle thinks the dependency graph looks like.
- Apply ONE hypothesis. Make one change (add a directive, move a file, exclude something).
- Re-run Gazelle. Read the new diff. Compare.
- Repeat until the diff matches your expectations.
The critical insight: the diff is the truth. Not what you think Gazelle should do. Not what the documentation says. The actual diff produced by the actual tool on the actual codebase. When I was stacking gazelle:ignore directives, I was reasoning about what I thought Gazelle would do. When I deleted and regenerated, I could see what it actually did.
This is the general pattern for debugging code generators. Don’t reason about the generator’s behavior in your head. Run it. Read the output. Change one thing. Run it again. The output is cheap to produce and expensive to predict.
The summary
| Directive | What it does | When to use |
|---|---|---|
gazelle:ignore |
Suppress dep resolution for one import | Dep is optional or handled externally |
gazelle:exclude |
Remove file from Gazelle entirely | File should be in py_binary, not py_library |
The rule: if a Python file is an entry point (CLI tool, script, main function), it belongs in py_binary, not py_library. Entry points import everything and nothing imports them. That directionality is what prevents cycles.
py_binary as a firewall. Dependencies flow in. Nothing flows out. Cycles can’t cross a one-way wall.
Three wrong hypotheses, one Ruff red herring, and the fix was: move cli.py out of the library and into a binary. The dependency graph was telling me the answer the whole time — cli.py is a consumer of libraries, not part of one. The cycle was the build system’s way of saying “this file doesn’t belong here.”
Listen to your build errors. They’re usually right.