feat(gazelle): Add type-checking only dependencies to pyi_deps (#3014)

https://github.com/bazel-contrib/rules_python/pull/2538 added the
attribute `pyi_deps` to python rules, intended to be used for
dependencies that are only used for type-checking purposes. This PR adds
a new directive, `gazelle:python_generate_pyi_deps`, which, when
enabled:

- When a dependency is added only to satisfy type-checking only imports
(in a `if TYPE_CHECKING:` block), the dependency is added to `pyi_deps`
instead of `deps`;
- Third-party stub packages (eg. `boto3-stubs`) are now added to
`pyi_deps` instead of `deps`.

---------

Co-authored-by: Douglas Thor <dougthor42@users.noreply.github.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4facff4..78a3d1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -70,6 +70,9 @@
 * (pypi) To configure the environment for `requirements.txt` evaluation, use the newly added
   developer preview of the `pip.default` tag class. Only `rules_python` and root modules can use
   this feature. You can also configure custom `config_settings` using `pip.default`.
+* (gazelle) New directive `gazelle:python_generate_pyi_deps`; when `true`,
+  dependencies added to satisfy type-only imports (`if TYPE_CHECKING`) and type
+  stub packages are added to `pyi_deps` instead of `deps`.
 
 {#v0-0-0-removed}
 ### Removed
diff --git a/gazelle/README.md b/gazelle/README.md
index 58ec55e..5c63e21 100644
--- a/gazelle/README.md
+++ b/gazelle/README.md
@@ -222,6 +222,8 @@
 | Controls how distribution names in labels to third-party deps are normalized. Useful for using Gazelle plugin with other rules with different label conventions (e.g. `rules_pycross` uses PEP-503). Can be "snake_case", "none", or "pep503".                                                  |
 | `# gazelle:experimental_allow_relative_imports`          | `false` |
 | Controls whether Gazelle resolves dependencies for import statements that use paths relative to the current package. Can be "true" or "false".|
+| `# gazelle:python_generate_pyi_deps`                                                                                                                                                                                                                                                            | `false` |
+| Controls whether to generate a separate `pyi_deps` attribute for type-checking dependencies or merge them into the regular `deps` attribute. When `false` (default), type-checking dependencies are merged into `deps` for backward compatibility. When `true`, generates separate `pyi_deps`. Imports in blocks with the format `if typing.TYPE_CHECKING:`/`if TYPE_CHECKING:` and type-only stub packages (eg. boto3-stubs) are recognized as type-checking dependencies. |
 
 #### Directive: `python_root`:
 
diff --git a/gazelle/python/configure.go b/gazelle/python/configure.go
index ae0f7ee..db80fc1 100644
--- a/gazelle/python/configure.go
+++ b/gazelle/python/configure.go
@@ -68,6 +68,7 @@
 		pythonconfig.TestFilePattern,
 		pythonconfig.LabelConvention,
 		pythonconfig.LabelNormalization,
+		pythonconfig.GeneratePyiDeps,
 		pythonconfig.ExperimentalAllowRelativeImports,
 	}
 }
@@ -230,6 +231,12 @@
 					pythonconfig.ExperimentalAllowRelativeImports, rel, d.Value)
 			}
 			config.SetExperimentalAllowRelativeImports(v)
+		case pythonconfig.GeneratePyiDeps:
+			v, err := strconv.ParseBool(strings.TrimSpace(d.Value))
+			if err != nil {
+				log.Fatal(err)
+			}
+			config.SetGeneratePyiDeps(v)
 		}
 	}
 
diff --git a/gazelle/python/file_parser.go b/gazelle/python/file_parser.go
index cb82cb9..aca925c 100644
--- a/gazelle/python/file_parser.go
+++ b/gazelle/python/file_parser.go
@@ -47,9 +47,10 @@
 }
 
 type FileParser struct {
-	code        []byte
-	relFilepath string
-	output      ParserOutput
+	code                 []byte
+	relFilepath          string
+	output               ParserOutput
+	inTypeCheckingBlock  bool
 }
 
 func NewFileParser() *FileParser {
@@ -158,6 +159,7 @@
 				continue
 			}
 			m.Filepath = p.relFilepath
+			m.TypeCheckingOnly = p.inTypeCheckingBlock
 			if strings.HasPrefix(m.Name, ".") {
 				continue
 			}
@@ -178,6 +180,7 @@
 			m.Filepath = p.relFilepath
 			m.From = from
 			m.Name = fmt.Sprintf("%s.%s", from, m.Name)
