Most Bazel migrations in a Python monorepo follow a formula. Run gazelle, fix a couple of dep issues, done. Gazelle scans your imports, generates BUILD files, and you move on with your life. It’s the happy path, and for ~90% of packages, it works.
Then there’s the other 10%. The packages where gazelle looks at your source code and goes: “I have no idea what’s happening here.”
lib/chz is one of those. It’s a configuration/dataclass library that uses heavy metaprogramming — exec()-based __init__ synthesis, lazy evaluation thunks, runtime factory dispatch. Gazelle works by statically analyzing Python imports to build a dependency graph. When your library generates code at runtime, gazelle sees nothing useful. It’s like trying to read a book that writes itself as you open it.
This is the story of PR #23041 — Ryan’s hand-written migration of chz to Bazel. Not a gazelle success story. A survival story.
Why chz was a “poison pill”
We track four packages in our monorepo that can’t be auto-migrated. We call them poison pills because they transitively block everything that depends on them:
| Poison Pill | Type | Downstream Impact |
|---|---|---|
| lib/bus | C++/Cython | ~28 packages |
| lib/chz | Metaprogramming | ~5 packages |
| fasttext | Excluded external | ~3 packages |
| mai_kernels | C++/CUDA | overlaps with lib/bus |
chz isn’t the biggest blocker — that’s lib/bus with its C++ tentacles. But every poison pill removed is progress, and the packages behind chz (torchlab, audio_data, image_data, ray_llm_inference_pipeline) aren’t going to unblock themselves.
The reason gazelle chokes on chz boils down to three patterns:
exec()-based__init__synthesis — chz generates__init__methods by building Python source strings and callingexec()on them. The actual code that runs doesn’t exist until runtime. Gazelle can’t analyze what doesn’t exist yet.- Lazy evaluation thunks — deferred computation where the dependency graph is resolved at evaluation time, not import time.
- Runtime factory dispatch — polymorphic construction that’s decided at runtime. Gazelle needs to know your dependency graph at scan time. chz decides its dependency graph at run time.
Static analysis tools assume your code is… static. chz disagrees.
The strategy: gazelle:ignore and drive manually
Instead of fighting gazelle, the approach is simple: tell it to go away.
# gazelle:ignore
This directive in a BUILD file means “don’t touch this directory, I’m driving.” No auto-generation, no static analysis, no gazelle opinions. You’re on your own.
For the library itself, the BUILD file uses a monolithic py_library with a glob:
py_library(
name = "chz",
srcs = glob(["**/*.py"]),
# ...
)
One big target for all of chz’s source. Normally you’d want fine-grained targets — one per module, explicit deps between them. That’s the Bazel way. But chz’s internal cross-references are so dynamic that splitting would break things. Sometimes the pragmatic answer is a glob.
The elegant test pattern
The test BUILD is where this gets interesting. chz has 19 test files. Most need the same base deps. A few need extras — pyyaml for YAML config tests, cloudpickle for data model serialization, pydantic for type validation.
Instead of writing 19 py_test rules by hand:
[py_test(
name = test_file.removesuffix(".py"),
srcs = [test_file],
deps = _BASE_DEPS + ({
"test_config_yaml.py": ["@pypi//pyyaml"],
"test_data_model.py": ["@pypi//cloudpickle"],
"test_tiepin.py": ["@pypi//pydantic"],
}.get(test_file, [])),
) for test_file in glob(["test_*.py"])]
Three lines of config, 19 individual test targets. New test files auto-discover — just name them test_*.py and they’re picked up. Only files with special deps need an entry in the dict.
This is a pattern worth stealing. If you’re hand-writing BUILD files for a package with many test files, list comprehension over glob() with a conditional deps dict is clean, maintainable, and self-documenting.
The __name__ problem
Here’s where the real work is. The biggest chunk of the PR isn’t the BUILD files — it’s fixing 11 test files. And the reason is one of those things that seems obvious in hindsight but absolutely blindsides you the first time.
chz’s error messages include fully-qualified module paths:
"No subclass of tests.test_factories:A named 'X'"
The tests assert on these exact strings. Makes sense — you want to verify the error message is useful.
But when you run under Bazel, Python’s __name__ resolves differently. Bazel’s test runner sets up sys.path its own way, so the module path changes. tests.test_factories becomes something else. Every assertion on a module path string breaks.
Before (hardcoded):
match="No subclass of tests.test_factories:A named 'X'"
After (dynamic):
_MODULE = __name__
# ...
match=f"No subclass of {_MODULE}:A named 'X'"
This is a general lesson for anyone migrating Python tests to Bazel: if your tests assert on module paths, they will break. The fix is always _MODULE = __name__ at module level, then f-strings in your assertions. Across 11 files in this PR, that’s the pattern — capture the module name once, use it everywhere.
The whitespace problem
This one’s subtler. chz has help text output that’s column-aligned — like a CLI --help. The column widths depend on the length of fully-qualified type names. When module paths change length under Bazel (shorter path = different alignment), the columns shift.
The tests were asserting on exact string matches, spaces and all. Under Bazel, those assertions fail because there are fewer spaces between columns.
The fix:
def _normalize_help(text: str) -> str:
"""Normalize help text by collapsing runs of spaces to a single space per line."""
import re as _re
return "\n".join(_re.sub(r" +", " ", line) for line in text.strip().splitlines())
Collapse runs of spaces to single spaces before comparing. The content is the same; only the alignment changed. Don’t assert on formatting that depends on runtime values.
This is the kind of thing you’d never anticipate in a migration plan. “Step 7: fix whitespace-sensitive help text assertions.” Nobody writes that. But it’s where half the debugging time goes.
The lessons
Six things I’d tell someone about to hit the same wall:
1. Know your poison pills early. Before starting a Bazel migration, scan for packages that use heavy metaprogramming, C extensions, or exec()-based code generation. These won’t auto-migrate. Budget time for them separately.
2. gazelle:ignore + hand-written BUILD is a valid strategy. Don’t fight the tool. Gazelle is great for 90% of packages. For the other 10%, a monolithic glob(["**/*.py"]) target and hand-written deps is better than no Bazel target at all.
3. The test glob pattern is worth remembering. List comprehension over glob(["test_*.py"]) with per-file conditional deps via a dict lookup. Auto-discovers new tests, keeps special-case deps explicit, generates individual targets for parallel execution.
4. Bazel changes __name__. If your tests assert on module paths — and especially if your library generates error messages containing module paths — they’ll break under Bazel. Capture _MODULE = __name__ once, f-string everything.
5. Don’t assert on exact whitespace in formatted output. If your test compares column-aligned text where column widths depend on string lengths that change under Bazel, normalize whitespace before comparing.
6. Migration is a cascade problem. Removing one poison pill unblocks its downstream packages, which may in turn unblock others. Track your dependency graph. The impact of fixing chz isn’t just chz — it’s torchlab, audio_data, image_data, and everything downstream of those.
The happy-path migration story is boring on purpose — run gazelle, push, green. The interesting story is what happens when your code is too dynamic for static analysis. You stop automating and start understanding. You read the BUILD format docs, you learn what glob can and can’t do, you discover that __name__ isn’t stable across build systems.
It’s slower. It’s manual. And the package that was blocking five others is finally on Bazel.