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 <240938+aignas@users.noreply.github.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e74f14b..b65a233 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -106,6 +106,11 @@
* 3.12.11
* 3.13.5
* 3.14.0b3
+* (gazelle): New annotation `gazelle:include_pytest_conftest`. When not set (the
+ default) or `true`, gazelle will inject any `conftest.py` file found in the same
+ directory as a {obj}`py_test` target to that {obj}`py_test` target's `deps`.
+ This behavior is unchanged from previous versions. When `false`, the `:conftest`
+ dep is not added to the {obj}`py_test` target.
* (gazelle) New directive `gazelle:python_generate_proto`; when `true`,
Gazelle generates `py_proto_library` rules for `proto_library`. `false` by default.
diff --git a/gazelle/README.md b/gazelle/README.md
index 35a1e4f..cf91461 100644
--- a/gazelle/README.md
+++ b/gazelle/README.md
@@ -550,6 +550,8 @@
| Tells Gazelle to ignore import statements. `imports` is a comma-separated list of imports to ignore. | |
| [`# gazelle:include_dep targets`](#annotation-include_dep) | N/A |
| 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. | |
+| [`# gazelle:include_pytest_conftest bool`](#annotation-include_pytest_conftest) | N/A |
+| Whether or not to include a sibling `:conftest` target in the deps of a `py_test` target. Default behaviour is to include `:conftest`. | |
#### Annotation: `ignore`
@@ -622,6 +624,89 @@
]
```
+#### Annotation: `include_pytest_conftest`
+
+Added in [#3080][gh3080].
+
+[gh3080]: https://github.com/bazel-contrib/rules_python/pull/3080
+
+This annotation accepts any string that can be parsed by go's
+[`strconv.ParseBool`][ParseBool]. If an unparsable string is passed, the
+annotation is ignored.
+
+[ParseBool]: https://pkg.go.dev/strconv#ParseBool
+
+Starting with [`rules_python` 0.14.0][rules-python-0.14.0] (specifically [PR #879][gh879]),
+Gazelle will include a `:conftest` dependency to an `py_test` target that is in
+the same directory as `conftest.py`.
+
+[rules-python-0.14.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.14.0
+[gh879]: https://github.com/bazel-contrib/rules_python/pull/879
+
+This annotation allows users to adjust that behavior. To disable the behavior, set
+the annotation value to "false":
+
+```
+# some_file_test.py
+# gazelle:include_pytest_conftest false
+```
+
+Example:
+
+Given a directory tree like:
+
+```
+.
+├── BUILD.bazel
+├── conftest.py
+└── some_file_test.py
+```
+
+The default Gazelle behavior would create:
+
+```starlark
+py_library(
+ name = "conftest",
+ testonly = True,
+ srcs = ["conftest.py"],
+ visibility = ["//:__subpackages__"],
+)
+
+py_test(
+ name = "some_file_test",
+ srcs = ["some_file_test.py"],
+ deps = [":conftest"],
+)
+```
+
+When `# gazelle:include_pytest_conftest false` is found in `some_file_test.py`
+
+```python
+# some_file_test.py
+# gazelle:include_pytest_conftest false
+```
+
+Gazelle will generate:
+
+```starlark
+py_library(
+ name = "conftest",
+ testonly = True,
+ srcs = ["conftest.py"],
+ visibility = ["//:__subpackages__"],
+)
+
+py_test(
+ name = "some_file_test",
+ srcs = ["some_file_test.py"],
+)
+```
+
+See [Issue #3076][gh3076] for more information.
+
+[gh3076]: https://github.com/bazel-contrib/rules_python/issues/3076
+
+
#### Directive: `experimental_allow_relative_imports`
Enables experimental support for resolving relative imports in
`python_generation_mode package`.
diff --git a/gazelle/python/generate.go b/gazelle/python/generate.go
index 3437435..279bee6 100644
--- a/gazelle/python/generate.go
+++ b/gazelle/python/generate.go
@@ -264,7 +264,9 @@
addSrc(filename).
addModuleDependencies(mainModules[filename]).
addResolvedDependencies(annotations.includeDeps).
- generateImportsAttribute().build()
+ generateImportsAttribute().
+ setAnnotations(*annotations).
+ build()
result.Gen = append(result.Gen, pyBinary)
result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey))
}
@@ -305,6 +307,7 @@
addModuleDependencies(allDeps).
addResolvedDependencies(annotations.includeDeps).
generateImportsAttribute().
+ setAnnotations(*annotations).
build()
if pyLibrary.IsEmpty(py.Kinds()[pyLibrary.Kind()]) {
@@ -357,6 +360,7 @@
addSrc(pyBinaryEntrypointFilename).
addModuleDependencies(deps).
addResolvedDependencies(annotations.includeDeps).
+ setAnnotations(*annotations).
generateImportsAttribute()
pyBinary := pyBinaryTarget.build()
@@ -387,6 +391,7 @@
addSrc(conftestFilename).
addModuleDependencies(deps).
addResolvedDependencies(annotations.includeDeps).
+ setAnnotations(*annotations).
addVisibility(visibility).
setTestonly().
generateImportsAttribute()
@@ -418,6 +423,7 @@
addSrcs(srcs).
addModuleDependencies(deps).
addResolvedDependencies(annotations.includeDeps).
+ setAnnotations(*annotations).
generateImportsAttribute()
}
if (!cfg.PerPackageGenerationRequireTestEntryPoint() || hasPyTestEntryPointFile || hasPyTestEntryPointTarget || cfg.CoarseGrainedGeneration()) && !cfg.PerFileGeneration() {
@@ -470,7 +476,14 @@
for _, pyTestTarget := range pyTestTargets {
if conftest != nil {
- pyTestTarget.addModuleDependency(Module{Name: strings.TrimSuffix(conftestFilename, ".py")})
+ conftestModule := Module{Name: strings.TrimSuffix(conftestFilename, ".py")}
+ if pyTestTarget.annotations.includePytestConftest == nil {
+ // unset; default behavior
+ pyTestTarget.addModuleDependency(conftestModule)
+ } else if *pyTestTarget.annotations.includePytestConftest {
+ // set; add if true, do not add if false
+ pyTestTarget.addModuleDependency(conftestModule)
+ }
}
pyTest := pyTestTarget.build()
diff --git a/gazelle/python/parser.go b/gazelle/python/parser.go
index 11e01db..3d0dbe7 100644
--- a/gazelle/python/parser.go
+++ b/gazelle/python/parser.go
@@ -18,6 +18,8 @@
"context"
_ "embed"
"fmt"
+ "log"
+ "strconv"
"strings"
"github.com/emirpasic/gods/sets/treeset"
@@ -123,6 +125,7 @@
allAnnotations.ignore[k] = v
}
allAnnotations.includeDeps = append(allAnnotations.includeDeps, annotations.includeDeps...)
+ allAnnotations.includePytestConftest = annotations.includePytestConftest
}
allAnnotations.includeDeps = removeDupesFromStringTreeSetSlice(allAnnotations.includeDeps)
@@ -183,8 +186,12 @@
// The Gazelle annotation prefix.
annotationPrefix string = "gazelle:"
// The ignore annotation kind. E.g. '# gazelle:ignore <module_name>'.
- annotationKindIgnore annotationKind = "ignore"
- annotationKindIncludeDep annotationKind = "include_dep"
+ annotationKindIgnore annotationKind = "ignore"
+ // Force a particular target to be added to `deps`. Multiple invocations are
+ // accumulated and the value can be comma separated.
+ // Eg: '# gazelle:include_dep //foo/bar:baz,@repo//:target
+ annotationKindIncludeDep annotationKind = "include_dep"
+ annotationKindIncludePytestConftest annotationKind = "include_pytest_conftest"
)
// Comment represents a Python comment.
@@ -222,6 +229,10 @@
ignore map[string]struct{}
// Labels that Gazelle should include as deps of the generated target.
includeDeps []string
+ // Whether the conftest.py file, found in the same directory as the current
+ // python test file, should be added to the py_test target's `deps` attribute.
+ // A *bool is used so that we can handle the "not set" state.
+ includePytestConftest *bool
}
// annotationsFromComments returns all the annotations parsed out of the
@@ -229,6 +240,7 @@
func annotationsFromComments(comments []Comment) (*annotations, error) {
ignore := make(map[string]struct{})
includeDeps := []string{}
+ var includePytestConftest *bool
for _, comment := range comments {
annotation, err := comment.asAnnotation()
if err != nil {
@@ -255,11 +267,21 @@
includeDeps = append(includeDeps, t)
}
}
+ if annotation.kind == annotationKindIncludePytestConftest {
+ val := annotation.value
+ parsedVal, err := strconv.ParseBool(val)
+ if err != nil {
+ log.Printf("WARNING: unable to cast %q to bool in %q. Ignoring annotation", val, comment)
+ continue
+ }
+ includePytestConftest = &parsedVal
+ }
}
}
return &annotations{
- ignore: ignore,
- includeDeps: includeDeps,
+ ignore: ignore,
+ includeDeps: includeDeps,
+ includePytestConftest: includePytestConftest,
}, nil
}
diff --git a/gazelle/python/target.go b/gazelle/python/target.go
index 06b653d..6e6c3f4 100644
--- a/gazelle/python/target.go
+++ b/gazelle/python/target.go
@@ -37,6 +37,7 @@
main *string
imports []string
testonly bool
+ annotations *annotations
}
// newTargetBuilder constructs a new targetBuilder.
@@ -51,6 +52,7 @@
deps: treeset.NewWith(moduleComparator),
resolvedDeps: treeset.NewWith(godsutils.StringComparator),
visibility: treeset.NewWith(godsutils.StringComparator),
+ annotations: new(annotations),
}
}
@@ -130,6 +132,13 @@
return t
}
+// setAnnotations sets the annotations attribute on the target.
+func (t *targetBuilder) setAnnotations(val annotations) *targetBuilder {
+ t.annotations = &val
+ return t
+}
+
+
// generateImportsAttribute generates the imports attribute.
// These are a list of import directories to be added to the PYTHONPATH. In our
// case, the value we add is on Bazel sub-packages to be able to perform imports
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/README.md b/gazelle/python/testdata/annotation_include_pytest_conftest/README.md
new file mode 100644
index 0000000..6a347d1
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/README.md
@@ -0,0 +1,25 @@
+# Annotation: Include Pytest Conftest
+
+Validate that the `# gazelle:include_pytest_conftest` annotation follows
+this logic:
+
++ When a `conftest.py` file does not exist:
+ + all values have no affect
++ When a `conftest.py` file does exist:
+ + Truthy values add `:conftest` to `deps`.
+ + Falsey values do not add `:conftest` to `deps`.
+ + Unset (no annotation) performs the default action.
+
+Additionally, we test that:
+
++ invalid values (eg `foo`) print a warning and then act as if
+ the annotation was not present.
++ last annotation (highest line number) wins.
++ the annotation has no effect on non-test files/targets.
++ the `include_dep` can still inject `:conftest` even when `include_pytest_conftest`
+ is false.
++ `import conftest` will still add the dep even when `include_pytest_conftest` is
+ false.
+
+An annotation without a value is not tested, as that's part of the core
+annotation framework and not specific to this annotation.
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/WORKSPACE b/gazelle/python/testdata/annotation_include_pytest_conftest/WORKSPACE
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/WORKSPACE
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/test.yaml b/gazelle/python/testdata/annotation_include_pytest_conftest/test.yaml
new file mode 100644
index 0000000..e643d0e
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/test.yaml
@@ -0,0 +1,5 @@
+---
+expect:
+ stderr: |
+ gazelle: WARNING: unable to cast "foo" to bool in "# gazelle:include_pytest_conftest foo". Ignoring annotation
+ exit_code: 0
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.in b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.in
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.in
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.out b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.out
new file mode 100644
index 0000000..6069535
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/BUILD.out
@@ -0,0 +1,68 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
+
+py_binary(
+ name = "binary",
+ srcs = ["binary.py"],
+ visibility = ["//:__subpackages__"],
+)
+
+py_library(
+ name = "with_conftest",
+ srcs = [
+ "binary.py",
+ "library.py",
+ ],
+ visibility = ["//:__subpackages__"],
+)
+
+py_library(
+ name = "conftest",
+ testonly = True,
+ srcs = ["conftest.py"],
+ visibility = ["//:__subpackages__"],
+)
+
+py_test(
+ name = "bad_value_test",
+ srcs = ["bad_value_test.py"],
+ deps = [":conftest"],
+)
+
+py_test(
+ name = "conftest_imported_test",
+ srcs = ["conftest_imported_test.py"],
+ deps = [":conftest"],
+)
+
+py_test(
+ name = "conftest_included_test",
+ srcs = ["conftest_included_test.py"],
+ deps = [":conftest"],
+)
+
+py_test(
+ name = "false_test",
+ srcs = ["false_test.py"],
+)
+
+py_test(
+ name = "falsey_test",
+ srcs = ["falsey_test.py"],
+)
+
+py_test(
+ name = "last_value_wins_test",
+ srcs = ["last_value_wins_test.py"],
+)
+
+py_test(
+ name = "true_test",
+ srcs = ["true_test.py"],
+ deps = [":conftest"],
+)
+
+py_test(
+ name = "unset_test",
+ srcs = ["unset_test.py"],
+ deps = [":conftest"],
+)
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/bad_value_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/bad_value_test.py
new file mode 100644
index 0000000..af2e8c5
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/bad_value_test.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest foo
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/binary.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/binary.py
new file mode 100644
index 0000000..d6dc841
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/binary.py
@@ -0,0 +1,3 @@
+# gazelle:include_pytest_conftest true
+if __name__ == "__main__":
+ pass
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest.py
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest_imported_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest_imported_test.py
new file mode 100644
index 0000000..2c72ca4
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest_imported_test.py
@@ -0,0 +1,3 @@
+import conftest
+
+# gazelle:include_pytest_conftest false
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest_included_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest_included_test.py
new file mode 100644
index 0000000..c942bfb
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/conftest_included_test.py
@@ -0,0 +1,2 @@
+# gazelle:include_dep :conftest
+# gazelle:include_pytest_conftest false
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/false_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/false_test.py
new file mode 100644
index 0000000..ba71a28
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/false_test.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest false
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/falsey_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/falsey_test.py
new file mode 100644
index 0000000..c4387b3a
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/falsey_test.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest 0
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/last_value_wins_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/last_value_wins_test.py
new file mode 100644
index 0000000..6ffc06f
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/last_value_wins_test.py
@@ -0,0 +1,6 @@
+# gazelle:include_pytest_conftest true
+# gazelle:include_pytest_conftest TRUE
+# gazelle:include_pytest_conftest False
+# gazelle:include_pytest_conftest 0
+# gazelle:include_pytest_conftest 1
+# gazelle:include_pytest_conftest F
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/library.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/library.py
new file mode 100644
index 0000000..b2d1035
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/library.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest true
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/true_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/true_test.py
new file mode 100644
index 0000000..b2d1035
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/true_test.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest true
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/unset_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/unset_test.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/with_conftest/unset_test.py
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/BUILD.in b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/BUILD.in
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/BUILD.in
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/BUILD.out b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/BUILD.out
new file mode 100644
index 0000000..0138334
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/BUILD.out
@@ -0,0 +1,16 @@
+load("@rules_python//python:defs.bzl", "py_test")
+
+py_test(
+ name = "false_test",
+ srcs = ["false_test.py"],
+)
+
+py_test(
+ name = "true_test",
+ srcs = ["true_test.py"],
+)
+
+py_test(
+ name = "unset_test",
+ srcs = ["unset_test.py"],
+)
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/false_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/false_test.py
new file mode 100644
index 0000000..ba71a28
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/false_test.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest false
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/true_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/true_test.py
new file mode 100644
index 0000000..b2d1035
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/true_test.py
@@ -0,0 +1 @@
+# gazelle:include_pytest_conftest true
diff --git a/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/unset_test.py b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/unset_test.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/annotation_include_pytest_conftest/without_conftest/unset_test.py