+			m.TypeCheckingOnly = p.inTypeCheckingBlock
 			p.output.Modules = append(p.output.Modules, m)
 		}
 	} else {
@@ -202,10 +205,43 @@
 	p.output.FileName = filename
 }
 
+// isTypeCheckingBlock returns true if the given node is an `if TYPE_CHECKING:` block.
+func (p *FileParser) isTypeCheckingBlock(node *sitter.Node) bool {
+	if node.Type() != sitterNodeTypeIfStatement || node.ChildCount() < 2 {
+		return false
+	}
+
+	condition := node.Child(1)
+
+	// Handle `if TYPE_CHECKING:`
+	if condition.Type() == sitterNodeTypeIdentifier && condition.Content(p.code) == "TYPE_CHECKING" {
+		return true
+	}
+
+	// Handle `if typing.TYPE_CHECKING:`
+	if condition.Type() == "attribute" && condition.ChildCount() >= 3 {
+		object := condition.Child(0)
+		attr := condition.Child(2)
+		if object.Type() == sitterNodeTypeIdentifier && object.Content(p.code) == "typing" &&
+			attr.Type() == sitterNodeTypeIdentifier && attr.Content(p.code) == "TYPE_CHECKING" {
+			return true
+		}
+	}
+
+	return false
+}
+
 func (p *FileParser) parse(ctx context.Context, node *sitter.Node) {
 	if node == nil {
 		return
 	}
+
+	// Check if this is a TYPE_CHECKING block
+	wasInTypeCheckingBlock := p.inTypeCheckingBlock
+	if p.isTypeCheckingBlock(node) {
+		p.inTypeCheckingBlock = true
+	}
+
 	for i := 0; i < int(node.ChildCount()); i++ {
 		if err := ctx.Err(); err != nil {
 			return
@@ -219,6 +255,9 @@
 		}
 		p.parse(ctx, child)
 	}
+
+	// Restore the previous state
+	p.inTypeCheckingBlock = wasInTypeCheckingBlock
 }
 
 func (p *FileParser) Parse(ctx context.Context) (*ParserOutput, error) {
diff --git a/gazelle/python/file_parser_test.go b/gazelle/python/file_parser_test.go
index 20085f0..f4db1a3 100644
--- a/gazelle/python/file_parser_test.go
+++ b/gazelle/python/file_parser_test.go
@@ -254,3 +254,40 @@
 		FileName: "a.py",
 	}, *output)
 }
+
+func TestTypeCheckingImports(t *testing.T) {
+	code := `
+import sys
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    import boto3
+    from rest_framework import serializers
+
+def example_function():
+    _ = sys.version_info
+`
+	p := NewFileParser()
+	p.SetCodeAndFile([]byte(code), "", "test.py")
+
+	result, err := p.Parse(context.Background())
+	if err != nil {
+		t.Fatalf("Failed to parse: %v", err)
+	}
+
+	// Check that we found the expected modules
+	expectedModules := map[string]bool{
+		"sys": false,
+		"typing.TYPE_CHECKING": false,
+		"boto3": true,
+		"rest_framework.serializers": true,
+	}
+
+	for _, mod := range result.Modules {
+		if expected, exists := expectedModules[mod.Name]; exists {
+			if mod.TypeCheckingOnly != expected {
+				t.Errorf("Module %s: expected TypeCheckingOnly=%v, got %v", mod.Name, expected, mod.TypeCheckingOnly)
+			}
+		}
+	}
+}
diff --git a/gazelle/python/parser.go b/gazelle/python/parser.go
index cf80578..11e01db 100644
--- a/gazelle/python/parser.go
+++ b/gazelle/python/parser.go
@@ -112,9 +112,9 @@
 				continue
 			}
 
-			modules.Add(m)
+			addModuleToTreeSet(modules, m)
 			if res.HasMain {
-				mainModules[res.FileName].Add(m)
+				addModuleToTreeSet(mainModules[res.FileName], m)
 			}
 		}
 
@@ -158,6 +158,8 @@
 	// If this was a from import, e.g. from foo import bar, From indicates the module
 	// from which it is imported.
 	From string `json:"from"`
+	// Whether this import is type-checking only (inside if TYPE_CHECKING block).
+	TypeCheckingOnly bool `json:"type_checking_only"`
 }
 
 // moduleComparator compares modules by name.
@@ -165,6 +167,15 @@
 	return godsutils.StringComparator(a.(Module).Name, b.(Module).Name)
 }
 
