Bazel Torch: When a Macro Hides Tests from an Entire Platform

2026/03/05

BUILDbazelcimonorepopython

I wrote about target_compatible_with vs select() last week. Two mechanisms, clean decision tree, nice table. I felt smart.

Then I immediately wrote a macro that used target_compatible_with to silently hide every torch test from macOS developers. For weeks. Nobody noticed.

The Setup

Quick context. Our monorepo has a py_test macro that intercepts torch tests. If your test depends on @pypi//torch, the macro does special things — it needs to, because torch on Linux comes from system site-packages in Docker, not from PyPI. The macro was introduced in PR #23305 so that gazelle could auto-generate torch test targets instead of engineers hand-writing platform splits.

The dream: you write one py_test, gazelle generates it into your BUILD file, the macro handles the platform mess behind the scenes.

The reality: the macro was only generating Linux targets.

Gazelle and Macros: Two Different Phases

This is the part that confused me — honestly still confuses me a little — and it’s the whole reason for this post.

Here’s the question I kept asking: when a dev runs gazelle on macOS vs Linux, do the generated BUILD files look different?

The answer is no. They look identical.

Gazelle reads your Python imports, looks at your pyproject.toml deps, and writes BUILD files. It doesn’t care what platform it’s running on. If your test imports torch, gazelle writes:

py_test(
    name = "test_foo",
    srcs = ["test_foo.py"],
    deps = ["@pypi//torch"],
)

Same on macOS. Same on Linux. Same on a potato with a Starlark interpreter. Gazelle is a code generator — it looks at source files and produces BUILD files. Platform doesn’t enter the picture.

The macro is where platform awareness happens. When Bazel loads that BUILD file, py_test isn’t the native rule — it’s our macro. The macro sees @pypi//torch in the deps and thinks: “ah, this is a torch test, let me do the platform split.” And this is where things went wrong.

The Bug

The macro, before the fix, did this:

py_test(name = "test_foo", deps = ["@pypi//torch"])
    ↓ macro expansion
py_venv_test(
    name = "test_foo",
    include_system_site_packages = True,
    target_compatible_with = ["@platforms//os:linux"],
    tags = ["torch"],
)

One target. Linux only. On Linux CI, bazel test //... picks it up. Works great.

On macOS? bazel test //... shows nothing. The test doesn’t exist. Not failed. Not skipped with a helpful “(incompatible)” message. Just… absent. Because the macro never created a macOS variant.

This is the insidious thing about target_compatible_with. I wrote a whole post about how it elegantly skips incompatible targets. What I failed to appreciate is that elegance cuts both ways — if a target is constrained to the wrong platform, it vanishes silently. No error. No warning. The test just isn’t there, and you’d never know unless you specifically went looking for it.

The Fix: Three Targets From One Line

The fix was making the macro generate three targets instead of one:

py_test(name = "test_foo", deps = ["@pypi//torch"])
    ↓ macro expansion

test_foo_linux   → py_venv_test, system site-packages, Linux only
test_foo_macos   → _py_test, @pypi//torch, macOS only
test_foo         → native.test_suite wrapping both

The _linux variant uses py_venv_test with include_system_site_packages = True — this is how system torch in Docker gets picked up. Constrained to Linux.

The _macos variant uses the original _py_test (the real rule, not our macro) with @pypi//torch as a regular dep. Constrained to macOS.

The plain test_foo is a test_suite that wraps both. When you run bazel test //pkg:test_foo, Bazel looks at the suite’s contents, filters out the incompatible one, and runs whichever matches your platform.

The BUILD file that devs see? Still one line:

py_test(
    name = "test_torch_smoke",
    srcs = ["test_torch_smoke.py"],
    deps = ["@pypi//torch"],
)

All the platform complexity lives in the macro. Gazelle generates the simple thing. The macro expands it into the right thing. Dev writes a test, adds torch to deps, runs gazelle, done.

Before and After

Compare this to what we had before the macro existed — from my previous post:

# macOS: torch comes from @pypi//torch
_py_test(
    name = "test_torch_smoke",
    srcs = ["test_torch_smoke.py"],
    pytest_main = True,
    target_compatible_with = ["@platforms//os:macos"],
    deps = ["@pypi//pytest", "@pypi//torch"],
)

# Linux: system torch in Docker
py_test(
    name = "test_torch_smoke_system",
    srcs = ["test_torch_smoke.py"],
    deps = ["@pypi//torch"],
)

