Skip to content

Commit 41297f8

Browse files
committed
gazelle: Add support for generating pyi_deps
1 parent 5b1db07 commit 41297f8

File tree

16 files changed

+259
-15
lines changed

16 files changed

+259
-15
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ END_UNRELEASED_TEMPLATE
5555
{#v0-0-0-changed}
5656
### Changed
5757
* (gazelle) Types for exposed members of `python.ParserOutput` are now all public.
58+
* (gazelle) Dependencies added to satisfy type-only imports (`if TYPE_CHECKING`) and type stub packages are
59+
now added to `pyi_deps` instead of `deps`.
5860

5961
{#v0-0-0-fixed}
6062
### Fixed

gazelle/python/file_parser.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ type ParserOutput struct {
4747
}
4848

4949
type FileParser struct {
50-
code []byte
51-
relFilepath string
52-
output ParserOutput
50+
code []byte
51+
relFilepath string
52+
output ParserOutput
53+
inTypeCheckingBlock bool
5354
}
5455

5556
func NewFileParser() *FileParser {
@@ -158,6 +159,7 @@ func (p *FileParser) parseImportStatements(node *sitter.Node) bool {
158159
continue
159160
}
160161
m.Filepath = p.relFilepath
162+
m.TypeCheckingOnly = p.inTypeCheckingBlock
161163
if strings.HasPrefix(m.Name, ".") {
162164
continue
163165
}
@@ -176,6 +178,7 @@ func (p *FileParser) parseImportStatements(node *sitter.Node) bool {
176178
m.Filepath = p.relFilepath
177179
m.From = from
178180
m.Name = fmt.Sprintf("%s.%s", from, m.Name)
181+
m.TypeCheckingOnly = p.inTypeCheckingBlock
179182
p.output.Modules = append(p.output.Modules, m)
180183
}
181184
} else {
@@ -200,10 +203,43 @@ func (p *FileParser) SetCodeAndFile(code []byte, relPackagePath, filename string
200203
p.output.FileName = filename
201204
}
202205

206+
// isTypeCheckingBlock returns true if the given node is an `if TYPE_CHECKING:` block.
207+
func (p *FileParser) isTypeCheckingBlock(node *sitter.Node) bool {
208+
if node.Type() != sitterNodeTypeIfStatement || node.ChildCount() < 2 {
209+
return false
210+
}
211+
212+
condition := node.Child(1)
213+
214+
// Handle `if TYPE_CHECKING:`
215+
if condition.Type() == sitterNodeTypeIdentifier && condition.Content(p.code) == "TYPE_CHECKING" {
216+
return true
217+
}
218+
219+
// Handle `if typing.TYPE_CHECKING:`
220+
if condition.Type() == "attribute" && condition.ChildCount() >= 3 {
221+
object := condition.Child(0)
222+
attr := condition.Child(2)
223+
if object.Type() == sitterNodeTypeIdentifier && object.Content(p.code) == "typing" &&
224+
attr.Type() == sitterNodeTypeIdentifier && attr.Content(p.code) == "TYPE_CHECKING" {
225+
return true
226+
}
227+
}
228+
229+
return false
230+
}
231+
203232
func (p *FileParser) parse(ctx context.Context, node *sitter.Node) {
204233
if node == nil {
205234
return
206235
}
236+
237+
// Check if this is a TYPE_CHECKING block
238+
wasInTypeCheckingBlock := p.inTypeCheckingBlock
239+
if p.isTypeCheckingBlock(node) {
240+
p.inTypeCheckingBlock = true
241+
}
242+
207243
for i := 0; i < int(node.ChildCount()); i++ {
208244
if err := ctx.Err(); err != nil {
209245
return
@@ -217,6 +253,9 @@ func (p *FileParser) parse(ctx context.Context, node *sitter.Node) {
217253
}
218254
p.parse(ctx, child)
219255
}
256+
257+
// Restore the previous state
258+
p.inTypeCheckingBlock = wasInTypeCheckingBlock
220259
}
221260

222261
func (p *FileParser) Parse(ctx context.Context) (*ParserOutput, error) {

gazelle/python/file_parser_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,40 @@ func TestParseFull(t *testing.T) {
254254
FileName: "a.py",
255255
}, *output)
256256
}
257+
258+
func TestTypeCheckingImports(t *testing.T) {
259+
code := `
260+
import sys
261+
from typing import TYPE_CHECKING
262+
263+
if TYPE_CHECKING:
264+
import boto3
265+
from rest_framework import serializers
266+
267+
def example_function():
268+
_ = sys.version_info
269+
`
270+
p := NewFileParser()
271+
p.SetCodeAndFile([]byte(code), "", "test.py")
272+
273+
result, err := p.Parse(context.Background())
274+
if err != nil {
275+
t.Fatalf("Failed to parse: %v", err)
276+
}
277+
278+
// Check that we found the expected modules
279+
expectedModules := map[string]bool{
280+
"sys": false,
281+
"typing.TYPE_CHECKING": false,
282+
"boto3": true,
283+
"rest_framework.serializers": true,
284+
}
285+
286+
for _, mod := range result.Modules {
287+
if expected, exists := expectedModules[mod.Name]; exists {
288+
if mod.TypeCheckingOnly != expected {
289+
t.Errorf("Module %s: expected TypeCheckingOnly=%v, got %v", mod.Name, expected, mod.TypeCheckingOnly)
290+
}
291+
}
292+
}
293+
}

gazelle/python/parser.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ type Module struct {
158158
// If this was a from import, e.g. from foo import bar, From indicates the module
159159
// from which it is imported.
160160
From string `json:"from"`
161+
// Whether this import is type-checking only (inside if TYPE_CHECKING block).
162+
TypeCheckingOnly bool `json:"type_checking_only"`
161163
}
162164

163165
// moduleComparator compares modules by name.

gazelle/python/resolve.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const (
3939
// resolvedDepsKey is the attribute key used to pass dependencies that don't
4040
// need to be resolved by the dependency resolver in the Resolver step.
4141
resolvedDepsKey = "_gazelle_python_resolved_deps"
42+
// resolvedPyiDepsKey is the attribute key used to pass type-checking dependencies that don't
43+
// need to be resolved by the dependency resolver in the Resolver step.
44+
resolvedPyiDepsKey = "_gazelle_python_resolved_pyi_deps"
4245
)
4346

4447
// Resolver satisfies the resolve.Resolver interface. It resolves dependencies
@@ -123,6 +126,16 @@ func (py *Resolver) Embeds(r *rule.Rule, from label.Label) []label.Label {
123126
return make([]label.Label, 0)
124127
}
125128

129+
// addDependency adds a dependency to either the regular deps or pyiDeps set based on
130+
// whether the module is type-checking only.
131+
func addDependency(dep string, mod Module, deps, pyiDeps *treeset.Set) {
132+
if mod.TypeCheckingOnly {
133+
pyiDeps.Add(dep)
134+
} else {
135+
deps.Add(dep)
136+
}
137+
}
138+
126139
// Resolve translates imported libraries for a given rule into Bazel
127140
// dependencies. Information about imported libraries is returned for each
128141
// rule generated by language.GenerateRules in
@@ -141,6 +154,8 @@ func (py *Resolver) Resolve(
141154
// join with the main Gazelle binary with other rules. It may conflict with
142155
// other generators that generate py_* targets.
143156
deps := treeset.NewWith(godsutils.StringComparator)
157+
pyiDeps := treeset.NewWith(godsutils.StringComparator)
158+
144159
if modulesRaw != nil {
145160
cfgs := c.Exts[languageName].(pythonconfig.Configs)
146161
cfg := cfgs[from.Pkg]
@@ -179,7 +194,7 @@ func (py *Resolver) Resolve(
179194
override.Repo = ""
180195
}
181196
dep := override.Rel(from.Repo, from.Pkg).String()
182-
deps.Add(dep)
197+
addDependency(dep, mod, deps, pyiDeps)
183198
if explainDependency == dep {
184199
log.Printf("Explaining dependency (%s): "+
185200
"in the target %q, the file %q imports %q at line %d, "+
@@ -190,7 +205,7 @@ func (py *Resolver) Resolve(
190205
}
191206
} else {
192207
if dep, distributionName, ok := cfg.FindThirdPartyDependency(moduleName); ok {
193-
deps.Add(dep)
208+
addDependency(dep, mod, deps, pyiDeps)
194209
// Add the type and stub dependencies if they exist.
195210
modules := []string{
196211
fmt.Sprintf("%s_stubs", strings.ToLower(distributionName)),
@@ -200,7 +215,8 @@ func (py *Resolver) Resolve(
200215
}
201216
for _, module := range modules {
202217
if dep, _, ok := cfg.FindThirdPartyDependency(module); ok {
203-
deps.Add(dep)
218+
// Type stub packages always go to pyiDeps
219+
pyiDeps.Add(dep)
204220
}
205221
}
206222
if explainDependency == dep {
@@ -259,7 +275,7 @@ func (py *Resolver) Resolve(
259275
}
260276
matchLabel := filteredMatches[0].Label.Rel(from.Repo, from.Pkg)
261277
dep := matchLabel.String()
262-
deps.Add(dep)
278+
addDependency(dep, mod, deps, pyiDeps)
263279
if explainDependency == dep {
264280
log.Printf("Explaining dependency (%s): "+
265281
"in the target %q, the file %q imports %q at line %d, "+
@@ -284,15 +300,29 @@ func (py *Resolver) Resolve(
284300
os.Exit(1)
285301
}
286302
}
287-
resolvedDeps := r.PrivateAttr(resolvedDepsKey).(*treeset.Set)
303+
304+
addResolvedDepsAndSetAttr(r, deps, resolvedDepsKey, "deps")
305+
addResolvedDepsAndSetAttr(r, pyiDeps, resolvedPyiDepsKey, "pyi_deps")
306+
}
307+
308+
// addResolvedDepsAndSetAttr adds the pre-resolved dependencies from the rule's private attributes
309+
// to the provided deps set and sets the attribute on the rule.
310+
func addResolvedDepsAndSetAttr(
311+
r *rule.Rule,
312+
deps *treeset.Set,
313+
resolvedDepsAttrName string,
314+
depsAttrName string,
315+
) {
316+
resolvedDeps := r.PrivateAttr(resolvedDepsAttrName).(*treeset.Set)
288317
if !resolvedDeps.Empty() {
289318
it := resolvedDeps.Iterator()
290319
for it.Next() {
291320
deps.Add(it.Value())
292321
}
293322
}
323+
294324
if !deps.Empty() {
295-
r.SetAttr("deps", convertDependencySetToExpr(deps))
325+
r.SetAttr(depsAttrName, convertDependencySetToExpr(deps))
296326
}
297327
}
298328

gazelle/python/target.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
package python
1616

1717
import (
18+
"path/filepath"
19+
1820
"github.com/bazelbuild/bazel-gazelle/config"
1921
"github.com/bazelbuild/bazel-gazelle/rule"
2022
"github.com/emirpasic/gods/sets/treeset"
2123
godsutils "github.com/emirpasic/gods/utils"
22-
"path/filepath"
2324
)
2425

2526
// targetBuilder builds targets to be generated by Gazelle.
@@ -31,7 +32,9 @@ type targetBuilder struct {
3132
srcs *treeset.Set
3233
siblingSrcs *treeset.Set
3334
deps *treeset.Set
35+
pyiDeps *treeset.Set
3436
resolvedDeps *treeset.Set
37+
resolvedPyiDeps *treeset.Set
3538
visibility *treeset.Set
3639
main *string
3740
imports []string
@@ -48,7 +51,9 @@ func newTargetBuilder(kind, name, pythonProjectRoot, bzlPackage string, siblingS
4851
srcs: treeset.NewWith(godsutils.StringComparator),
4952
siblingSrcs: siblingSrcs,
5053
deps: treeset.NewWith(moduleComparator),
54+
pyiDeps: treeset.NewWith(moduleComparator),
5155
resolvedDeps: treeset.NewWith(godsutils.StringComparator),
56+
resolvedPyiDeps: treeset.NewWith(godsutils.StringComparator),
5257
visibility: treeset.NewWith(godsutils.StringComparator),
5358
}
5459
}
@@ -79,7 +84,13 @@ func (t *targetBuilder) addModuleDependency(dep Module) *targetBuilder {
7984
// dependency resolution easier
8085
dep.Name = importSpecFromSrc(t.pythonProjectRoot, t.bzlPackage, fileName).Imp
8186
}
82-
t.deps.Add(dep)
87+
88+
// Add to appropriate dependency set based on whether it's type-checking only
89+
if dep.TypeCheckingOnly {
90+
t.pyiDeps.Add(dep)
91+
} else {
92+
t.deps.Add(dep)
93+
}
8394
return t
8495
}
8596

@@ -162,12 +173,23 @@ func (t *targetBuilder) build() *rule.Rule {
162173
if t.imports != nil {
163174
r.SetAttr("imports", t.imports)
164175
}
165-
if !t.deps.Empty() {
166-
r.SetPrivateAttr(config.GazelleImportsKey, t.deps)
176+
if combinedDeps := t.combinedDeps(); !combinedDeps.Empty() {
177+
r.SetPrivateAttr(config.GazelleImportsKey, combinedDeps)
167178
}
168179
if t.testonly {
169180
r.SetAttr("testonly", true)
170181
}
171182
r.SetPrivateAttr(resolvedDepsKey, t.resolvedDeps)
183+
r.SetPrivateAttr(resolvedPyiDepsKey, t.resolvedPyiDeps)
172184
return r
173185
}
186+
187+
// Combine both regular and type-checking imports into a single set
188+
// for passing to the resolver. The resolver will distinguish them
189+
// based on the TypeCheckingOnly field.
190+
func (t *targetBuilder) combinedDeps() *treeset.Set {
191+
combinedDeps := treeset.NewWith(moduleComparator)
192+
combinedDeps.Add(t.pyiDeps.Values()...)
193+
combinedDeps.Add(t.deps.Values()...)
194+
return combinedDeps
195+
}

gazelle/python/testdata/add_type_stub_packages/BUILD.out

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ py_binary(
44
name = "add_type_stub_packages_bin",
55
srcs = ["__main__.py"],
66
main = "__main__.py",
7+
pyi_deps = [
8+
"@gazelle_python_test//boto3_stubs",
9+
"@gazelle_python_test//django_types",
10+
],
711
visibility = ["//:__subpackages__"],
812
deps = [
913
"@gazelle_python_test//boto3",
10-
"@gazelle_python_test//boto3_stubs",
1114
"@gazelle_python_test//django",
12-
"@gazelle_python_test//django_types",
1315
],
1416
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# gazelle:python_generation_mode file
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
# gazelle:python_generation_mode file
4+
5+
py_library(
6+
name = "bar",
7+
srcs = ["bar.py"],
8+
pyi_deps = [":foo"],
9+
visibility = ["//:__subpackages__"],
10+
deps = [":baz"],
11+
)
12+
13+
py_library(
14+
name = "baz",
15+
srcs = ["baz.py"],
16+
visibility = ["//:__subpackages__"],
17+
)
18+
19+
py_library(
20+
name = "foo",
21+
srcs = ["foo.py"],
22+
pyi_deps = ["@gazelle_python_test//djangorestframework"],
23+
visibility = ["//:__subpackages__"],
24+
deps = ["@gazelle_python_test//boto3"],
25+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Type Checking Imports
2+
3+
Test that the Python gazelle correctly handles type-only imports inside `if TYPE_CHECKING:` blocks.
4+
5+
Type-only imports should be added to the `pyi_deps` attribute instead of the regular `deps` attribute.

0 commit comments

Comments
 (0)