+// addModuleToTreeSet adds a module to a treeset.Set, ensuring that a TypeCheckingOnly=false module is
+// prefered over a TypeCheckingOnly=true module.
+func addModuleToTreeSet(set *treeset.Set, mod Module) {
+	if mod.TypeCheckingOnly && set.Contains(mod) {
+		return
+	}
+	set.Add(mod)
+}
+
 // annotationKind represents Gazelle annotation kinds.
 type annotationKind string
 
diff --git a/gazelle/python/resolve.go b/gazelle/python/resolve.go
index 413e69b..88275e0 100644
--- a/gazelle/python/resolve.go
+++ b/gazelle/python/resolve.go
@@ -123,6 +123,16 @@
 	return make([]label.Label, 0)
 }
 
+// addDependency adds a dependency to either the regular deps or pyiDeps set based on
+// whether the module is type-checking only.
+func addDependency(dep string, mod Module, deps, pyiDeps *treeset.Set) {
+	if mod.TypeCheckingOnly {
+		pyiDeps.Add(dep)
+	} else {
+		deps.Add(dep)
+	}
+}
+
 // Resolve translates imported libraries for a given rule into Bazel
 // dependencies. Information about imported libraries is returned for each
 // rule generated by language.GenerateRules in
@@ -141,9 +151,11 @@
 	// join with the main Gazelle binary with other rules. It may conflict with
 	// other generators that generate py_* targets.
 	deps := treeset.NewWith(godsutils.StringComparator)
+	pyiDeps := treeset.NewWith(godsutils.StringComparator)
+	cfgs := c.Exts[languageName].(pythonconfig.Configs)
+	cfg := cfgs[from.Pkg]
+
 	if modulesRaw != nil {
-		cfgs := c.Exts[languageName].(pythonconfig.Configs)
-		cfg := cfgs[from.Pkg]
 		pythonProjectRoot := cfg.PythonProjectRoot()
 		modules := modulesRaw.(*treeset.Set)
 		it := modules.Iterator()
@@ -228,7 +240,7 @@
 							override.Repo = ""
 						}
 						dep := override.Rel(from.Repo, from.Pkg).String()
-						deps.Add(dep)
+						addDependency(dep, mod, deps, pyiDeps)
 						if explainDependency == dep {
 							log.Printf("Explaining dependency (%s): "+
 								"in the target %q, the file %q imports %q at line %d, "+
@@ -239,7 +251,7 @@
 					}
 				} else {
 					if dep, distributionName, ok := cfg.FindThirdPartyDependency(moduleName); ok {
-						deps.Add(dep)
+						addDependency(dep, mod, deps, pyiDeps)
 						// Add the type and stub dependencies if they exist.
 						modules := []string{
 							fmt.Sprintf("%s_stubs", strings.ToLower(distributionName)),
@@ -249,7 +261,8 @@
 						}
 						for _, module := range modules {
 							if dep, _, ok := cfg.FindThirdPartyDependency(module); ok {
-								deps.Add(dep)
+								// Type stub packages always go to pyiDeps
+								pyiDeps.Add(dep)
 							}
 						}
 						if explainDependency == dep {
@@ -308,7 +321,7 @@
 						}
 						matchLabel := filteredMatches[0].Label.Rel(from.Repo, from.Pkg)
 						dep := matchLabel.String()
-						deps.Add(dep)
+						addDependency(dep, mod, deps, pyiDeps)
 						if explainDependency == dep {
 							log.Printf("Explaining dependency (%s): "+
 								"in the target %q, the file %q imports %q at line %d, "+
@@ -333,6 +346,34 @@
 			os.Exit(1)
 		}
 	}
+
+	addResolvedDeps(r, deps)
+
+	if cfg.GeneratePyiDeps() {
+		if !deps.Empty() {
+			r.SetAttr("deps", convertDependencySetToExpr(deps))
+		}
+		if !pyiDeps.Empty() {
+			r.SetAttr("pyi_deps", convertDependencySetToExpr(pyiDeps))
+		}
+	} else {
+		// When generate_pyi_deps is false, merge both deps and pyiDeps into deps
+		combinedDeps := treeset.NewWith(godsutils.StringComparator)
+		combinedDeps.Add(deps.Values()...)
+		combinedDeps.Add(pyiDeps.Values()...)
+
+		if !combinedDeps.Empty() {
+			r.SetAttr("deps", convertDependencySetToExpr(combinedDeps))
+		}
+	}
+}
+
+// addResolvedDeps adds the pre-resolved dependencies from the rule's private attributes
+// to the provided deps set.
+func addResolvedDeps(
+	r *rule.Rule,
+	deps *treeset.Set,
+) {
 	resolvedDeps := r.PrivateAttr(resolvedDepsKey).(*treeset.Set)
 	if !resolvedDeps.Empty() {
 		it := resolvedDeps.Iterator()
@@ -340,9 +381,6 @@
 			deps.Add(it.Value())
 		}
 	}
-	if !deps.Empty() {
-		r.SetAttr("deps", convertDependencySetToExpr(deps))
-	}
 }
 
 // targetListFromResults returns a string with the human-readable list of
