Skip to content

Commit 044eb26

Browse files
authored
feat(gazelle): Add python_generate_pyi_srcs directive (bazel-contrib#3356)
Fixes bazel-contrib#3354 This adds a new Gazelle directive `python_generate_pyi_srcs` which accepts a boolean value `true` or `false`. It defaults to `false` for backwards compatibility. When `true`, the directive causes `.pyi` files, whose name matches with a .py file found in `srcs`, to be added to the `pyi_srcs` target attribute. This helps with cases where manually-generated `.pyi` files are present in the project and are needed for things such as rules_mypy. Given the following files in a `my_dir` package: ``` BUILD.bazel __init__.py a.py a.pyi b.py c.py c.pyi ``` `# gazelle:python_generate_pyi_srcs true` will cause Gazelle to generate: ```starlark py_library( name = "my_dir", srcs = [ "__init__.py", "a.py", "b.py", "c.py", ], pyi_srcs = [ "a.py", "c.py", ], ) ```
1 parent fa82b68 commit 044eb26

37 files changed

+223
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ END_UNRELEASED_TEMPLATE
7878
* (binaries/tests) Build information is now included in binaries and tests.
7979
Use the `bazel_binary_info` module to access it. The {flag}`--stamp` flag will
8080
add {flag}`--workspace_status` information.
81+
* (gazelle) A new directive `python_generate_pyi_deps` has been added. When
82+
`true`, a `py_*` target's `pyi_srcs` attribute will be set if any `.pyi` files
83+
that are associated with the target's `srcs` are present.
84+
([#3354](https://github.com/bazel-contrib/rules_python/issues/3354)).
8185

8286
{#v1-8-1}
8387
## [1.8.1] - 2026-01-20

gazelle/docs/directives.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ The Python-specific directives are:
155155
* Default: `false`
156156
* Allowed Values: `true`, `false`
157157

158+
[`# gazelle:python_generate_pyi_srcs bool`](#python-generate-pyi-srcs)
159+
: Controls whether to generate a `pyi_srcs` attribute if a sibling `.pyi` file
160+
is found. When `false` (default), the `pyi_srcs` attribute is not added.
161+
* Default: `false`
162+
* Allowed Values: `true`, `false`
163+
158164
[`# gazelle:python_generate_proto bool`](#python-generate-proto)
159165
: Controls whether to generate a {bzl:obj}`py_proto_library` for each
160166
{bzl:obj}`proto_library` in the package. By default we load this rule from the
@@ -626,6 +632,28 @@ Detailed docs are not yet written.
626632
:::
627633

628634

635+
## `python_generate_pyi_deps`
636+
637+
When `true`, include any sibling `.pyi` files in the `pyi_srcs` target attribute.
638+
639+
For example, assume you have the following files:
640+
641+
```
642+
foo.py
643+
foo.pyi
644+
```
645+
646+
The generated target will be:
647+
648+
```starlark
649+
py_library(
650+
name = "foo",
651+
srcs = ["foo.py"],
652+
pyi_srcs = ["foo.pyi"],
653+
)
654+
```
655+
656+
629657
## `python_generate_proto`
630658

631659
When `# gazelle:python_generate_proto true`, Gazelle will generate one

gazelle/python/configure.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func (py *Configurer) KnownDirectives() []string {
7070
pythonconfig.LabelConvention,
7171
pythonconfig.LabelNormalization,
7272
pythonconfig.GeneratePyiDeps,
73+
pythonconfig.GeneratePyiSrcs,
7374
pythonconfig.ExperimentalAllowRelativeImports,
7475
pythonconfig.GenerateProto,
7576
pythonconfig.PythonResolveSiblingImports,
@@ -242,6 +243,12 @@ func (py *Configurer) Configure(c *config.Config, rel string, f *rule.File) {
242243
log.Fatal(err)
243244
}
244245
config.SetGeneratePyiDeps(v)
246+
case pythonconfig.GeneratePyiSrcs:
247+
v, err := strconv.ParseBool(strings.TrimSpace(d.Value))
248+
if err != nil {
249+
log.Fatal(err)
250+
}
251+
config.SetGeneratePyiSrcs(v)
245252
case pythonconfig.GenerateProto:
246253
v, err := strconv.ParseBool(strings.TrimSpace(d.Value))
247254
if err != nil {

gazelle/python/generate.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
273273
srcs.Remove(name)
274274
}
275275
}
276+
276277
sort.Strings(mainFileNames)
277278
for _, filename := range mainFileNames {
278279
pyBinaryTargetName := strings.TrimSuffix(filepath.Base(filename), ".py")
@@ -282,9 +283,15 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
282283
fqTarget.String(), actualPyBinaryKind, err)
283284
continue
284285
}
286+
287+
// Add any sibling .pyi files to pyi_srcs
288+
filenames := treeset.NewWith(godsutils.StringComparator, filename)
289+
pyiSrcs, _ := getPyiFilenames(filenames, cfg.GeneratePyiSrcs(), args.Dir)
290+
285291
pyBinary := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames, cfg.ResolveSiblingImports()).
286292
addVisibility(visibility).
287293
addSrc(filename).
294+
addPyiSrcs(pyiSrcs).
288295
addModuleDependencies(mainModules[filename]).
289296
addResolvedDependencies(annotations.includeDeps).
290297
generateImportsAttribute().
@@ -312,6 +319,9 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
312319
}
313320
}
314321

322+
// Add any sibling .pyi files to pyi_srcs
323+
pyiSrcs, _ := getPyiFilenames(srcs, cfg.GeneratePyiSrcs(), args.Dir)
324+
315325
// Check if a target with the same name we are generating already
316326
// exists, and if it is of a different kind from the one we are
317327
// generating. If so, we have to throw an error since Gazelle won't
@@ -327,6 +337,7 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
327337
pyLibrary := newTargetBuilder(pyLibraryKind, pyLibraryTargetName, pythonProjectRoot, args.Rel, pyFileNames, cfg.ResolveSiblingImports()).
328338
addVisibility(visibility).
329339
addSrcs(srcs).
340+
addPyiSrcs(pyiSrcs).
330341
addModuleDependencies(allDeps).
331342
addResolvedDependencies(annotations.includeDeps).
332343
generateImportsAttribute().
@@ -377,10 +388,15 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
377388
collisionErrors.Add(err)
378389
}
379390

391+
// Add any sibling .pyi files to pyi_srcs
392+
filenames := treeset.NewWith(godsutils.StringComparator, pyBinaryEntrypointFilename)
393+
pyiSrcs, _ := getPyiFilenames(filenames, cfg.GeneratePyiSrcs(), args.Dir)
394+
380395
pyBinaryTarget := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames, cfg.ResolveSiblingImports()).
381396
setMain(pyBinaryEntrypointFilename).
382397
addVisibility(visibility).
383398
addSrc(pyBinaryEntrypointFilename).
399+
addPyiSrcs(pyiSrcs).
384400
addModuleDependencies(deps).
385401
addResolvedDependencies(annotations.includeDeps).
386402
setAnnotations(*annotations).
@@ -411,8 +427,13 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
411427
collisionErrors.Add(err)
412428
}
413429

