Skip to content

Commit 036e8c5

Browse files
yushan26yushan8aignasdougthor42
authored
feat(gazelle): For package mode, resolve dependencies when imports are relative to the package path (#2865)
When `# gazelle:python_generation_mode package` is enabled, relative imports are currently not being added to the `deps` field of the generated target. For example, given the following Python code: ``` from .library import add as _add from .library import divide as _divide from .library import multiply as _multiply from .library import subtract as _subtract ``` The expected py_library rule should include a dependency on the local library package: ``` py_library( name = "py_default_library", srcs = ["__init__.py"], visibility = ["//visibility:public"], deps = [ "//example/library:py_default_library", ], ) ``` However, the actual generated rule is missing the deps entry: ``` py_library( name = "py_default_library", srcs = ["__init__.py"], visibility = ["//visibility:public"], ) ``` This change updates file_parser.go to ensure that relative imports (those starting with a .) are parsed and preserved. In `Resolve()`, logic is added to correctly interpret relative paths: A single dot (.) refers to the current package. Multiple dots (.., ..., etc.) traverse up parent directories. The relative import is resolved against the current label.Pkg path that imports the module and converted into an path relative to the root before dependency resolution. As a result, dependencies for relative imports are now correctly added to the deps field in package generation mode. Added a directive `# gazelle:experimental_allow_relative_imports true` to allow this feature to be opt in. --------- Co-authored-by: yushan <[email protected]> Co-authored-by: Ignas Anikevicius <[email protected]> Co-authored-by: Douglas Thor <[email protected]>
1 parent 8f8c5b9 commit 036e8c5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+373
-14
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ END_UNRELEASED_TEMPLATE
5454

5555
{#v0-0-0-changed}
5656
### Changed
57+
* (gazelle) For package mode, resolve dependencies when imports are relative
58+
to the package path. This is enabled via the
59+
`# gazelle:experimental_allow_relative_imports` true directive ({gh-issue}`2203`).
5760
* (gazelle) Types for exposed members of `python.ParserOutput` are now all public.
5861

5962
{#v0-0-0-fixed}

gazelle/README.md

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,12 @@ gazelle_python_manifest(
121121
requirements = "//:requirements_lock.txt",
122122
# include_stub_packages: bool (default: False)
123123
# If set to True, this flag automatically includes any corresponding type stub packages
124-
# for the third-party libraries that are present and used. For example, if you have
124+
# for the third-party libraries that are present and used. For example, if you have
125125
# `boto3` as a dependency, and this flag is enabled, the corresponding `boto3-stubs`
126126
# package will be automatically included in the BUILD file.
127127
#
128-
# Enabling this feature helps ensure that type hints and stubs are readily available
129-
# for tools like type checkers and IDEs, improving the development experience and
128+
# Enabling this feature helps ensure that type hints and stubs are readily available
129+
# for tools like type checkers and IDEs, improving the development experience and
130130
# reducing manual overhead in managing separate stub packages.
131131
include_stub_packages = True
132132
)
@@ -220,6 +220,8 @@ Python-specific directives are as follows:
220220
| 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`. |
221221
| `# gazelle:python_label_normalization` | `snake_case` |
222222
| 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". |
223+
| `# gazelle:experimental_allow_relative_imports` | `false` |
224+
| Controls whether Gazelle resolves dependencies for import statements that use paths relative to the current package. Can be "true" or "false".|
223225

224226
#### Directive: `python_root`:
225227

@@ -468,7 +470,7 @@ def py_test(name, main=None, **kwargs):
468470
name = "__test__",
469471
deps = ["@pip_pytest//:pkg"], # change this to the pytest target in your repo.
470472
)
471-
473+
472474
deps.append(":__test__")
473475
main = ":__test__.py"
474476

@@ -581,6 +583,44 @@ deps = [
581583
]
582584
```
583585

586+
#### Directive: `experimental_allow_relative_imports`
587+
Enables experimental support for resolving relative imports in
588+
`python_generation_mode package`.
589+
590+
By default, when `# gazelle:python_generation_mode package` is enabled,
591+
relative imports (e.g., from .library import foo) are not added to the
592+
deps field of the generated target. This results in incomplete py_library
593+
rules that lack required dependencies on sibling packages.
594+
595+
Example:
596+
Given this Python file import:
597+
```python
598+
from .library import add as _add
599+
from .library import subtract as _subtract
600+
```
601+
602+
Expected BUILD file output:
603+
```starlark
604+
py_library(
605+
name = "py_default_library",
606+
srcs = ["__init__.py"],
607+
deps = [
608+
"//example/library:py_default_library",
609+
],
610+
visibility = ["//visibility:public"],
611+
)
612+
```
613+
614+
Actual output without this annotation:
615+
```starlark
616+
py_library(
617+
name = "py_default_library",
618+
srcs = ["__init__.py"],
619+
visibility = ["//visibility:public"],
620+
)
621+
```
622+
If the directive is set to `true`, gazelle will resolve imports
623+
that are relative to the current package.
584624

585625
### Libraries
586626

gazelle/python/configure.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func (py *Configurer) KnownDirectives() []string {
6868
pythonconfig.TestFilePattern,
6969
pythonconfig.LabelConvention,
7070
pythonconfig.LabelNormalization,
71+
pythonconfig.ExperimentalAllowRelativeImports,
7172
}
7273
}
7374

@@ -222,6 +223,13 @@ func (py *Configurer) Configure(c *config.Config, rel string, f *rule.File) {
222223
default:
223224
config.SetLabelNormalization(pythonconfig.DefaultLabelNormalizationType)
224225
}
226+
case pythonconfig.ExperimentalAllowRelativeImports:
227+
v, err := strconv.ParseBool(strings.TrimSpace(d.Value))
228+
if err != nil {
229+
log.Printf("invalid value for gazelle:%s in %q: %q",
230+
pythonconfig.ExperimentalAllowRelativeImports, rel, d.Value)
231+
}
232+
config.SetExperimentalAllowRelativeImports(v)
225233
}
226234
}
227235