diff --git a/gazelle/python/target.go b/gazelle/python/target.go
index 1fb9218..06b653d 100644
--- a/gazelle/python/target.go
+++ b/gazelle/python/target.go
@@ -15,11 +15,12 @@
 package python
 
 import (
+	"path/filepath"
+
 	"github.com/bazelbuild/bazel-gazelle/config"
 	"github.com/bazelbuild/bazel-gazelle/rule"
 	"github.com/emirpasic/gods/sets/treeset"
 	godsutils "github.com/emirpasic/gods/utils"
-	"path/filepath"
 )
 
 // targetBuilder builds targets to be generated by Gazelle.
@@ -79,7 +80,8 @@
 		// dependency resolution easier
 		dep.Name = importSpecFromSrc(t.pythonProjectRoot, t.bzlPackage, fileName).Imp
 	}
-	t.deps.Add(dep)
+
+	addModuleToTreeSet(t.deps, dep)
 	return t
 }
 
diff --git a/gazelle/python/testdata/add_type_stub_packages/BUILD.in b/gazelle/python/testdata/add_type_stub_packages/BUILD.in
index e69de29..99d122a 100644
--- a/gazelle/python/testdata/add_type_stub_packages/BUILD.in
+++ b/gazelle/python/testdata/add_type_stub_packages/BUILD.in
@@ -0,0 +1 @@
+# gazelle:python_generate_pyi_deps true
diff --git a/gazelle/python/testdata/add_type_stub_packages/BUILD.out b/gazelle/python/testdata/add_type_stub_packages/BUILD.out
index d30540f..1a5b640 100644
--- a/gazelle/python/testdata/add_type_stub_packages/BUILD.out
+++ b/gazelle/python/testdata/add_type_stub_packages/BUILD.out
@@ -1,14 +1,18 @@
 load("@rules_python//python:defs.bzl", "py_binary")
 
+# gazelle:python_generate_pyi_deps true
+
 py_binary(
     name = "add_type_stub_packages_bin",
     srcs = ["__main__.py"],
     main = "__main__.py",
+    pyi_deps = [
+        "@gazelle_python_test//boto3_stubs",
+        "@gazelle_python_test//django_types",
+    ],
     visibility = ["//:__subpackages__"],
     deps = [
         "@gazelle_python_test//boto3",
-        "@gazelle_python_test//boto3_stubs",
         "@gazelle_python_test//django",
-        "@gazelle_python_test//django_types",
     ],
 )
diff --git a/gazelle/python/testdata/add_type_stub_packages/README.md b/gazelle/python/testdata/add_type_stub_packages/README.md
index c42e76f..e3a2afe 100644
--- a/gazelle/python/testdata/add_type_stub_packages/README.md
+++ b/gazelle/python/testdata/add_type_stub_packages/README.md
@@ -1,4 +1,4 @@
 # Add stubs to `deps` of `py_library` target
 
