Gazelle: Delete stale py_library and py_test targets (#3817)

## Summary
Fix the issue #3375
Right now the `py_library` and `py_test` targets with missing srcs
wouldn't be cleaned up by Gazelle in file mode.

This can be reproduced by a unit test added in commit
ad0e48ce0849ef9a0e89fb75a51345afb1947eb6


## Testing
In commit ad0e48ce0849ef9a0e89fb75a51345afb1947eb6 , without the fix,
`bazel test //python/...` failed in the newly added tests. The stale
`py_library ` and `py_test ` cannot be removed.

In latest HEAD d32fb7bb70a996e2d1215944a17941c5c4a708ed, `bazel test
//python/...` can pass

```
INFO: From Testing //python:python_test_remove_invalid_per_file:
==================== Test output for //python:python_test_remove_invalid_per_file:
--- FAIL: TestGazelleBinary (0.02s)
    --- FAIL: TestGazelleBinary/remove_invalid_per_file (0.08s)
        python_test.go:186: remove_invalid_per_file/BUILD diff (-want,+got):
              (
                """
                ... // 8 identical lines
                )
              
            +   py_library(
            +       name = "deleted_lib",
            +       srcs = ["deleted.py"],
            +       visibility = ["//:__subpackages__"],
            +   )
            + 
            +   py_test(
            +       name = "bar_test",
            +       srcs = ["bar_test.py"],
            +   )
            + 
                py_test(
            -       name = "bar_test",
            -       srcs = ["bar_test.py"],
            +       name = "deleted_test",
            +       srcs = ["deleted_test.py"],
                )
                """
              )
```

---------

Co-authored-by: Tao Wang <watao@microsoft.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43e05b6..978464f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -74,6 +74,11 @@
 
 {#v0-0-0-fixed}
 ### Fixed
+* (gazelle) `py_library` and `py_test` targets with missing source files can now be
+  removed by Gazelle ([#3375](https://github.com/bazel-contrib/rules_python/issues/3375)). 
+  However `map_kind` and `alias_kind` will not be removed unless people are running a 
+  gazelle version that includes 
+  [bazel-gazelle#2362](https://github.com/bazel-contrib/bazel-gazelle/pull/2362)
 * (bootstrap) Fixed a potential race condition with symlink creation during
   startup.
 * (gazelle) Fixed handling of auto-included `__init__.py` files when generating `py_binary`
diff --git a/gazelle/python/generate.go b/gazelle/python/generate.go
index 6fa6252..90e06a1 100644
--- a/gazelle/python/generate.go
+++ b/gazelle/python/generate.go
@@ -593,26 +593,53 @@
 		validFilesMap[file] = struct{}{}
 	}
 
+	// allFilesMap extends validFilesMap with all regular files on disk.
+	// py_binary uses validFilesMap (main modules + generated files), while py_library
+	// and py_test use allFilesMap since any file is a valid src for them.
+	allFilesMap := make(map[string]struct{}, len(validFilesMap)+len(args.RegularFiles))
+	for file := range validFilesMap {
+		allFilesMap[file] = struct{}{}
+	}
+	for _, file := range args.RegularFiles {
+		allFilesMap[file] = struct{}{}
+	}
+
 	isTarget := func(src string) bool {
 		return strings.HasPrefix(src, "@") || strings.HasPrefix(src, "//") || strings.HasPrefix(src, ":")
 	}
 	for _, existingRule := range args.File.Rules {
-		if !kindMatches(args.Config, existingRule, pyBinaryKind) {
+		var matchedKind string
+		var filesMap map[string]struct{}
+		if kindMatches(args.Config, existingRule, pyBinaryKind) {
+			matchedKind = pyBinaryKind
+			filesMap = validFilesMap
+		} else if kindMatches(args.Config, existingRule, pyLibraryKind) {
+			matchedKind = pyLibraryKind
+			filesMap = allFilesMap
+		} else if kindMatches(args.Config, existingRule, pyTestKind) {
+			matchedKind = pyTestKind
+			filesMap = allFilesMap
+		} else {
+			continue
+		}
+
+		srcs := existingRule.AttrStrings("srcs")
+		if len(srcs) == 0 {
 			continue
 		}
 		var hasValidSrcs bool
-		for _, src := range existingRule.AttrStrings("srcs") {
+		for _, src := range srcs {
 			if isTarget(src) {
 				hasValidSrcs = true
 				break
 			}
-			if _, ok := validFilesMap[src]; ok {
+			if _, ok := filesMap[src]; ok {
 				hasValidSrcs = true
 				break
 			}
 		}
 		if !hasValidSrcs {
-			invalidRules = append(invalidRules, newTargetBuilder(pyBinaryKind, existingRule.Name(), "", "", nil, false).build())
+			invalidRules = append(invalidRules, newTargetBuilder(matchedKind, existingRule.Name(), "", "", nil, false).build())
 		}
 	}
 	return invalidRules
diff --git a/gazelle/python/testdata/remove_invalid_library/BUILD.in b/gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.in
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/BUILD.in
rename to gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.in
diff --git a/gazelle/python/testdata/remove_invalid_library/BUILD.out b/gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.out
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/BUILD.out
rename to gazelle/python/testdata/remove_invalid_library_package_mode/BUILD.out
diff --git a/gazelle/python/testdata/remove_invalid_library/README.md b/gazelle/python/testdata/remove_invalid_library_package_mode/README.md
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/README.md
rename to gazelle/python/testdata/remove_invalid_library_package_mode/README.md
diff --git a/gazelle/python/testdata/remove_invalid_library/WORKSPACE b/gazelle/python/testdata/remove_invalid_library_package_mode/WORKSPACE
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/WORKSPACE
copy to gazelle/python/testdata/remove_invalid_library_package_mode/WORKSPACE
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_library_package_mode/my_test.py
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/my_test.py
rename to gazelle/python/testdata/remove_invalid_library_package_mode/my_test.py
diff --git a/gazelle/python/testdata/remove_invalid_library/others/BUILD.in b/gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.in
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/others/BUILD.in
rename to gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.in
diff --git a/gazelle/python/testdata/remove_invalid_library/others/BUILD.out b/gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.out
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/others/BUILD.out
rename to gazelle/python/testdata/remove_invalid_library_package_mode/others/BUILD.out
diff --git a/gazelle/python/testdata/remove_invalid_library/test.yaml b/gazelle/python/testdata/remove_invalid_library_package_mode/test.yaml
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/test.yaml
rename to gazelle/python/testdata/remove_invalid_library_package_mode/test.yaml
diff --git a/gazelle/python/testdata/remove_invalid_per_file/BUILD.in b/gazelle/python/testdata/remove_invalid_per_file/BUILD.in
new file mode 100644
index 0000000..d3b15a7
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/BUILD.in
@@ -0,0 +1,45 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
+
+# gazelle:python_generation_mode file
+
+# Valid py_library — bar.py exists on disk, should be kept.
+py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale py_library — deleted.py does not exist, should be removed.
+py_library(
+    name = "deleted_lib",
+    srcs = ["deleted.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Valid py_binary — __main__.py exists on disk, should be kept.
+py_binary(
+    name = "remove_invalid_per_file_bin",
+    srcs = ["__main__.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale py_binary — deleted_bin.py does not exist, should be removed.
+py_binary(
+    name = "deleted_bin",
+    srcs = ["deleted_bin.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Valid py_test — bar_test.py exists on disk, should be kept.
+py_test(
+    name = "bar_test",
+    srcs = ["bar_test.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale py_test — deleted_test.py does not exist, should be removed.
+py_test(
+    name = "deleted_test",
+    srcs = ["deleted_test.py"],
+    visibility = ["//:__subpackages__"],
+)
diff --git a/gazelle/python/testdata/remove_invalid_per_file/BUILD.out b/gazelle/python/testdata/remove_invalid_per_file/BUILD.out
new file mode 100644
index 0000000..376c14c
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/BUILD.out
@@ -0,0 +1,24 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
+
+# gazelle:python_generation_mode file
+
+# Valid py_library — bar.py exists on disk, should be kept.
+py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Valid py_test — bar_test.py exists on disk, should be kept.
+py_test(
+    name = "bar_test",
+    srcs = ["bar_test.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+py_binary(
+    name = "remove_invalid_per_file_bin",
+    srcs = ["__main__.py"],
+    main = "__main__.py",
+    visibility = ["//:__subpackages__"],
+)
diff --git a/gazelle/python/testdata/remove_invalid_library/WORKSPACE b/gazelle/python/testdata/remove_invalid_per_file/WORKSPACE
similarity index 100%
rename from gazelle/python/testdata/remove_invalid_library/WORKSPACE
rename to gazelle/python/testdata/remove_invalid_per_file/WORKSPACE
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/__main__.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/__main__.py
diff --git a/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in
new file mode 100644
index 0000000..e56ea16
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.in
@@ -0,0 +1,37 @@
+load(":mylib.bzl", "my_py_library", "my_py_test")
+
+# gazelle:python_generation_mode file
+# gazelle:alias_kind my_py_library py_library
+# gazelle:alias_kind my_py_test py_test
+
+# Valid aliased py_library — bar.py exists on disk, should be kept.
+my_py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale aliased py_library — deleted.py does not exist, should be removed.
+# TODO: Known limitation: not fully deleted until gazelle is bumped to include
+# https://github.com/bazel-contrib/bazel-gazelle/pull/2362
+my_py_library(
+    name = "deleted_lib",
+    srcs = ["deleted.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Valid aliased py_test — bar_test.py exists on disk, should be kept.
+my_py_test(
+    name = "bar_test",
+    srcs = ["bar_test.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale aliased py_test — deleted_test.py does not exist, should be removed.
+# TODO: Known limitation: not fully deleted until gazelle is bumped to include
+# https://github.com/bazel-contrib/bazel-gazelle/pull/2362
+my_py_test(
+    name = "deleted_test",
+    srcs = ["deleted_test.py"],
+    visibility = ["//:__subpackages__"],
+)
diff --git a/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out
new file mode 100644
index 0000000..19d6c01
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/BUILD.out
@@ -0,0 +1,35 @@
+load(":mylib.bzl", "my_py_library", "my_py_test")
+
+# gazelle:python_generation_mode file
+# gazelle:alias_kind my_py_library py_library
+# gazelle:alias_kind my_py_test py_test
+
+# Valid aliased py_library — bar.py exists on disk, should be kept.
+my_py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale aliased py_library — deleted.py does not exist, should be removed.
+# TODO: Known limitation: not fully deleted until gazelle is bumped to include
+# https://github.com/bazel-contrib/bazel-gazelle/pull/2362
+my_py_library(
+    name = "deleted_lib",
+    visibility = ["//:__subpackages__"],
+)
+
+# Valid aliased py_test — bar_test.py exists on disk, should be kept.
+my_py_test(
+    name = "bar_test",
+    srcs = ["bar_test.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale aliased py_test — deleted_test.py does not exist, should be removed.
+# TODO: Known limitation: not fully deleted until gazelle is bumped to include
+# https://github.com/bazel-contrib/bazel-gazelle/pull/2362
+my_py_test(
+    name = "deleted_test",
+    visibility = ["//:__subpackages__"],
+)
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar.py
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar_test.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/alias_kind/bar_test.py
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/bar.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/bar.py
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/bar_test.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/bar_test.py
diff --git a/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in
new file mode 100644
index 0000000..00b9a60
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.in
@@ -0,0 +1,37 @@
+load(":mylib.bzl", "my_py_binary", "my_py_test")
+
+# gazelle:python_generation_mode file
+# gazelle:map_kind py_binary my_py_binary :mylib.bzl
+# gazelle:map_kind py_test my_py_test :mylib.bzl
+
+# Valid py_library — bar.py exists on disk, should be kept.
+py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale py_library — deleted.py does not exist, should be removed.
+py_library(
+    name = "deleted_lib",
+    srcs = ["deleted.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Stale mapped py_binary — deleted_bin.py does not exist, should be removed.
+my_py_binary(
+    name = "deleted_bin",
+    srcs = ["deleted_bin.py"],
+)
+
+# Valid mapped py_test — bar_test.py exists on disk, should be kept.
+my_py_test(
+    name = "bar_test",
+    srcs = ["bar_test.py"],
+)
+
+# Stale mapped py_test — deleted_test.py does not exist, should be removed.
+my_py_test(
+    name = "deleted_test",
+    srcs = ["deleted_test.py"],
+)
diff --git a/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out
new file mode 100644
index 0000000..84ca905
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/map_kind/BUILD.out
@@ -0,0 +1,19 @@
+load("@rules_python//python:defs.bzl", "py_library")
+load(":mylib.bzl", "my_py_test")
+
+# gazelle:python_generation_mode file
+# gazelle:map_kind py_binary my_py_binary :mylib.bzl
+# gazelle:map_kind py_test my_py_test :mylib.bzl
+
+# Valid py_library — bar.py exists on disk, should be kept.
+py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+# Valid mapped py_test — bar_test.py exists on disk, should be kept.
+my_py_test(
+    name = "bar_test",
+    srcs = ["bar_test.py"],
+)
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/map_kind/bar.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/map_kind/bar.py
diff --git a/gazelle/python/testdata/remove_invalid_library/my_test.py b/gazelle/python/testdata/remove_invalid_per_file/map_kind/bar_test.py
similarity index 100%
copy from gazelle/python/testdata/remove_invalid_library/my_test.py
copy to gazelle/python/testdata/remove_invalid_per_file/map_kind/bar_test.py
diff --git a/gazelle/python/testdata/remove_invalid_per_file/test.yaml b/gazelle/python/testdata/remove_invalid_per_file/test.yaml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/gazelle/python/testdata/remove_invalid_per_file/test.yaml
@@ -0,0 +1 @@
+---