Skip to content

Commit a97b98c

Browse files
dougthor42aignas
andauthored
feat(gazelle): Add include_pytest_conftest annotation (#3080)
Fixes #3076. Add a new gazelle annotation `include_pytest_conftest`. When unset or true, the gazelle behavior is unchanged. When false, gazelle will *not* inject the `:conftest` dependency to py_test targets. One of the refactorings that is done to support this is to pass around an `annotations` struct in `target.targetBuilder`. This will also open up support for other annotations in the future. --------- Co-authored-by: Ignas Anikevicius <[email protected]>
1 parent dd6550f commit a97b98c

26 files changed

+275
-6
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ END_UNRELEASED_TEMPLATE
106106
* 3.12.11
107107
* 3.13.5
108108
* 3.14.0b3
109+
* (gazelle): New annotation `gazelle:include_pytest_conftest`. When not set (the
110+
default) or `true`, gazelle will inject any `conftest.py` file found in the same
111+
directory as a {obj}`py_test` target to that {obj}`py_test` target's `deps`.
112+
This behavior is unchanged from previous versions. When `false`, the `:conftest`
113+
dep is not added to the {obj}`py_test` target.
109114
* (gazelle) New directive `gazelle:python_generate_proto`; when `true`,
110115
Gazelle generates `py_proto_library` rules for `proto_library`. `false` by default.
111116

gazelle/README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,8 @@ The annotations are:
550550
| Tells Gazelle to ignore import statements. `imports` is a comma-separated list of imports to ignore. | |
551551
| [`# gazelle:include_dep targets`](#annotation-include_dep) | N/A |
552552
| Tells Gazelle to include a set of dependencies, even if they are not imported in a Python module. `targets` is a comma-separated list of target names to include as dependencies. | |
553+
| [`# gazelle:include_pytest_conftest bool`](#annotation-include_pytest_conftest) | N/A |
554+
| Whether or not to include a sibling `:conftest` target in the deps of a `py_test` target. Default behaviour is to include `:conftest`. | |
553555

554556

555557
#### Annotation: `ignore`
@@ -622,6 +624,89 @@ deps = [
622624
]
623625
```
624626

627+
#### Annotation: `include_pytest_conftest`
628+
629+
Added in [#3080][gh3080].
630+
631+
[gh3080]: https://github.com/bazel-contrib/rules_python/pull/3080
632+
633+
This annotation accepts any string that can be parsed by go's
634+
[`strconv.ParseBool`][ParseBool]. If an unparsable string is passed, the
635+
annotation is ignored.
636+
637+
[ParseBool]: https://pkg.go.dev/strconv#ParseBool
638+
639+
Starting with [`rules_python` 0.14.0][rules-python-0.14.0] (specifically [PR #879][gh879]),
640+
Gazelle will include a `:conftest` dependency to an `py_test` target that is in
641+
the same directory as `conftest.py`.
642+
643+
[rules-python-0.14.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.14.0
644+
[gh879]: https://github.com/bazel-contrib/rules_python/pull/879
645+
646+
This annotation allows users to adjust that behavior. To disable the behavior, set
647+
the annotation value to "false":
648+
649+
```
650+
# some_file_test.py
651+
# gazelle:include_pytest_conftest false
652+
```
653+
654+
Example:
655+
656+
Given a directory tree like:
657+
658+
```
659+
.
660+
├── BUILD.bazel
661+
├── conftest.py
662+
└── some_file_test.py
663+
```
664+
665+
The default Gazelle behavior would create:
666+
667+
```starlark
668+
py_library(
669+
name = "conftest",
670+
testonly = True,
671+
srcs = ["conftest.py"],
672+
visibility = ["//:__subpackages__"],
673+
)
674+
675+
py_test(
676+
name = "some_file_test",
677+
srcs = ["some_file_test.py"],
678+
deps = [":conftest"],
679+
)
680+
```
681+
682+
When `# gazelle:include_pytest_conftest false` is found in `some_file_test.py`
683+
684+
```python
685+
# some_file_test.py
686+
# gazelle:include_pytest_conftest false
687+
```
688+
689+
Gazelle will generate:
690+
691+
```starlark
692+
py_library(
693+
name = "conftest",
694+
testonly = True,
695+
srcs = ["conftest.py"],
696+
visibility = ["//:__subpackages__"],
697+
)
698+
699+
py_test(
700+
name = "some_file_test",
701+
srcs = ["some_file_test.py"],
702+
)
703+
```
704+
705+
See [Issue #3076][gh3076] for more information.
706+
707+
[gh3076]: https://github.com/bazel-contrib/rules_python/issues/3076
708+
709+
625710
#### Directive: `experimental_allow_relative_imports`
626711
Enables experimental support for resolving relative imports in
627712
`python_generation_mode package`.

gazelle/python/generate.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,9 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
264264
addSrc(filename).
265265
addModuleDependencies(mainModules[filename]).
266266
addResolvedDependencies(annotations.includeDeps).
267-
generateImportsAttribute().build()
267+
generateImportsAttribute().
268+
setAnnotations(*annotations).
269+
build()
268270
result.Gen = append(result.Gen, pyBinary)
269271
result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey))
270272
}
@@ -305,6 +307,7 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
305307
addModuleDependencies(allDeps).
306308
addResolvedDependencies(annotations.includeDeps).
307309
generateImportsAttribute().
310+
setAnnotations(*annotations).
308311
build()
309312

310313
if pyLibrary.IsEmpty(py.Kinds()[pyLibrary.Kind()]) {
@@ -357,6 +360,7 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
357360
addSrc(pyBinaryEntrypointFilename).
358361
addModuleDependencies(deps).
359362
addResolvedDependencies(annotations.includeDeps).
363+
setAnnotations(*annotations).
360364
generateImportsAttribute()
361365

362366
pyBinary := pyBinaryTarget.build()
@@ -387,6 +391,7 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
387391
addSrc(conftestFilename).
388392
addModuleDependencies(deps).
389393
addResolvedDependencies(annotations.includeDeps).
394+
setAnnotations(*annotations).
390395
addVisibility(visibility).
391396
setTestonly().
392397
generateImportsAttribute()
@@ -418,6 +423,7 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
418423
addSrcs(srcs).
419424
addModuleDependencies(deps).
420425
addResolvedDependencies(annotations.includeDeps).
426+
setAnnotations(*annotations).
421427
generateImportsAttribute()
422428
}
423429
if (!cfg.PerPackageGenerationRequireTestEntryPoint() || hasPyTestEntryPointFile || hasPyTestEntryPointTarget || cfg.CoarseGrainedGeneration()) && !cfg.PerFileGeneration() {
@@ -470,7 +476,14 @@ func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateRes
470476

471477
for _, pyTestTarget := range pyTestTargets {
472478
if conftest != nil {
473-
pyTestTarget.addModuleDependency(Module{Name: strings.TrimSuffix(conftestFilename, ".py")})
479+
conftestModule := Module{Name: strings.TrimSuffix(conftestFilename, ".py")}
480+
if pyTestTarget.annotations.includePytestConftest == nil {
481+
// unset; default behavior
482+
pyTestTarget.addModuleDependency(conftestModule)
483+
} else if *pyTestTarget.annotations.includePytestConftest {
484+
// set; add if true, do not add if false
485+
pyTestTarget.addModuleDependency(conftestModule)
486+
}
474487
}
475488
pyTest := pyTestTarget.build()
476489

gazelle/python/parser.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"context"
1919
_ "embed"
2020
"fmt"
21+
"log"
22+
"strconv"
2123
"strings"
2224

2325
"github.com/emirpasic/gods/sets/treeset"
@@ -123,6 +125,7 @@ func (p *python3Parser) parse(pyFilenames *treeset.Set) (*treeset.Set, map[strin
123125
allAnnotations.ignore[k] = v
124126
}
125127
allAnnotations.includeDeps = append(allAnnotations.includeDeps, annotations.includeDeps...)
128+
allAnnotations.includePytestConftest = annotations.includePytestConftest
126129
}
127130

128131
allAnnotations.includeDeps = removeDupesFromStringTreeSetSlice(allAnnotations.includeDeps)
@@ -183,8 +186,12 @@ const (
183186
// The Gazelle annotation prefix.
184187
annotationPrefix string = "gazelle:"
185188
// The ignore annotation kind. E.g. '# gazelle:ignore <module_name>'.
186-
annotationKindIgnore annotationKind = "ignore"
187-
annotationKindIncludeDep annotationKind = "include_dep"
189+
annotationKindIgnore annotationKind = "ignore"
190+
// Force a particular target to be added to `deps`. Multiple invocations are
191+
// accumulated and the value can be comma separated.
192+
// Eg: '# gazelle:include_dep //foo/bar:baz,@repo//:target
193+
annotationKindIncludeDep annotationKind = "include_dep"
194+
annotationKindIncludePytestConftest annotationKind = "include_pytest_conftest"
188195
)
189196

190197
// Comment represents a Python comment.
@@ -222,13 +229,18 @@ type annotations struct {
222229
ignore map[string]struct{}
223230
// Labels that Gazelle should include as deps of the generated target.
224231
includeDeps []string
232+
// Whether the conftest.py file, found in the same directory as the current
233+
// python test file, should be added to the py_test target's `deps` attribute.
234+
// A *bool is used so that we can handle the "not set" state.
235+
includePytestConftest *bool
225236
}
226237

227238
// annotationsFromComments returns all the annotations parsed out of the
228239
// comments of a Python module.
229240
func annotationsFromComments(comments []Comment) (*annotations, error) {
230241
ignore := make(map[string]struct{})
231242
includeDeps := []string{}
243+
var includePytestConftest *bool
232244
for _, comment := range comments {
233245
annotation, err := comment.asAnnotation()
234246
if err != nil {
@@ -255,11 +267,21 @@ func annotationsFromComments(comments []Comment) (*annotations, error) {
255267
includeDeps = append(includeDeps, t)
256268
}
257269
}
270+
if annotation.kind == annotationKindIncludePytestConftest {
271+
val := annotation.value
272+
parsedVal, err := strconv.ParseBool(val)
273+
if err != nil {
274+
log.Printf("WARNING: unable to cast %q to bool in %q. Ignoring annotation", val, comment)
275+
continue
276+
}
277+
includePytestConftest = &parsedVal
278+
}
258279
}
259280
}
260281
return &annotations{
261-
ignore: ignore,
262-
includeDeps: includeDeps,
282+
ignore: ignore,
283+
includeDeps: includeDeps,
284+
includePytestConftest: includePytestConftest,
263285
}, nil
264286
}
265287

gazelle/python/target.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type targetBuilder struct {
3737
main *string
3838
imports []string
3939
testonly bool
40+
annotations *annotations
4041
}
4142

4243
// newTargetBuilder constructs a new targetBuilder.
@@ -51,6 +52,7 @@ func newTargetBuilder(kind, name, pythonProjectRoot, bzlPackage string, siblingS
5152
deps: treeset.NewWith(moduleComparator),
5253
resolvedDeps: treeset.NewWith(godsutils.StringComparator),
5354
visibility: treeset.NewWith(godsutils.StringComparator),
55+
annotations: new(annotations),
5456
}
5557
}
5658

@@ -130,6 +132,13 @@ func (t *targetBuilder) setTestonly() *targetBuilder {
130132
return t
131133
}
132134

135+
// setAnnotations sets the annotations attribute on the target.
136+
func (t *targetBuilder) setAnnotations(val annotations) *targetBuilder {
137+
t.annotations = &val
138+
return t
139+
}
140+
141+
133142
// generateImportsAttribute generates the imports attribute.
134143
// These are a list of import directories to be added to the PYTHONPATH. In our
135144
// case, the value we add is on Bazel sub-packages to be able to perform imports
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Annotation: Include Pytest Conftest
2+
3+
Validate that the `# gazelle:include_pytest_conftest` annotation follows
4+
this logic:
5+
6+
+ When a `conftest.py` file does not exist:
7+
+ all values have no affect
8+
+ When a `conftest.py` file does exist:
9+
+ Truthy values add `:conftest` to `deps`.
10+
+ Falsey values do not add `:conftest` to `deps`.
11+
+ Unset (no annotation) performs the default action.
12+
13+
Additionally, we test that:
14+
15+
+ invalid values (eg `foo`) print a warning and then act as if
16+
the annotation was not present.
17+
+ last annotation (highest line number) wins.
18+
+ the annotation has no effect on non-test files/targets.
19+
+ the `include_dep` can still inject `:conftest` even when `include_pytest_conftest`
20+
is false.
21+
+ `import conftest` will still add the dep even when `include_pytest_conftest` is
22+
false.
23+
24+
An annotation without a value is not tested, as that's part of the core
25+
annotation framework and not specific to this annotation.

gazelle/python/testdata/annotation_include_pytest_conftest/WORKSPACE

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
expect:
3+
stderr: |
4+
gazelle: WARNING: unable to cast "foo" to bool in "# gazelle:include_pytest_conftest foo". Ignoring annotation
5+
exit_code: 0

gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.in

Whitespace-only changes.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
2+
3+
py_binary(
4+
name = "binary",
5+
srcs = ["binary.py"],
6+
visibility = ["//:__subpackages__"],
7+
)
8+
9+
py_library(
10+
name = "with_conftest",
11+
srcs = [
12+
"binary.py",
13+
"library.py",
14+
],
15+
visibility = ["//:__subpackages__"],
16+
)
17+
18+
py_library(
19+
name = "conftest",
20+
testonly = True,
21+
srcs = ["conftest.py"],
22+
visibility = ["//:__subpackages__"],
23+
)
24+
25+
py_test(
26+
name = "bad_value_test",
27+
srcs = ["bad_value_test.py"],
28+
deps = [":conftest"],
29+
)
30+
31+
py_test(
32+
name = "conftest_imported_test",
33+
srcs = ["conftest_imported_test.py"],
34+
deps = [":conftest"],
35+
)
36+
37+
py_test(
38+
name = "conftest_included_test",
39+
srcs = ["conftest_included_test.py"],
40+
deps = [":conftest"],
41+
)
42+
43+
py_test(
44+
name = "false_test",
45+
srcs = ["false_test.py"],
46+
)
47+
48+
py_test(
49+
name = "falsey_test",
50+
srcs = ["falsey_test.py"],
51+
)
52+
53+
py_test(
54+
name = "last_value_wins_test",
55+
srcs = ["last_value_wins_test.py"],
56+
)
57+
58+
py_test(
59+
name = "true_test",
60+
srcs = ["true_test.py"],
61+
deps = [":conftest"],
62+
)
63+
64+
py_test(
65+
name = "unset_test",
66+
srcs = ["unset_test.py"],
67+
deps = [":conftest"],
68+
)

0 commit comments

Comments
 (0)