-This test case asserts that 
-* if a package has the corresponding stub available, it is added to the `deps` of the `py_library` target.
+This test case asserts that
+* if a package has the corresponding stub available, it is added to the `pyi_deps` of the `py_library` target.
diff --git a/gazelle/python/testdata/type_checking_imports/BUILD.in b/gazelle/python/testdata/type_checking_imports/BUILD.in
new file mode 100644
index 0000000..d4dce06
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/BUILD.in
@@ -0,0 +1,2 @@
+# gazelle:python_generation_mode file
+# gazelle:python_generate_pyi_deps true
diff --git a/gazelle/python/testdata/type_checking_imports/BUILD.out b/gazelle/python/testdata/type_checking_imports/BUILD.out
new file mode 100644
index 0000000..6902106
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/BUILD.out
@@ -0,0 +1,33 @@
+load("@rules_python//python:defs.bzl", "py_library")
+
+# gazelle:python_generation_mode file
+# gazelle:python_generate_pyi_deps true
+
+py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    pyi_deps = [":foo"],
+    visibility = ["//:__subpackages__"],
+    deps = [":baz"],
+)
+
+py_library(
+    name = "baz",
+    srcs = ["baz.py"],
+    pyi_deps = [
+        "@gazelle_python_test//boto3",
+        "@gazelle_python_test//boto3_stubs",
+    ],
+    visibility = ["//:__subpackages__"],
+)
+
+py_library(
+    name = "foo",
+    srcs = ["foo.py"],
+    pyi_deps = [
+        "@gazelle_python_test//boto3_stubs",
+        "@gazelle_python_test//djangorestframework",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = ["@gazelle_python_test//boto3"],
+)
diff --git a/gazelle/python/testdata/type_checking_imports/README.md b/gazelle/python/testdata/type_checking_imports/README.md
new file mode 100644
index 0000000..b09f442
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/README.md
@@ -0,0 +1,5 @@
+# Type Checking Imports
+
+Test that the Python gazelle correctly handles type-only imports inside `if TYPE_CHECKING:` blocks.
+
+Type-only imports should be added to the `pyi_deps` attribute instead of the regular `deps` attribute.
diff --git a/gazelle/python/testdata/type_checking_imports/WORKSPACE b/gazelle/python/testdata/type_checking_imports/WORKSPACE
new file mode 100644
index 0000000..3e6e74e
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "gazelle_python_test")
diff --git a/gazelle/python/testdata/type_checking_imports/bar.py b/gazelle/python/testdata/type_checking_imports/bar.py
new file mode 100644
index 0000000..47c7d93
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/bar.py
@@ -0,0 +1,9 @@
+from typing import TYPE_CHECKING
+
+# foo should be added as a pyi_deps, since it is only imported in a type-checking context, but baz should be
+# added as a deps.
+from baz import X
+
+if TYPE_CHECKING:
+    import baz
+    import foo
diff --git a/gazelle/python/testdata/type_checking_imports/baz.py b/gazelle/python/testdata/type_checking_imports/baz.py
new file mode 100644
index 0000000..1c69e25
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/baz.py
@@ -0,0 +1,23 @@
+# 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.
+
+
+# While this format is not official, it is supported by most type checkers and
+# is used in the wild to avoid importing the typing module.
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    # Both boto3 and boto3_stubs should be added to pyi_deps.
+    import boto3
+
+X = 1
diff --git a/gazelle/python/testdata/type_checking_imports/foo.py b/gazelle/python/testdata/type_checking_imports/foo.py
new file mode 100644
index 0000000..655cb54
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/foo.py
@@ -0,0 +1,21 @@
+# 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.
+
+import typing
+
+# boto3 should be added to deps. boto3_stubs and djangorestframework should be added to pyi_deps.
+import boto3
+
+if typing.TYPE_CHECKING:
+    from rest_framework import serializers
diff --git a/gazelle/python/testdata/type_checking_imports/gazelle_python.yaml b/gazelle/python/testdata/type_checking_imports/gazelle_python.yaml
new file mode 100644
index 0000000..a782354
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/gazelle_python.yaml
@@ -0,0 +1,20 @@
+# 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.
+
+manifest:
+  modules_mapping:
+    boto3: boto3
+    boto3_stubs: boto3_stubs
+    rest_framework: djangorestframework
+  pip_deps_repository_name: gazelle_python_test
diff --git a/gazelle/python/testdata/type_checking_imports/test.yaml b/gazelle/python/testdata/type_checking_imports/test.yaml
new file mode 100644
index 0000000..fcea777
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports/test.yaml
@@ -0,0 +1,15 @@
+# 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.
+
+---
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/BUILD.in b/gazelle/python/testdata/type_checking_imports_disabled/BUILD.in
new file mode 100644
index 0000000..ab6d30f
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/BUILD.in
@@ -0,0 +1,2 @@
+# gazelle:python_generation_mode file
+# gazelle:python_generate_pyi_deps false
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/BUILD.out b/gazelle/python/testdata/type_checking_imports_disabled/BUILD.out
new file mode 100644
index 0000000..bf23d28
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/BUILD.out
@@ -0,0 +1,35 @@
+load("@rules_python//python:defs.bzl", "py_library")
+
+# gazelle:python_generation_mode file
+# gazelle:python_generate_pyi_deps false
+
+py_library(
+    name = "bar",
+    srcs = ["bar.py"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":baz",
+        ":foo",
+    ],
+)
+
+py_library(
+    name = "baz",
+    srcs = ["baz.py"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "@gazelle_python_test//boto3",
+        "@gazelle_python_test//boto3_stubs",
+    ],
+)
+
+py_library(
+    name = "foo",
+    srcs = ["foo.py"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "@gazelle_python_test//boto3",
+        "@gazelle_python_test//boto3_stubs",
+        "@gazelle_python_test//djangorestframework",
+    ],
+)
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/README.md b/gazelle/python/testdata/type_checking_imports_disabled/README.md
new file mode 100644
index 0000000..0e3b623
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/README.md
@@ -0,0 +1,3 @@
+# Type Checking Imports (disabled)
+
+See `type_checking_imports`; this is the same test case, but with the directive disabled.
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/WORKSPACE b/gazelle/python/testdata/type_checking_imports_disabled/WORKSPACE
new file mode 100644
index 0000000..3e6e74e
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "gazelle_python_test")
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/bar.py b/gazelle/python/testdata/type_checking_imports_disabled/bar.py
new file mode 100644
index 0000000..47c7d93
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/bar.py
@@ -0,0 +1,9 @@
+from typing import TYPE_CHECKING
+
+# foo should be added as a pyi_deps, since it is only imported in a type-checking context, but baz should be
+# added as a deps.
+from baz import X
+
+if TYPE_CHECKING:
+    import baz
+    import foo
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/baz.py b/gazelle/python/testdata/type_checking_imports_disabled/baz.py
new file mode 100644
index 0000000..1c69e25
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/baz.py
@@ -0,0 +1,23 @@
+# 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.
+
+
+# While this format is not official, it is supported by most type checkers and
+# is used in the wild to avoid importing the typing module.
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    # Both boto3 and boto3_stubs should be added to pyi_deps.
+    import boto3
+
+X = 1
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/foo.py b/gazelle/python/testdata/type_checking_imports_disabled/foo.py
new file mode 100644
index 0000000..655cb54
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/foo.py
@@ -0,0 +1,21 @@
+# 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.
+
+import typing
+
+# boto3 should be added to deps. boto3_stubs and djangorestframework should be added to pyi_deps.
+import boto3
+
+if typing.TYPE_CHECKING:
+    from rest_framework import serializers
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/gazelle_python.yaml b/gazelle/python/testdata/type_checking_imports_disabled/gazelle_python.yaml
new file mode 100644
index 0000000..a782354
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/gazelle_python.yaml
@@ -0,0 +1,20 @@
+# 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.
+
+manifest:
+  modules_mapping:
+    boto3: boto3
+    boto3_stubs: boto3_stubs
+    rest_framework: djangorestframework
+  pip_deps_repository_name: gazelle_python_test
diff --git a/gazelle/python/testdata/type_checking_imports_disabled/test.yaml b/gazelle/python/testdata/type_checking_imports_disabled/test.yaml
new file mode 100644
index 0000000..fcea777
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_disabled/test.yaml
@@ -0,0 +1,15 @@
+# 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.
+
+---
diff --git a/gazelle/python/testdata/type_checking_imports_package/BUILD.in b/gazelle/python/testdata/type_checking_imports_package/BUILD.in
new file mode 100644
index 0000000..8e6c1cb
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/BUILD.in
@@ -0,0 +1,2 @@
+# gazelle:python_generation_mode package
+# gazelle:python_generate_pyi_deps true
diff --git a/gazelle/python/testdata/type_checking_imports_package/BUILD.out b/gazelle/python/testdata/type_checking_imports_package/BUILD.out
new file mode 100644
index 0000000..0091e9c
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/BUILD.out
@@ -0,0 +1,19 @@
+load("@rules_python//python:defs.bzl", "py_library")
+
+# gazelle:python_generation_mode package
+# gazelle:python_generate_pyi_deps true
+
+py_library(
+    name = "type_checking_imports_package",
+    srcs = [
+        "bar.py",
+        "baz.py",
+        "foo.py",
+    ],
+    pyi_deps = [
+        "@gazelle_python_test//boto3_stubs",
+        "@gazelle_python_test//djangorestframework",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = ["@gazelle_python_test//boto3"],
+)
diff --git a/gazelle/python/testdata/type_checking_imports_package/README.md b/gazelle/python/testdata/type_checking_imports_package/README.md
new file mode 100644
index 0000000..3e2cafe
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/README.md
@@ -0,0 +1,3 @@
+# Type Checking Imports (package mode)
+
+See `type_checking_imports`; this is the same test case, but using the package generation mode.
diff --git a/gazelle/python/testdata/type_checking_imports_package/WORKSPACE b/gazelle/python/testdata/type_checking_imports_package/WORKSPACE
new file mode 100644
index 0000000..3e6e74e
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "gazelle_python_test")
diff --git a/gazelle/python/testdata/type_checking_imports_package/bar.py b/gazelle/python/testdata/type_checking_imports_package/bar.py
new file mode 100644
index 0000000..47c7d93
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/bar.py
@@ -0,0 +1,9 @@
+from typing import TYPE_CHECKING
+
+# foo should be added as a pyi_deps, since it is only imported in a type-checking context, but baz should be
+# added as a deps.
+from baz import X
+
+if TYPE_CHECKING:
+    import baz
+    import foo
diff --git a/gazelle/python/testdata/type_checking_imports_package/baz.py b/gazelle/python/testdata/type_checking_imports_package/baz.py
new file mode 100644
index 0000000..1c69e25
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/baz.py
@@ -0,0 +1,23 @@
+# 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.
+
+
+# While this format is not official, it is supported by most type checkers and
+# is used in the wild to avoid importing the typing module.
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    # Both boto3 and boto3_stubs should be added to pyi_deps.
+    import boto3
+
+X = 1
diff --git a/gazelle/python/testdata/type_checking_imports_package/foo.py b/gazelle/python/testdata/type_checking_imports_package/foo.py
new file mode 100644
index 0000000..655cb54
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/foo.py
@@ -0,0 +1,21 @@
+# 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.
+
+import typing
+
+# boto3 should be added to deps. boto3_stubs and djangorestframework should be added to pyi_deps.
+import boto3
+
+if typing.TYPE_CHECKING:
+    from rest_framework import serializers
diff --git a/gazelle/python/testdata/type_checking_imports_package/gazelle_python.yaml b/gazelle/python/testdata/type_checking_imports_package/gazelle_python.yaml
new file mode 100644
index 0000000..a782354
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/gazelle_python.yaml
@@ -0,0 +1,20 @@
+# 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.
+
+manifest:
+  modules_mapping:
+    boto3: boto3
+    boto3_stubs: boto3_stubs
+    rest_framework: djangorestframework
+  pip_deps_repository_name: gazelle_python_test
diff --git a/gazelle/python/testdata/type_checking_imports_package/test.yaml b/gazelle/python/testdata/type_checking_imports_package/test.yaml
new file mode 100644
index 0000000..fcea777
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_package/test.yaml
@@ -0,0 +1,15 @@
+# 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.
+
+---
diff --git a/gazelle/python/testdata/type_checking_imports_project/BUILD.in b/gazelle/python/testdata/type_checking_imports_project/BUILD.in
new file mode 100644
index 0000000..808e3e0
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/BUILD.in
@@ -0,0 +1,2 @@
+# gazelle:python_generation_mode project
+# gazelle:python_generate_pyi_deps true
diff --git a/gazelle/python/testdata/type_checking_imports_project/BUILD.out b/gazelle/python/testdata/type_checking_imports_project/BUILD.out
new file mode 100644
index 0000000..6d6ac3c
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/BUILD.out
@@ -0,0 +1,19 @@
+load("@rules_python//python:defs.bzl", "py_library")
+
+# gazelle:python_generation_mode project
+# gazelle:python_generate_pyi_deps true
+
+py_library(
+    name = "type_checking_imports_project",
+    srcs = [
+        "bar.py",
+        "baz.py",
+        "foo.py",
+    ],
+    pyi_deps = [
+        "@gazelle_python_test//boto3_stubs",
+        "@gazelle_python_test//djangorestframework",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = ["@gazelle_python_test//boto3"],
+)
diff --git a/gazelle/python/testdata/type_checking_imports_project/README.md b/gazelle/python/testdata/type_checking_imports_project/README.md
new file mode 100644
index 0000000..ead09e1
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/README.md
@@ -0,0 +1,3 @@
+# Type Checking Imports (project mode)
+
+See `type_checking_imports`; this is the same test case, but using the project generation mode.
diff --git a/gazelle/python/testdata/type_checking_imports_project/WORKSPACE b/gazelle/python/testdata/type_checking_imports_project/WORKSPACE
new file mode 100644
index 0000000..3e6e74e
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "gazelle_python_test")
diff --git a/gazelle/python/testdata/type_checking_imports_project/bar.py b/gazelle/python/testdata/type_checking_imports_project/bar.py
new file mode 100644
index 0000000..47c7d93
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/bar.py
@@ -0,0 +1,9 @@
+from typing import TYPE_CHECKING
+
+# foo should be added as a pyi_deps, since it is only imported in a type-checking context, but baz should be
+# added as a deps.
+from baz import X
+
+if TYPE_CHECKING:
+    import baz
+    import foo
diff --git a/gazelle/python/testdata/type_checking_imports_project/baz.py b/gazelle/python/testdata/type_checking_imports_project/baz.py
new file mode 100644
index 0000000..1c69e25
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/baz.py
@@ -0,0 +1,23 @@
+# 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.
+
+
+# While this format is not official, it is supported by most type checkers and
+# is used in the wild to avoid importing the typing module.
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    # Both boto3 and boto3_stubs should be added to pyi_deps.
+    import boto3
+
+X = 1
diff --git a/gazelle/python/testdata/type_checking_imports_project/foo.py b/gazelle/python/testdata/type_checking_imports_project/foo.py
new file mode 100644
index 0000000..655cb54
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/foo.py
@@ -0,0 +1,21 @@
+# 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.
+
+import typing
+
+# boto3 should be added to deps. boto3_stubs and djangorestframework should be added to pyi_deps.
+import boto3
+
+if typing.TYPE_CHECKING:
+    from rest_framework import serializers
diff --git a/gazelle/python/testdata/type_checking_imports_project/gazelle_python.yaml b/gazelle/python/testdata/type_checking_imports_project/gazelle_python.yaml
new file mode 100644
index 0000000..a782354
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/gazelle_python.yaml
@@ -0,0 +1,20 @@
+# 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.
+
+manifest:
+  modules_mapping:
+    boto3: boto3
+    boto3_stubs: boto3_stubs
+    rest_framework: djangorestframework
+  pip_deps_repository_name: gazelle_python_test
diff --git a/gazelle/python/testdata/type_checking_imports_project/test.yaml b/gazelle/python/testdata/type_checking_imports_project/test.yaml
new file mode 100644
index 0000000..fcea777
--- /dev/null
+++ b/gazelle/python/testdata/type_checking_imports_project/test.yaml
@@ -0,0 +1,15 @@
+# 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.
+
+---
diff --git a/gazelle/pythonconfig/pythonconfig.go b/gazelle/pythonconfig/pythonconfig.go
index e0a2b8a..8bf79cb 100644
--- a/gazelle/pythonconfig/pythonconfig.go
+++ b/gazelle/pythonconfig/pythonconfig.go
@@ -94,6 +94,10 @@
 	// ExperimentalAllowRelativeImports represents the directive that controls
 	// whether relative imports are allowed.
 	ExperimentalAllowRelativeImports = "experimental_allow_relative_imports"