gazelle/python/file_parser.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ func (p *FileParser) parseImportStatements(node *sitter.Node) bool {
165165
}
166166
} else if node.Type() == sitterNodeTypeImportFromStatement {
167167
from := node.Child(1).Content(p.code)
168-
if strings.HasPrefix(from, ".") {
168+
// If the import is from the current package, we don't need to add it to the modules i.e. from . import Class1.
169+
// If the import is from a different relative package i.e. from .package1 import foo, we need to add it to the modules.
170+
if from == "." {
169171
return true
170172
}
171173
for j := 3; j < int(node.ChildCount()); j++ {

gazelle/python/resolve.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,61 @@ func (py *Resolver) Resolve(
148148
modules := modulesRaw.(*treeset.Set)
149149
it := modules.Iterator()
150150
explainDependency := os.Getenv("EXPLAIN_DEPENDENCY")
151+
// Resolve relative paths for package generation
152+
isPackageGeneration := !cfg.PerFileGeneration() && !cfg.CoarseGrainedGeneration()
151153
hasFatalError := false
152154
MODULES_LOOP:
153155
for it.Next() {
154156
mod := it.Value().(Module)
155-
moduleParts := strings.Split(mod.Name, ".")
156-
possibleModules := []string{mod.Name}
157+
moduleName := mod.Name
158+
// Transform relative imports `.` or `..foo.bar` into the package path from root.
159+
if strings.HasPrefix(mod.From, ".") {
160+
if !cfg.ExperimentalAllowRelativeImports() || !isPackageGeneration {
161+
continue MODULES_LOOP
162+
}
163+
164+
// Count number of leading dots in mod.From (e.g., ".." = 2, "...foo.bar" = 3)
165+
relativeDepth := strings.IndexFunc(mod.From, func(r rune) bool { return r != '.' })
166+
if relativeDepth == -1 {
167+
relativeDepth = len(mod.From)
168+
}
169+
170+
// Extract final symbol (e.g., "some_function") from mod.Name
171+
imported := mod.Name
172+
if idx := strings.LastIndex(mod.Name, "."); idx >= 0 {
173+
imported = mod.Name[idx+1:]
174+
}
175+
176+
// Optional subpath in 'from' clause, e.g. "from ...my_library.foo import x"
177+
fromPath := strings.TrimLeft(mod.From, ".")
178+
var fromParts []string
179+
if fromPath != "" {
180+
fromParts = strings.Split(fromPath, ".")
181+
}
182+
183+
// Current Bazel package as path segments
184+
pkgParts := strings.Split(from.Pkg, "/")
185+
186+
if relativeDepth-1 > len(pkgParts) {
187+
log.Printf("ERROR: Invalid relative import %q in %q: exceeds package root.", mod.Name, mod.Filepath)
188+
continue MODULES_LOOP
189+
}
190+
191+
// Go up relativeDepth - 1 levels
192+
baseParts := pkgParts
193+
if relativeDepth > 1 {
194+
baseParts = pkgParts[:len(pkgParts)-(relativeDepth-1)]
195+
}
196+
// Build absolute module path
197+
absParts := append([]string{}, baseParts...) // base path
198+
absParts = append(absParts, fromParts...) // subpath from 'from'
199+
absParts = append(absParts, imported) // actual imported symbol
200+
201+
moduleName = strings.Join(absParts, ".")
202+
}
203+
204+
moduleParts := strings.Split(moduleName, ".")
205+
possibleModules := []string{moduleName}
157206
for len(moduleParts) > 1 {
158207
// Iterate back through the possible imports until
159208
// a match is found.

gazelle/python/testdata/relative_imports/README.md

Lines changed: 0 additions & 4 deletions
This file was deleted.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# gazelle:python_generation_mode package
2+
# gazelle:experimental_allow_relative_imports true
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
load("@rules_python//python:defs.bzl", "py_binary")
2+
3+
# gazelle:python_generation_mode package
4+
# gazelle:experimental_allow_relative_imports true
5+
6+
py_binary(
7+
name = "relative_imports_package_mode_bin",
8+
srcs = ["__main__.py"],
9+
main = "__main__.py",
10+
visibility = ["//:__subpackages__"],
11+
deps = [
12+
"//package1",
13+
"//package2",
14+
],
15+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Resolve deps for relative imports
2+
3+
This test case verifies that the generated targets correctly handle relative imports in
4+
Python. Specifically, when the Python generation mode is set to "package," it ensures
5+
that relative import statements such as from .foo import X are properly resolved to
6+
their corresponding modules.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from package1.module1 import function1
2+
from package2.module3 import function3
3+
4+
print(function1())
5+
print(function3())
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "package1",
5+
srcs = [
6+
"__init__.py",
7+
"module1.py",
8+
"module2.py",
9+
],
10+
visibility = ["//:__subpackages__"],
11+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def some_function():
2+
pass
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "my_library",
5+
srcs = ["__init__.py"],
6+
visibility = ["//:__subpackages__"],
7+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "my_library",
5+
srcs = ["__init__.py"],
6+
visibility = ["//:__subpackages__"],
7+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def some_function():
2+
return "some_function"

gazelle/python/testdata/relative_imports_package_mode/package1/my_library/foo/BUILD.in

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "foo",
5+
srcs = ["__init__.py"],
6+
visibility = ["//:__subpackages__"],
7+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def some_function():
2+
return "some_function"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "subpackage1",
5+
srcs = [
6+
"__init__.py",
7+
"some_module.py",
8+
],
9+
visibility = ["//:__subpackages__"],
10+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "subpackage1",
5+
srcs = [
6+
"__init__.py",
7+
"some_module.py",
8+
],
9+
visibility = ["//:__subpackages__"],
10+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
def some_init():
3+
return "some_init"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
def some_function():
3+
return "some_function"

gazelle/python/testdata/relative_imports_package_mode/package1/subpackage1/subpackage2/BUILD.in

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "subpackage2",
5+
srcs = [
6+
"__init__.py",
7+
"script.py",
8+
],
9+
visibility = ["//:__subpackages__"],
10+
deps = [
11+
"//package1/my_library",
12+
"//package1/my_library/foo",
13+
"//package1/subpackage1",
14+
"//package1/subpackage1/subpackage2/library",
15+
],
16+
)

gazelle/python/testdata/relative_imports_package_mode/package1/subpackage1/subpackage2/__init__.py

Whitespace-only changes.

gazelle/python/testdata/relative_imports_package_mode/package1/subpackage1/subpackage2/library/BUILD.in

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "library",
5+
srcs = ["other_module.py"],
6+
visibility = ["//:__subpackages__"],
7+
)

gazelle/python/testdata/relative_imports_package_mode/package1/subpackage1/subpackage2/library/other_module.py

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from ...my_library import (
2+
some_function,
3+
) # Import path should be package1.my_library.some_function
4+
from ...my_library.foo import (
5+
some_function,
6+
) # Import path should be package1.my_library.foo.some_function
7+
from .library import (
8+
other_module,
9+
) # Import path should be package1.subpackage1.subpackage2.library.other_module
10+
from .. import some_module # Import path should be package1.subpackage1.some_module
11+
from .. import some_function # Import path should be package1.subpackage1.some_function

gazelle/python/testdata/relative_imports_package_mode/package2/BUILD.in

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "package2",
5+
srcs = [
6+
"__init__.py",
7+
"module3.py",
8+
"module4.py",
9+
],
10+
visibility = ["//:__subpackages__"],
11+
deps = ["//package2/library"],
12+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from .library import add as _add
2+
from .library import divide as _divide
3+
from .library import multiply as _multiply
4+
from .library import subtract as _subtract
5+
6+
7+
def add(a, b):
8+
return _add(a, b)
9+
10+
11+
def divide(a, b):
12+
return _divide(a, b)
13+
14+
15+
def multiply(a, b):
16+
return _multiply(a, b)
17+
18+
19+
def subtract(a, b):
20+
return _subtract(a, b)

gazelle/python/testdata/relative_imports_package_mode/package2/library/BUILD.in

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:defs.bzl", "py_library")
2+
3+
py_library(
4+
name = "library",
5+
srcs = ["__init__.py"],
6+
visibility = ["//:__subpackages__"],
7+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
def add(a, b):
2+
return a + b
3+
4+
5+
def divide(a, b):
6+
return a / b
7+
8+
9+
def multiply(a, b):
10+
return a * b
11+
12+
13+
def subtract(a, b):
14+
return a - b
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .library import function5
2+
3+
4+
def function3():
5+
return "function3 " + function5()

0 commit comments

Comments
 (0)