Every dep graph has a load-bearing wall. In ours, it was lib/bus — a package with four Cython .pyx files compiled via setup.py with language="c++". Not glamorous. Not GPU. Just Cython doing Cython things. And it was blocking ~28 packages from migrating to Bazel.
Why lib/bus matters
Here’s the dependency chain:
lib/bus (Cython)
└── lib/oai_api
└── common_utils
└── ...everything
That “everything” isn’t hyperbole. common_utils is the kind of package that shows up in import statements the way oxygen shows up in molecules — everywhere, invisibly, load-bearing. Twenty-eight packages downstream couldn’t migrate because they transitively depended on lib/bus, and lib/bus had native code that Bazel can’t compile.
Before today: 24 out of 101 packages on Bazel. After: the root blocker is gone. The cascade can begin.
The Cython problem
lib/bus has four compiled modules:
| Module | What it is |
|---|---|
oai.multimodal_token.token_schema.token_schema |
Token schema definitions |
oai.multimodal_token.token_schema.token_ops |
Token operations |
oai.multimodal_token.mmbatch.batch |
Multimodal batch handling |
oai.multimodal_token.toklist.util_token |
Token utilities |
The __init__.py files eagerly import from these modules. Not lazy imports. Not conditional. Just straight from .token_schema import ... at the top of the file.
Bazel doesn’t compile Cython. That’s a Docker concern — setup.py runs during image build, produces .so files, done. But if you try to put this package in Bazel as a py_library, the first thing that happens on import is Python looking for those .so files, not finding them, and throwing an ImportError.
The question: how do you declare a Bazel target for a package whose imports literally don’t exist outside a Docker container?
The pattern: mock the native, manage the Python
If you read the mai_kernels post, this will feel familiar. Same idea, different native code.
magic_mock_packages() from mai_nano.mocks — placed at the very top of oai/multimodal_token/__init__.py, before any submodule imports:
from mai_nano.mocks.magic_mock_packages import magic_mock_packages
magic_mock_packages(
"oai.multimodal_token.token_schema.token_schema",
"oai.multimodal_token.token_schema.token_ops",
"oai.multimodal_token.mmbatch.batch",
"oai.multimodal_token.toklist.util_token",
raise_on_access=True,
)
Two execution environments, two behaviors:
- CPU / Bazel outside Docker: The four Cython modules get replaced with
RaisingMagicMockobjects insys.modules. Imports succeed. But touch any real attribute and you get aRuntimeError. You can type-check, you can resolve deps, you can build the graph — you just can’t call Cython code that doesn’t exist. - Docker: The real
.sofiles are there.magic_mock_packagesdetects they’re importable and does nothing. It’s a no-op.
The raise_on_access=True is the safety net. Without it, you’d silently get MagicMock return values everywhere — the kind of bug that passes tests and explodes in production. With it, any accidental usage fails loud and immediate.
The BUILD files
Two files. Both manually maintained — Gazelle can’t reason about Cython any better than it can reason about CUDA.
lib/bus/BUILD.bazel — the main target. Globs all Python source from src/bus/, src/completer/, src/oai/. Excludes test files. Declares ~73 external deps. Tagged with gazelle:ignore because auto-generation would be a disaster here.
lib/bus/src/oai/multimodal_token/tests/BUILD.bazel — 10 test targets, all py_torch_test with include_system_site_packages=True and target_compatible_with = ["@platforms//os:linux"]. These only run in Docker where the Cython modules actually exist.
The split is clean: Bazel manages the Python source graph everywhere. Tests that need real Cython run only where real Cython lives.
The code review catch
Here’s a fun one. During review, the engineer flagged that sample_image.py was listed as data in a test target:
py_torch_test(
name = "test_sample_image",
srcs = ["test_sample_image.py"],
data = ["sample_image.py"], # ← wrong
deps = ["//lib/bus"],
)
But the test does this:
from oai.multimodal_token.tests.sample_image import sample_image_bytes
In Bazel, data puts files in the runfiles directory — they’re accessible as files, but they’re NOT on the Python import path. So data = ["sample_image.py"] means the file exists on disk but Python can’t import it. The fix: move it to srcs.
Small thing. Exactly the kind of Bazel gotcha that works fine locally (where everything is on the path) and fails in CI (where the sandbox enforces the declared graph). This is why code review exists.
The dep graph math
Before lib/bus, the poison pill analysis looked like this:
| Root Poison | Type | Downstream Impact |
|---|---|---|
lib/bus |
C++/Cython | ~28 packages |
lib/chz |
Manually excluded | 5 packages |
fasttext |
Excluded external | ~3 packages |
mai_kernels |
C++/CUDA | overlaps with lib/bus |
lib/bus was the biggest. And mai_kernels was already handled. So today’s change removes the #1 blocker from the list.
The remaining blockers are smaller:
lib/chz— actually already has a working BUILD file. It was manually excluded because metaprogramming breaks Gazelle’s import analysis, but the Bazel target itself compiles fine. This might just need an un-exclusion.fasttext— excluded external dep. Affects ~3 unique packages.mai_kernels/simplertransformer— CUDA packages, already migrated via the same Docker-route pattern.
The full cascade still requires migrating each of those ~28 downstream packages individually. But the root constraint is gone — //lib/bus is now a valid Bazel dep that any package can reference.
The pattern
Three times now — torch, mai_kernels, and now lib/bus — the solution has been the same:
- Docker compiles the native code. Cython, CUDA, C++ extensions — whatever it is, Docker’s
setup.py/ CMake / nvcc handles it during image build. - Bazel manages the Python source layer.
py_librarywith glob,gazelle:ignore, manually maintained deps. - A mock bridge lets them coexist.
magic_mock_packages()replaces native modules with safe mocks on CPU. Real modules take over in Docker.
It’s not pure. A Bazel purist would want everything built from source inside the sandbox. But we have 100+ packages, Cython extensions, CUDA kernels, and a migration that needs to happen this quarter — not next year. The pragmatic answer is: let each build system do what it’s good at, and put a thin bridge between them.
That bridge unblocked the single biggest bottleneck in the monorepo. I’ll take pragmatic over pure every time.