+	// GeneratePyiDeps represents the directive that controls whether to generate
+	// separate pyi_deps attribute or merge type-checking dependencies into deps.
+	// Defaults to false for backward compatibility.
+	GeneratePyiDeps = "python_generate_pyi_deps"
 )
 
 // GenerationModeType represents one of the generation modes for the Python
@@ -181,6 +185,7 @@
 	labelConvention                           string
 	labelNormalization                        LabelNormalizationType
 	experimentalAllowRelativeImports          bool
+	generatePyiDeps                           bool
 }
 
 type LabelNormalizationType int
@@ -217,6 +222,7 @@
 		labelConvention:                           DefaultLabelConvention,
 		labelNormalization:                        DefaultLabelNormalizationType,
 		experimentalAllowRelativeImports:          false,
+		generatePyiDeps:                           false,
 	}
 }
 
@@ -250,6 +256,7 @@
 		labelConvention:                           c.labelConvention,
 		labelNormalization:                        c.labelNormalization,
 		experimentalAllowRelativeImports:          c.experimentalAllowRelativeImports,
+		generatePyiDeps:                           c.generatePyiDeps,
 	}
 }
 
@@ -536,6 +543,18 @@
 	return c.experimentalAllowRelativeImports
 }
 
+// SetGeneratePyiDeps sets whether pyi_deps attribute should be generated separately
+// or type-checking dependencies should be merged into the regular deps attribute.
+func (c *Config) SetGeneratePyiDeps(generatePyiDeps bool) {
+	c.generatePyiDeps = generatePyiDeps
+}
+
+// GeneratePyiDeps returns whether pyi_deps attribute should be generated separately
+// or type-checking dependencies should be merged into the regular deps attribute.
+func (c *Config) GeneratePyiDeps() bool {
+	return c.generatePyiDeps
+}
+
 // FormatThirdPartyDependency returns a label to a third-party dependency performing all formating and normalization.
 func (c *Config) FormatThirdPartyDependency(repositoryName string, distributionName string) label.Label {
 	conventionalDistributionName := strings.ReplaceAll(c.labelConvention, distributionNameLabelConventionSubstitution, distributionName)