430+
// Add any sibling .pyi files to pyi_srcs
431+
filenames := treeset.NewWith(godsutils.StringComparator, conftestFilename)
432+
pyiSrcs, _ := getPyiFilenames(filenames, cfg.GeneratePyiSrcs(), args.Dir)
433+
414434
conftestTarget := newTargetBuilder(pyLibraryKind, conftestTargetname, pythonProjectRoot, args.Rel, pyFileNames, cfg.ResolveSiblingImports()).
415435
addSrc(conftestFilename).
436+
addPyiSrcs(pyiSrcs).
416437
addModuleDependencies(deps).
417438
addResolvedDependencies(annotations.includeDeps).
418439
setAnnotations(*annotations).
@@ -443,8 +464,13 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
443464
fqTarget.String(), actualPyTestKind, err, pythonconfig.TestNamingConvention)
444465
collisionErrors.Add(err)
445466
}
467+
468+
// Add any sibling .pyi files to pyi_srcs
469+
pyiSrcs, _ := getPyiFilenames(srcs, cfg.GeneratePyiSrcs(), args.Dir)
470+
446471
return newTargetBuilder(pyTestKind, pyTestTargetName, pythonProjectRoot, args.Rel, pyFileNames, cfg.ResolveSiblingImports()).
447472
addSrcs(srcs).
473+
addPyiSrcs(pyiSrcs).
448474
addModuleDependencies(deps).
449475
addResolvedDependencies(annotations.includeDeps).
450476
setAnnotations(*annotations).
@@ -691,3 +717,25 @@ func generateProtoLibraries(args language.GenerateArgs, cfg *pythonconfig.Config
691717
}
692718

693719
}
720+
721+
// getPyiFilenames returns a set of existing .pyi source file names for a given set of source
722+
// file names if GeneratePyiSrcs is set. Otherwise, returns an empty set.
723+
func getPyiFilenames(filenames *treeset.Set, generatePyiSrcs bool, basePath string) (*treeset.Set, error) {
724+
pyiSrcs := treeset.NewWith(godsutils.StringComparator)
725+
if !generatePyiSrcs {
726+
return pyiSrcs, nil
727+
}
728+
729+
it := filenames.Iterator()
730+
for it.Next() {
731+
pyiFilename := it.Value().(string) + "i" // foo.py --> foo.pyi
732+
733+
_, err := os.Stat(filepath.Join(basePath, pyiFilename))
734+
// If the file DNE or there's some other error, there's nothing to do.
735+
if err == nil {
736+
// pyi file exists, add it
737+
pyiSrcs.Add(pyiFilename)
738+
}
739+
}
740+
return pyiSrcs, nil
741+
}

