fix: Gazelle bug with merging py_binary targets in per-file mode and partial update (#2619)
This PR adds a new unit test. Currently, this is just a failing test
without a fix, and I am still trying to understand the code well enough
to find the root cause of the issue.
Our team uses Python+Gazelle in a monorepo, and we have a handful of
directories with multiple `.py` files containing `if __name__ ==
"__main__"`. Most of the time these are present for convenience or
ad-hoc invocation. We're aware of the [recommendation to split these
into separate
files](https://github.com/bazelbuild/rules_python/tree/main/gazelle#binaries),
but that can cause clutter, and it is non-obvious to most engineers what
to do when encountering this issue, which presents either as a
misleading error message or a no-op without creating the appropriate
targets.
**Update**
This bug occurs when ALL of the following are true:
* `python_generation_mode` is set to `file`.
* Multiple python binary files (files with `if __name__ == "__main__"`)
exist in the same directory.
* The directory has no `__main__.py` file.
* The `BUILD` file in the directory is partially complete, i.e. it
contains `py_binary` targets for some of the python files, but not
others.
In this situation, previously absent `py_binary` targets are merged into
existing `py_binary` targets instead of being created as new targets.
---------
Co-authored-by: Jimmy Tanner <jimmy@physicalintelligence.company>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a62ab7..1c075af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,8 @@
{#v0-0-0-fixed}
### Fixed
* (pypi) The `ppc64le` is now pointing to the right target in the `platforms` package.
+* (gazelle) No longer incorrectly merge `py_binary` targets during partial updates in
+ `file` generation mode. Fixed in [#2619](https://github.com/bazelbuild/rules_python/pull/2619).
{#v0-0-0-added}
### Added
diff --git a/gazelle/python/kinds.go b/gazelle/python/kinds.go
index a948337..7a0639a 100644
--- a/gazelle/python/kinds.go
+++ b/gazelle/python/kinds.go
@@ -32,7 +32,8 @@
var pyKinds = map[string]rule.KindInfo{
pyBinaryKind: {
- MatchAny: true,
+ MatchAny: false,
+ MatchAttrs: []string{"srcs"},
NonEmptyAttrs: map[string]bool{
"deps": true,
"main": true,
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/BUILD.in b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/BUILD.in
new file mode 100644
index 0000000..63b547f
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/BUILD.in
@@ -0,0 +1,9 @@
+load("@rules_python//python:defs.bzl", "py_binary")
+
+# gazelle:python_generation_mode file
+
+py_binary(
+ name = "a",
+ srcs = ["a.py"],
+ visibility = ["//:__subpackages__"],
+)
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/BUILD.out b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/BUILD.out
new file mode 100644
index 0000000..8f49ccc
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/BUILD.out
@@ -0,0 +1,15 @@
+load("@rules_python//python:defs.bzl", "py_binary")
+
+# gazelle:python_generation_mode file
+
+py_binary(
+ name = "a",
+ srcs = ["a.py"],
+ visibility = ["//:__subpackages__"],
+)
+
+py_binary(
+ name = "b",
+ srcs = ["b.py"],
+ visibility = ["//:__subpackages__"],
+)
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/README.md b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/README.md
new file mode 100644
index 0000000..5aa499f
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/README.md
@@ -0,0 +1,3 @@
+# Partial update with multiple per-file binaries
+
+This test case asserts that when there are multiple binaries in a package, and no __main__.py, and the BUILD file already includes a py_binary for one of the files, a py_binary is generated for the other file.
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/WORKSPACE b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/WORKSPACE
new file mode 100644
index 0000000..faff6af
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/WORKSPACE
@@ -0,0 +1 @@
+# This is a Bazel workspace for the Gazelle test data.
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/a.py b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/a.py
new file mode 100644
index 0000000..9c97da4
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/a.py
@@ -0,0 +1,2 @@
+if __name__ == "__main__":
+ print("Hello, world!")
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/b.py b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/b.py
new file mode 100644
index 0000000..9c97da4
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/b.py
@@ -0,0 +1,2 @@
+if __name__ == "__main__":
+ print("Hello, world!")
diff --git a/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/test.yaml b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/test.yaml
new file mode 100644
index 0000000..346ecd7
--- /dev/null
+++ b/gazelle/python/testdata/binary_without_entrypoint_per_file_generation_partial_update/test.yaml
@@ -0,0 +1,17 @@
+# Copyright 2025 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+expect:
+ exit_code: 0