Bazel Migration: The Biggest Blocker Was a Cython Package

2026/03/11

BUILDbazelcc-toolchaindependenciesdockermonorepopython

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:

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:

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:

  1. Docker compiles the native code. Cython, CUDA, C++ extensions — whatever it is, Docker’s setup.py / CMake / nvcc handles it during image build.
  2. Bazel manages the Python source layer. py_library with glob, gazelle:ignore, manually maintained deps.
  3. 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.