feat(gazelle): Add "include_dep" Python comment annotation (#1863)

Add a new Python comment annotation for Gazelle: `include_dep`. This
annotation accepts a comma-separated string of values. Values _should_
be targets names, but no validation is done.

The annotation can be added multiple times, and all values are combined
and de-duplicated.

For `python_generation_mode = "package"`, the `include_dep` annotations
found across all files included in the generated target.

The `parser.annotations` struct is updated to include a new `includeDep`
field, and `parser.parse` is updated to return the `annotations` struct.
All
target builders then add the resolved dependencies.

Fixes #1862.

Example:

```python
# gazelle:include_dep //foo:bar,:hello_world,//:abc
# gazelle:include_dep //:def,//foo:bar
```

will cause gazelle to generate:

```starlark
deps = [
    ":hello_world",
    "//:abc",
    "//:def",
    "//foo:bar",
]
```

---------

Co-authored-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 415b936..449dae0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,8 @@
   the whl and sdist files will be written to the lock file. Controlling whether
   the downloading of metadata is done in parallel can be done using
   `parallel_download` attribute.
+* (gazelle) Add a new annotation `include_deps`. Also add documentation for
+  annotations to `gazelle/README.md`.
 * (deps): `rules_python` depends now on `rules_cc` 0.0.9
 * (pip_parse): A new flag `use_hub_alias_dependencies` has been added that is going
   to become default in the next release. This makes use of `dep_template` flag
diff --git a/gazelle/README.md b/gazelle/README.md
index 4c1cb27..e7b1766 100644
--- a/gazelle/README.md
+++ b/gazelle/README.md
@@ -425,6 +425,108 @@
 [issue-1826]: https://github.com/bazelbuild/rules_python/issues/1826
 
 
+### Annotations
+
+*Annotations* refer to comments found _within Python files_ that configure how
+Gazelle acts for that particular file.
+
+Annotations have the form:
+
+```python
+# gazelle:annotation_name value
+```
+
+and can reside anywhere within a Python file where comments are valid. For example:
+
+```python
+import foo
+# gazelle:annotation_name value
+
+def bar():  # gazelle:annotation_name value
+    pass
+```
+
+The annotations are:
+
+| **Annotation**                                                | **Default value** |
+|---------------------------------------------------------------|-------------------|
+| [`# gazelle:ignore imports`](#annotation-ignore)              | N/A               |
+| Tells Gazelle to ignore import statements. `imports` is a comma-separated list of imports to ignore. | |
+| [`# gazelle:include_dep targets`](#annotation-include_dep)    | N/A               |
+| Tells Gazelle to include a set of dependencies, even if they are not imported in a Python module. `targets` is a comma-separated list of target names to include as dependencies. | |
+
+
+#### Annotation: `ignore`
+
+This annotation accepts a comma-separated string of values. Values are names of Python
+imports that Gazelle should _not_ include in target dependencies.
+
+The annotation can be added multiple times, and all values are combined and
+de-duplicated.
+
+For `python_generation_mode = "package"`, the `ignore` annotations
+found across all files included in the generated target are removed from `deps`.
+
+Example:
+
+```python
+import numpy  # a pypi package
+
+# gazelle:ignore bar.baz.hello,foo
+import bar.baz.hello
+import foo
+
+# Ignore this import because _reasons_
+import baz  # gazelle:ignore baz
+```
+
+will cause Gazelle to generate:
+
+```starlark
+deps = ["@pypi//numpy"],
+```
+
+
+#### Annotation: `include_dep`
+
+This annotation accepts a comma-separated string of values. Values _must_
+be Python targets, but _no validation is done_. If a value is not a Python
+target, building will result in an error saying:
+
+```
+<target> does not have mandatory providers: 'PyInfo' or 'CcInfo' or 'PyInfo'.
+```
+
+Adding non-Python targets to the generated target is a feature request being
+tracked in [Issue #1865](https://github.com/bazelbuild/rules_python/issues/1865).
+
+The annotation can be added multiple times, and all values are combined
+and de-duplicated.
+
+For `python_generation_mode = "package"`, the `include_dep` annotations
+found across all files included in the generated target are included in `deps`.
+
+Example:
+
+```python
+# gazelle:include_dep //foo:bar,:hello_world,//:abc
+# gazelle:include_dep //:def,//foo:bar
+import numpy  # a pypi package
+```
+
+will cause Gazelle to generate:
+
+```starlark
+deps = [
+    ":hello_world",
+    "//:abc",
+    "//:def",
+    "//foo:bar",
+    "@pypi//numpy",
+]
+```
+
+
 ### Libraries
 
 Python source files are those ending in `.py` but not ending in `_test.py`.
diff --git a/gazelle/python/generate.go b/gazelle/python/generate.go
index 1937831..8889438 100644
--- a/gazelle/python/generate.go
+++ b/gazelle/python/generate.go
@@ -233,7 +233,7 @@
 	collisionErrors := singlylinkedlist.New()
 
 	appendPyLibrary := func(srcs *treeset.Set, pyLibraryTargetName string) {
-		allDeps, mainModules, err := parser.parse(srcs)
+		allDeps, mainModules, annotations, err := parser.parse(srcs)
 		if err != nil {
 			log.Fatalf("ERROR: %v\n", err)
 		}
@@ -263,6 +263,7 @@
 					addVisibility(visibility).
 					addSrc(filename).
 					addModuleDependencies(mainModules[filename]).
+					addResolvedDependencies(annotations.includeDeps).
 					generateImportsAttribute().build()
 				result.Gen = append(result.Gen, pyBinary)
 				result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey))
@@ -290,6 +291,7 @@
 			addVisibility(visibility).
 			addSrcs(srcs).
 			addModuleDependencies(allDeps).
+			addResolvedDependencies(annotations.includeDeps).
 			generateImportsAttribute().
 			build()
 
@@ -314,7 +316,7 @@
 	}
 
 	if hasPyBinaryEntryPointFile {
-		deps, _, err := parser.parseSingle(pyBinaryEntrypointFilename)
+		deps, _, annotations, err := parser.parseSingle(pyBinaryEntrypointFilename)
 		if err != nil {
 			log.Fatalf("ERROR: %v\n", err)
 		}
@@ -338,6 +340,7 @@
 			addVisibility(visibility).
 			addSrc(pyBinaryEntrypointFilename).
 			addModuleDependencies(deps).
+			addResolvedDependencies(annotations.includeDeps).
 			generateImportsAttribute()
 
 		pyBinary := pyBinaryTarget.build()
@@ -348,7 +351,7 @@
 
 	var conftest *rule.Rule
 	if hasConftestFile {
-		deps, _, err := parser.parseSingle(conftestFilename)
+		deps, _, annotations, err := parser.parseSingle(conftestFilename)
 		if err != nil {
 			log.Fatalf("ERROR: %v\n", err)
 		}
@@ -367,6 +370,7 @@
 		conftestTarget := newTargetBuilder(pyLibraryKind, conftestTargetname, pythonProjectRoot, args.Rel, pyFileNames).
 			addSrc(conftestFilename).
 			addModuleDependencies(deps).
+			addResolvedDependencies(annotations.includeDeps).
 			addVisibility(visibility).
 			setTestonly().
 			generateImportsAttribute()
@@ -379,7 +383,7 @@
 
 	var pyTestTargets []*targetBuilder
 	newPyTestTargetBuilder := func(srcs *treeset.Set, pyTestTargetName string) *targetBuilder {
-		deps, _, err := parser.parse(srcs)
+		deps, _, annotations, err := parser.parse(srcs)
 		if err != nil {
 			log.Fatalf("ERROR: %v\n", err)
 		}
@@ -397,6 +401,7 @@
 		return newTargetBuilder(pyTestKind, pyTestTargetName, pythonProjectRoot, args.Rel, pyFileNames).
 			addSrcs(srcs).
 			addModuleDependencies(deps).
+			addResolvedDependencies(annotations.includeDeps).
 			generateImportsAttribute()
 	}
 	if (hasPyTestEntryPointFile || hasPyTestEntryPointTarget || cfg.CoarseGrainedGeneration()) && !cfg.PerFileGeneration() {
diff --git a/gazelle/python/parser.go b/gazelle/python/parser.go
index 9b00b83..184fad7 100644
--- a/gazelle/python/parser.go
+++ b/gazelle/python/parser.go
@@ -101,7 +101,7 @@
 
 // parseSingle parses a single Python file and returns the extracted modules
 // from the import statements as well as the parsed comments.
-func (p *python3Parser) parseSingle(pyFilename string) (*treeset.Set, map[string]*treeset.Set, error) {
+func (p *python3Parser) parseSingle(pyFilename string) (*treeset.Set, map[string]*treeset.Set, *annotations, error) {
 	pyFilenames := treeset.NewWith(godsutils.StringComparator)
 	pyFilenames.Add(pyFilename)
 	return p.parse(pyFilenames)
@@ -109,7 +109,7 @@
 
 // parse parses multiple Python files and returns the extracted modules from
 // the import statements as well as the parsed comments.
-func (p *python3Parser) parse(pyFilenames *treeset.Set) (*treeset.Set, map[string]*treeset.Set, error) {
+func (p *python3Parser) parse(pyFilenames *treeset.Set) (*treeset.Set, map[string]*treeset.Set, *annotations, error) {
 	parserMutex.Lock()
 	defer parserMutex.Unlock()
 
@@ -122,28 +122,30 @@
 	}
 	encoder := json.NewEncoder(parserStdin)
 	if err := encoder.Encode(&req); err != nil {
-		return nil, nil, fmt.Errorf("failed to parse: %w", err)
+		return nil, nil, nil, fmt.Errorf("failed to parse: %w", err)
 	}
 
 	reader := bufio.NewReader(parserStdout)
 	data, err := reader.ReadBytes(0)
 	if err != nil {
-		return nil, nil, fmt.Errorf("failed to parse: %w", err)
+		return nil, nil, nil, fmt.Errorf("failed to parse: %w", err)
 	}
 	data = data[:len(data)-1]
 	var allRes []parserResponse
 	if err := json.Unmarshal(data, &allRes); err != nil {
-		return nil, nil, fmt.Errorf("failed to parse: %w", err)
+		return nil, nil, nil, fmt.Errorf("failed to parse: %w", err)
 	}
 
 	mainModules := make(map[string]*treeset.Set, len(allRes))
+	allAnnotations := new(annotations)
+	allAnnotations.ignore = make(map[string]struct{})
 	for _, res := range allRes {
 		if res.HasMain {
 			mainModules[res.FileName] = treeset.NewWith(moduleComparator)
 		}
 		annotations, err := annotationsFromComments(res.Comments)
 		if err != nil {
-			return nil, nil, fmt.Errorf("failed to parse annotations: %w", err)
+			return nil, nil, nil, fmt.Errorf("failed to parse annotations: %w", err)
 		}
 
 		for _, m := range res.Modules {
@@ -164,9 +166,32 @@
 				mainModules[res.FileName].Add(m)
 			}
 		}
+
+		// Collect all annotations from each file into a single annotations struct.
+		for k, v := range annotations.ignore {
+			allAnnotations.ignore[k] = v
+		}
+		allAnnotations.includeDeps = append(allAnnotations.includeDeps, annotations.includeDeps...)
 	}
 
-	return modules, mainModules, nil
+	allAnnotations.includeDeps = removeDupesFromStringTreeSetSlice(allAnnotations.includeDeps)
+
+	return modules, mainModules, allAnnotations, nil
+}
+
+// removeDupesFromStringTreeSetSlice takes a []string, makes a set out of the
+// elements, and then returns a new []string with all duplicates removed. Order
+// is preserved.
+func removeDupesFromStringTreeSetSlice(array []string) []string {
+	s := treeset.NewWith(godsutils.StringComparator)
+	for _, v := range array {
+		s.Add(v)
+	}
+	dedupe := make([]string, s.Size())
+	for i, v := range s.Values() {
+		dedupe[i] = fmt.Sprint(v)
+	}
+	return dedupe
 }
 
 // parserResponse represents a response returned by the parser.py for a given
@@ -211,7 +236,8 @@
 	// The Gazelle annotation prefix.
 	annotationPrefix string = "gazelle:"
 	// The ignore annotation kind. E.g. '# gazelle:ignore <module_name>'.
-	annotationKindIgnore annotationKind = "ignore"
+	annotationKindIgnore     annotationKind = "ignore"
+	annotationKindIncludeDep annotationKind = "include_dep"
 )
 
 // comment represents a Python comment.
@@ -247,12 +273,15 @@
 type annotations struct {
 	// The parsed modules to be ignored by Gazelle.
 	ignore map[string]struct{}
+	// Labels that Gazelle should include as deps of the generated target.
+	includeDeps []string
 }
 
 // annotationsFromComments returns all the annotations parsed out of the
 // comments of a Python module.
 func annotationsFromComments(comments []comment) (*annotations, error) {
 	ignore := make(map[string]struct{})
+	includeDeps := []string{}
 	for _, comment := range comments {
 		annotation, err := comment.asAnnotation()
 		if err != nil {
@@ -269,10 +298,21 @@
 					ignore[m] = struct{}{}
 				}
 			}
+			if annotation.kind == annotationKindIncludeDep {
+				targets := strings.Split(annotation.value, ",")
+				for _, t := range targets {
+					if t == "" {
+						continue
+					}
+					t = strings.TrimSpace(t)
+					includeDeps = append(includeDeps, t)
+				}
+			}
 		}
 	}
 	return &annotations{
-		ignore: ignore,
+		ignore:      ignore,
+		includeDeps: includeDeps,
 	}, nil
 }
 
diff --git a/gazelle/python/target.go b/gazelle/python/target.go
index a941a7c..c40d6fb 100644
--- a/gazelle/python/target.go
+++ b/gazelle/python/target.go
@@ -99,6 +99,15 @@
 	return t
 }
 
+// addResolvedDependencies adds multiple dependencies, that have already been
+// resolved or generated, to the target.
+func (t *targetBuilder) addResolvedDependencies(deps []string) *targetBuilder {
+	for _, dep := range deps {
+		t.addResolvedDependency(dep)
+	}
+	return t
+}
+
 // addVisibility adds visibility labels to the target.
 func (t *targetBuilder) addVisibility(visibility []string) *targetBuilder {
 	for _, item := range visibility {
diff --git a/gazelle/python/testdata/annotation_include_dep/BUILD.in b/gazelle/python/testdata/annotation_include_dep/BUILD.in
new file mode 100644
index 0000000..af2c2ce
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/BUILD.in
@@ -0,0 +1 @@
+# gazelle:python_generation_mode file
diff --git a/gazelle/python/testdata/annotation_include_dep/BUILD.out b/gazelle/python/testdata/annotation_include_dep/BUILD.out
new file mode 100644
index 0000000..1cff8f4
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/BUILD.out
@@ -0,0 +1,53 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
+
+# gazelle:python_generation_mode file
+
+py_library(
+    name = "__init__",
+    srcs = ["__init__.py"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":module1",
+        ":module2",
+        "//foo/bar:baz",
+        "//hello:world",
+        "@gazelle_python_test//foo",
+        "@star_wars//rebel_alliance/luke:skywalker",
+    ],
+)
+
+py_library(
+    name = "module1",
+    srcs = ["module1.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+py_library(
+    name = "module2",
+    srcs = ["module2.py"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "//checking/py_binary/from/if:works",
+        "//foo:bar",
+    ],
+)
+
+py_binary(
+    name = "annotation_include_dep_bin",
+    srcs = ["__main__.py"],
+    main = "__main__.py",
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":module2",
+        "//checking/py_binary/from/__main__:works",
+    ],
+)
+
+py_test(
+    name = "module2_test",
+    srcs = ["module2_test.py"],
+    deps = [
+        ":module2",
+        "//checking/py_test/works:too",
+    ],
+)
diff --git a/gazelle/python/testdata/annotation_include_dep/README.md b/gazelle/python/testdata/annotation_include_dep/README.md
new file mode 100644
index 0000000..4c8afbe
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/README.md
@@ -0,0 +1,10 @@
+# Annotation: Include Dep
+
+Test that the Python gazelle annotation `# gazelle:include_dep` correctly adds dependences
+to the generated target even if those dependencies are not imported by the Python module.
+
+The root directory tests that all `py_*` targets will correctly include the additional
+dependencies.
+
+The `subpkg` directory tests that all `# gazlle:include_dep` annotations found in all source
+files are included in the generated target (such as during `generation_mode package`).
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.in b/gazelle/python/testdata/annotation_include_dep/WORKSPACE
similarity index 100%
copy from gazelle/python/testdata/invalid_annotation/BUILD.in
copy to gazelle/python/testdata/annotation_include_dep/WORKSPACE
diff --git a/gazelle/python/testdata/annotation_include_dep/__init__.py b/gazelle/python/testdata/annotation_include_dep/__init__.py
new file mode 100644
index 0000000..6101534
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/__init__.py
@@ -0,0 +1,9 @@
+import module1
+import foo  # third party package
+
+# gazelle:include_dep //foo/bar:baz
+# gazelle:include_dep //hello:world,@star_wars//rebel_alliance/luke:skywalker
+# gazelle:include_dep :module2
+
+del module1
+del foo
diff --git a/gazelle/python/testdata/annotation_include_dep/__main__.py b/gazelle/python/testdata/annotation_include_dep/__main__.py
new file mode 100644
index 0000000..6d9d8aa
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/__main__.py
@@ -0,0 +1,7 @@
+# gazelle:include_dep //checking/py_binary/from/__main__:works
+# Check deduping
+# gazelle:include_dep //checking/py_binary/from/__main__:works
+
+import module2
+
+del module2
diff --git a/gazelle/python/testdata/annotation_include_dep/gazelle_python.yaml b/gazelle/python/testdata/annotation_include_dep/gazelle_python.yaml
new file mode 100644
index 0000000..7afe81f
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/gazelle_python.yaml
@@ -0,0 +1,18 @@
+# Copyright 2024 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.
+
+manifest:
+  modules_mapping:
+    foo: foo
+  pip_deps_repository_name: gazelle_python_test
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.in b/gazelle/python/testdata/annotation_include_dep/module1.py
similarity index 100%
copy from gazelle/python/testdata/invalid_annotation/BUILD.in
copy to gazelle/python/testdata/annotation_include_dep/module1.py
diff --git a/gazelle/python/testdata/annotation_include_dep/module2.py b/gazelle/python/testdata/annotation_include_dep/module2.py
new file mode 100644
index 0000000..23a75af
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/module2.py
@@ -0,0 +1,5 @@
+# gazelle:include_dep //foo:bar
+
+if __name__ == "__main__":
+    # gazelle:include_dep //checking/py_binary/from/if:works
+    print("hello")
diff --git a/gazelle/python/testdata/annotation_include_dep/module2_test.py b/gazelle/python/testdata/annotation_include_dep/module2_test.py
new file mode 100644
index 0000000..6fa18c6
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/module2_test.py
@@ -0,0 +1,5 @@
+# gazelle:include_dep //checking/py_test/works:too
+
+import module2
+
+del module2
diff --git a/gazelle/python/testdata/annotation_include_dep/subpkg/BUILD.in b/gazelle/python/testdata/annotation_include_dep/subpkg/BUILD.in
new file mode 100644
index 0000000..421b486
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/subpkg/BUILD.in
@@ -0,0 +1 @@
+# gazelle:python_generation_mode package
diff --git a/gazelle/python/testdata/annotation_include_dep/subpkg/BUILD.out b/gazelle/python/testdata/annotation_include_dep/subpkg/BUILD.out
new file mode 100644
index 0000000..921c892
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/subpkg/BUILD.out
@@ -0,0 +1,29 @@
+load("@rules_python//python:defs.bzl", "py_library", "py_test")
+
+# gazelle:python_generation_mode package
+
+py_library(
+    name = "subpkg",
+    srcs = [
+        "__init__.py",
+        "module1.py",
+        "module2.py",
+        "module3.py",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":nonexistant_target_from_include_dep_in_module3",
+        "//me_from_module1",
+        "//other/thing:from_include_dep_in_module2",
+        "//you_from_module1",
+    ],
+)
+
+py_test(
+    name = "module1_test",
+    srcs = ["module1_test.py"],
+    deps = [
+        ":subpkg",
+        "//:bagel_from_include_dep_in_module1_test",
+    ],
+)
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.in b/gazelle/python/testdata/annotation_include_dep/subpkg/__init__.py
similarity index 100%
copy from gazelle/python/testdata/invalid_annotation/BUILD.in
copy to gazelle/python/testdata/annotation_include_dep/subpkg/__init__.py
diff --git a/gazelle/python/testdata/annotation_include_dep/subpkg/module1.py b/gazelle/python/testdata/annotation_include_dep/subpkg/module1.py
new file mode 100644
index 0000000..01566a0
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/subpkg/module1.py
@@ -0,0 +1,3 @@
+def hello():
+    # gazelle:include_dep //you_from_module1,//me_from_module1
+    pass
diff --git a/gazelle/python/testdata/annotation_include_dep/subpkg/module1_test.py b/gazelle/python/testdata/annotation_include_dep/subpkg/module1_test.py
new file mode 100644
index 0000000..087763a
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/subpkg/module1_test.py
@@ -0,0 +1,5 @@
+# gazelle:include_dep //:bagel_from_include_dep_in_module1_test
+
+import module1
+
+del module1
diff --git a/gazelle/python/testdata/annotation_include_dep/subpkg/module2.py b/gazelle/python/testdata/annotation_include_dep/subpkg/module2.py
new file mode 100644
index 0000000..dabeb67
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/subpkg/module2.py
@@ -0,0 +1,4 @@
+# gazelle:include_dep //other/thing:from_include_dep_in_module2
+import module1
+
+del module1
diff --git a/gazelle/python/testdata/annotation_include_dep/subpkg/module3.py b/gazelle/python/testdata/annotation_include_dep/subpkg/module3.py
new file mode 100644
index 0000000..899a7c4
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/subpkg/module3.py
@@ -0,0 +1,3 @@
+def goodbye():
+    # gazelle:include_dep :nonexistant_target_from_include_dep_in_module3
+    pass
diff --git a/gazelle/python/testdata/annotation_include_dep/test.yaml b/gazelle/python/testdata/annotation_include_dep/test.yaml
new file mode 100644
index 0000000..2410223
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_dep/test.yaml
@@ -0,0 +1,17 @@
+# Copyright 2023 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
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.in b/gazelle/python/testdata/invalid_annotation_exclude/BUILD.in
similarity index 100%
rename from gazelle/python/testdata/invalid_annotation/BUILD.in
rename to gazelle/python/testdata/invalid_annotation_exclude/BUILD.in
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.out b/gazelle/python/testdata/invalid_annotation_exclude/BUILD.out
similarity index 100%
rename from gazelle/python/testdata/invalid_annotation/BUILD.out
rename to gazelle/python/testdata/invalid_annotation_exclude/BUILD.out
diff --git a/gazelle/python/testdata/invalid_annotation/README.md b/gazelle/python/testdata/invalid_annotation_exclude/README.md
similarity index 100%
rename from gazelle/python/testdata/invalid_annotation/README.md
rename to gazelle/python/testdata/invalid_annotation_exclude/README.md
diff --git a/gazelle/python/testdata/invalid_annotation/WORKSPACE b/gazelle/python/testdata/invalid_annotation_exclude/WORKSPACE
similarity index 100%
rename from gazelle/python/testdata/invalid_annotation/WORKSPACE
rename to gazelle/python/testdata/invalid_annotation_exclude/WORKSPACE
diff --git a/gazelle/python/testdata/invalid_annotation/__init__.py b/gazelle/python/testdata/invalid_annotation_exclude/__init__.py
similarity index 100%
rename from gazelle/python/testdata/invalid_annotation/__init__.py
rename to gazelle/python/testdata/invalid_annotation_exclude/__init__.py
diff --git a/gazelle/python/testdata/invalid_annotation/test.yaml b/gazelle/python/testdata/invalid_annotation_exclude/test.yaml
similarity index 100%
rename from gazelle/python/testdata/invalid_annotation/test.yaml
rename to gazelle/python/testdata/invalid_annotation_exclude/test.yaml
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.in b/gazelle/python/testdata/invalid_annotation_include_dep/BUILD.in
similarity index 100%
copy from gazelle/python/testdata/invalid_annotation/BUILD.in
copy to gazelle/python/testdata/invalid_annotation_include_dep/BUILD.in
diff --git a/gazelle/python/testdata/invalid_annotation/BUILD.out b/gazelle/python/testdata/invalid_annotation_include_dep/BUILD.out
similarity index 100%
copy from gazelle/python/testdata/invalid_annotation/BUILD.out
copy to gazelle/python/testdata/invalid_annotation_include_dep/BUILD.out
diff --git a/gazelle/python/testdata/invalid_annotation_include_dep/README.md b/gazelle/python/testdata/invalid_annotation_include_dep/README.md
new file mode 100644
index 0000000..2f8e024
--- /dev/null
+++ b/gazelle/python/testdata/invalid_annotation_include_dep/README.md
@@ -0,0 +1,3 @@
+# Invalid  annotation
+This test case asserts that the parse step fails as expected due to invalid annotation format of
+the `include_dep` annotation.
diff --git a/gazelle/python/testdata/invalid_annotation/WORKSPACE b/gazelle/python/testdata/invalid_annotation_include_dep/WORKSPACE
similarity index 100%
copy from gazelle/python/testdata/invalid_annotation/WORKSPACE
copy to gazelle/python/testdata/invalid_annotation_include_dep/WORKSPACE
diff --git a/gazelle/python/testdata/invalid_annotation_include_dep/__init__.py b/gazelle/python/testdata/invalid_annotation_include_dep/__init__.py
new file mode 100644
index 0000000..61f4c76
--- /dev/null
+++ b/gazelle/python/testdata/invalid_annotation_include_dep/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2024 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.
+
+# gazelle:include_dep
diff --git a/gazelle/python/testdata/invalid_annotation_include_dep/test.yaml b/gazelle/python/testdata/invalid_annotation_include_dep/test.yaml
new file mode 100644
index 0000000..f2159a6
--- /dev/null
+++ b/gazelle/python/testdata/invalid_annotation_include_dep/test.yaml
@@ -0,0 +1,19 @@
+# Copyright 2024 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: 1
+  stderr: |
+    gazelle: ERROR: failed to parse annotations: `# gazelle:include_dep` requires a value