Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ END_UNRELEASED_TEMPLATE
### Changed
* (gazelle) Types for exposed members of `python.ParserOutput` are now all public.

### Added
* (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-fixed}
### Fixed
* Nothing fixed.
Expand Down
2 changes: 2 additions & 0 deletions gazelle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ Python-specific directives are as follows:
| Defines the format of the distribution name in labels to third-party deps. Useful for using Gazelle plugin with other rules with different repository conventions (e.g. `rules_pycross`). Full label is always prepended with (pip) repository name, e.g. `@pip//numpy`. |
| `# gazelle:python_label_normalization` | `snake_case` |
| 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: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`:

Expand Down
7 changes: 7 additions & 0 deletions gazelle/python/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func (py *Configurer) KnownDirectives() []string {
pythonconfig.TestFilePattern,
pythonconfig.LabelConvention,
pythonconfig.LabelNormalization,
pythonconfig.GeneratePyiDeps,
}
}

Expand Down Expand Up @@ -222,6 +223,12 @@ func (py *Configurer) Configure(c *config.Config, rel string, f *rule.File) {
default:
config.SetLabelNormalization(pythonconfig.DefaultLabelNormalizationType)
}
case pythonconfig.GeneratePyiDeps:
v, err := strconv.ParseBool(strings.TrimSpace(d.Value))
if err != nil {
log.Fatal(err)
}
config.SetGeneratePyiDeps(v)
}
}

Expand Down
45 changes: 42 additions & 3 deletions gazelle/python/file_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ type ParserOutput struct {
}

type FileParser struct {
code []byte
relFilepath string
output ParserOutput
code []byte
relFilepath string
output ParserOutput
inTypeCheckingBlock bool
}

func NewFileParser() *FileParser {
Expand Down Expand Up @@ -158,6 +159,7 @@ func (p *FileParser) parseImportStatements(node *sitter.Node) bool {
continue
}
m.Filepath = p.relFilepath
m.TypeCheckingOnly = p.inTypeCheckingBlock
if strings.HasPrefix(m.Name, ".") {
continue
}
Expand All @@ -176,6 +178,7 @@ func (p *FileParser) parseImportStatements(node *sitter.Node) bool {
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 {
Expand All @@ -200,10 +203,43 @@ func (p *FileParser) SetCodeAndFile(code []byte, relPackagePath, filename string
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
Expand All @@ -217,6 +253,9 @@ func (p *FileParser) parse(ctx context.Context, node *sitter.Node) {
}
p.parse(ctx, child)
}

// Restore the previous state
p.inTypeCheckingBlock = wasInTypeCheckingBlock
}

func (p *FileParser) Parse(ctx context.Context) (*ParserOutput, error) {
Expand Down
37 changes: 37 additions & 0 deletions gazelle/python/file_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,40 @@ func TestParseFull(t *testing.T) {
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)
}
}
}
}
2 changes: 2 additions & 0 deletions gazelle/python/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ type Module struct {
// 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.
Expand Down
59 changes: 50 additions & 9 deletions gazelle/python/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const (
// resolvedDepsKey is the attribute key used to pass dependencies that don't
// need to be resolved by the dependency resolver in the Resolver step.
resolvedDepsKey = "_gazelle_python_resolved_deps"
// resolvedPyiDepsKey is the attribute key used to pass type-checking dependencies that don't
// need to be resolved by the dependency resolver in the Resolver step.
resolvedPyiDepsKey = "_gazelle_python_resolved_pyi_deps"
)

// Resolver satisfies the resolve.Resolver interface. It resolves dependencies
Expand Down Expand Up @@ -123,6 +126,16 @@ func (py *Resolver) Embeds(r *rule.Rule, from label.Label) []label.Label {
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
Expand All @@ -141,9 +154,11 @@ func (py *Resolver) Resolve(
// 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()
Expand Down Expand Up @@ -179,7 +194,7 @@ func (py *Resolver) Resolve(
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, "+
Expand All @@ -190,7 +205,7 @@ func (py *Resolver) Resolve(
}
} 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)),
Expand All @@ -200,7 +215,8 @@ func (py *Resolver) Resolve(
}
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 {
Expand Down Expand Up @@ -259,7 +275,7 @@ func (py *Resolver) Resolve(
}
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, "+
Expand All @@ -284,16 +300,41 @@ func (py *Resolver) Resolve(
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()
for it.Next() {
deps.Add(it.Value())
}
}
if !deps.Empty() {
r.SetAttr("deps", convertDependencySetToExpr(deps))
}
}

// targetListFromResults returns a string with the human-readable list of
Expand Down
1 change: 1 addition & 0 deletions gazelle/python/testdata/add_type_stub_packages/BUILD.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# gazelle:python_generate_pyi_deps true
8 changes: 6 additions & 2 deletions gazelle/python/testdata/add_type_stub_packages/BUILD.out
Original file line number Diff line number Diff line change
@@ -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",
],
)
2 changes: 2 additions & 0 deletions gazelle/python/testdata/type_checking_imports/BUILD.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# gazelle:python_generation_mode file
# gazelle:python_generate_pyi_deps true
26 changes: 26 additions & 0 deletions gazelle/python/testdata/type_checking_imports/BUILD.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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"],
visibility = ["//:__subpackages__"],
)

py_library(
name = "foo",
srcs = ["foo.py"],
pyi_deps = ["@gazelle_python_test//djangorestframework"],
visibility = ["//:__subpackages__"],
deps = ["@gazelle_python_test//boto3"],
)
5 changes: 5 additions & 0 deletions gazelle/python/testdata/type_checking_imports/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions gazelle/python/testdata/type_checking_imports/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
workspace(name = "gazelle_python_test")
9 changes: 9 additions & 0 deletions gazelle/python/testdata/type_checking_imports/bar.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions gazelle/python/testdata/type_checking_imports/baz.py
Original file line number Diff line number Diff line change
@@ -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.

X = 1
Loading