gazelle/python/kinds.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ var pyKinds = map[string]rule.KindInfo{
5151
ResolveAttrs: map[string]bool{
5252
"deps": true,
5353
"pyi_deps": true,
54+
"pyi_srcs": true,
5455
},
5556
},
5657
pyLibraryKind: {
@@ -68,6 +69,7 @@ var pyKinds = map[string]rule.KindInfo{
6869
ResolveAttrs: map[string]bool{
6970
"deps": true,
7071
"pyi_deps": true,
72+
"pyi_srcs": true,
7173
},
7274
},
7375
pyProtoLibraryKind: {
@@ -91,6 +93,7 @@ var pyKinds = map[string]rule.KindInfo{
9193
ResolveAttrs: map[string]bool{
9294
"deps": true,
9395
"pyi_deps": true,
96+
"pyi_srcs": true,
9497
},
9598
},
9699
}

gazelle/python/target.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type targetBuilder struct {
3030
pythonProjectRoot string
3131
bzlPackage string
3232
srcs *treeset.Set
33+
pyiSrcs *treeset.Set
3334
siblingSrcs *treeset.Set
3435
deps *treeset.Set
3536
resolvedDeps *treeset.Set
@@ -49,6 +50,7 @@ func newTargetBuilder(kind, name, pythonProjectRoot, bzlPackage string, siblingS
4950
pythonProjectRoot: pythonProjectRoot,
5051
bzlPackage: bzlPackage,
5152
srcs: treeset.NewWith(godsutils.StringComparator),
53+
pyiSrcs: treeset.NewWith(godsutils.StringComparator),
5254
siblingSrcs: siblingSrcs,
5355
deps: treeset.NewWith(moduleComparator),
5456
resolvedDeps: treeset.NewWith(godsutils.StringComparator),
@@ -73,6 +75,21 @@ func (t *targetBuilder) addSrcs(srcs *treeset.Set) *targetBuilder {
7375
return t
7476
}
7577

78+
// addPyiSrc adds a single pyi_src to the target.
79+
func (t *targetBuilder) addPyiSrc(pyiSrc string) *targetBuilder {
80+
t.pyiSrcs.Add(pyiSrc)
81+
return t
82+
}
83+
84+
// addPyiSrcs adds multiple pyi_srcs to the target.
85+
func (t *targetBuilder) addPyiSrcs(pyiSrcs *treeset.Set) *targetBuilder {
86+
it := pyiSrcs.Iterator()
87+
for it.Next() {
88+
t.pyiSrcs.Add(it.Value().(string))
89+
}
90+
return t
91+
}
92+
7693
// addModuleDependency adds a single module dep to the target.
7794
func (t *targetBuilder) addModuleDependency(dep Module) *targetBuilder {
7895
fileName := dep.Name + ".py"
@@ -165,6 +182,9 @@ func (t *targetBuilder) build() *rule.Rule {
165182
if !t.srcs.Empty() {
166183
r.SetAttr("srcs", t.srcs.Values())
167184
}
185+
if !t.pyiSrcs.Empty() {
186+
r.SetAttr("pyi_srcs", t.pyiSrcs.Values())
187+
}
168188
if !t.visibility.Empty() {
169189
r.SetAttr("visibility", t.visibility.Values())
170190
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# gazelle:python_generate_pyi_srcs true
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
2+
3+
# gazelle:python_generate_pyi_srcs true
4+
5+
py_library(
6+
name = "directive_python_generate_pyi_srcs",
7+
srcs = [
8+
"__init__.py",
9+
"bar.py",
10+
"baz.py",
11+
"foo.py",
12+
],
13+
pyi_srcs = [
14+
"baz.pyi",
15+
"foo.pyi",
16+
],
17+
visibility = ["//:__subpackages__"],
18+
)
19+
20+
py_binary(
21+
name = "directive_python_generate_pyi_srcs_bin",
22+
srcs = ["__main__.py"],
23+
main = "__main__.py",
24+
pyi_srcs = ["__main__.pyi"],
25+
visibility = ["//:__subpackages__"],
26+
)
27+
28+
py_test(
29+
name = "directive_python_generate_pyi_srcs_test",
30+
srcs = [
31+
"__test__.py",
32+
"foo_test.py",
33+
],
34+
main = "__test__.py",
35+
pyi_srcs = ["foo_test.pyi"],
36+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Directive: python_generate_pyi_srcs
2+
3+
Test that the `python_generate_pyi_srcs` directive will add `pyi_srcs` to
4+
generated targets and that it can be toggled on/off on a per-package basis.
5+
6+
The root of the test case asserts that the default generation mode (package)
7+
will compile multiple .pyi files into a single py_* target.
8+
9+
The `per_file` directory asserts that the `file` generation mode will attach
10+
a single .pyi file to a given target.
11+
12+
Lastly, the `per_file/turn_off` directory asserts that we can turn off the
13+
directive for subpackages. It continues with per-file generation mode.

gazelle/python/testdata/directive_python_generate_pyi_srcs/WORKSPACE

Whitespace-only changes.

0 commit comments

Comments
 (0)