Two manually-written targets, different names, different rules. And this was the correct version — it had both platforms covered. The problem was that nobody was going to hand-write this for every torch test in the monorepo. Hence the macro.

After the fix, that entire block reduces to:

py_test(
    name = "test_torch_smoke",
    srcs = ["test_torch_smoke.py"],
    deps = ["@pypi//torch"],
)

And bazel query confirms the expansion:

$ bazel query 'attr(tags, torch, //bazel/tests:all)'
//bazel/tests:test_torch_smoke
//bazel/tests:test_torch_smoke_linux
//bazel/tests:test_torch_smoke_macos

Three targets from one line. Both platforms covered. Gazelle can generate it. Nobody needs to think about the platform split.

How CI Uses This

The CI setup is clean now:

The torch tag is the multiplexer. CI splits on it. The platform constraint handles the rest.

The Aha Moment

The thing that finally clicked for me — and the reason I’m writing this — is that gazelle and macros operate in completely different phases.

Gazelle is a pre-build step. It reads Python source, generates BUILD files. Platform-agnostic. It runs once and produces the same output regardless of where you run it.

Macros are a load-time step. When Bazel reads a BUILD file, macros expand into actual rule invocations. This is where platform-aware logic can live — because at load time, you can emit multiple targets with different target_compatible_with constraints.

The actual platform filtering happens even later, at analysis time, when Bazel evaluates which targets are compatible with the current configuration.

Three phases:

gazelle (generate)  →  macro (expand)  →  analysis (filter)
   platform-blind      platform-aware      platform-specific

My bug lived in the middle phase. Gazelle was doing the right thing. Analysis would have done the right thing if given the right targets. But the macro — the expansion step — was only producing one of the two targets it needed to.

Wait, Why Not Just select()?

After publishing this, I had the obvious follow-up conversation with myself. The one where you go: “okay but did I overcomplicate this?”

The question: why not use one rule with select() to switch between macOS (PyPI torch) and Linux (system site-packages)?

First instinct: you can’t. py_test and py_venv_test are different rules. You can’t select() which rule to invoke — that’s not how Bazel works. select() operates on attribute values within a single rule, not on which rule gets called.

But that’s the wrong framing. The better question is: what if we use py_venv_test for both platforms?

py_venv_test(
    name = "test_foo",
    include_system_site_packages = select({
        "@platforms//os:linux": True,
        "@platforms//os:macos": False,
    }),
    deps = select({
        "@platforms//os:macos": ["@pypi//torch"],
        "@platforms//os:linux": [],
    }),
)

One target. One rule. No test_suite wrapper. On Linux, it picks up system torch via site-packages. On macOS, it acts like a regular hermetic test with @pypi//torch as a dep. The select() flips the behavior per platform.

This works if py_venv_test with include_system_site_packages = False behaves identically to _py_test on macOS. Which… it might? I haven’t tested it yet. That’s the open question. If py_venv_test in hermetic mode is just _py_test with extra steps, then the three-target approach is overengineered. One target, two select() calls, done.

The three-target solution is correct. It works. It’s in production. But “correct and in production” and “simplest possible” aren’t always the same thing. And the fact that I didn’t think of this until after writing the whole macro — and the whole blog post about the macro — is a little embarrassing. The initial framing (“you can’t select() between rules”) was true but it blocked me from asking the next question (“do you even need different rules?”).

Something to test. If it works, the macro gets simpler. If it doesn’t — if py_venv_test has subtle behavioral differences in hermetic mode — then the three-target approach was the right call all along, just for the wrong reason.

The Lesson

If you’re writing a Bazel macro that uses target_compatible_with, ask yourself: am I generating targets for every platform that should be covered?

target_compatible_with is a filter, not a generator. It can only hide targets that exist. If your macro never creates a macOS variant, target_compatible_with on the Linux variant doesn’t produce a helpful “skipped (incompatible)” on macOS — it produces nothing. The absence of a target is invisible.

The previous post’s decision tree still holds. But I’d add a corollary: if you’re using target_compatible_with inside a macro, you almost certainly need to generate one constrained target per platform plus a test_suite to unify them. Otherwise you’re creating platform-specific targets that only work on the platform you happened to test on.

Which, in my case, was Linux. Because CI is Linux. And that’s exactly the kind of bias that makes bugs like this invisible until someone on a Mac says “hey, where’d all the torch tests go?”

Nobody said that, by the way. I found it myself. Which means I got lucky.