fix: make conftest.py special with gazelle (#879)

* fix: add conftest.py to py_test generated targets

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: use separate py_library for conftest.py

This allows the conftest.py to be used on sub-directories
as pytest would pick them up.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: add testonly to conftest py_library

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: testonly is a boolean, not a string

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>
diff --git a/gazelle/generate.go b/gazelle/generate.go
index 077acb8..c7b0709 100644
--- a/gazelle/generate.go
+++ b/gazelle/generate.go
@@ -26,6 +26,8 @@
 	pyBinaryEntrypointFilename  = "__main__.py"
 	pyTestEntrypointFilename    = "__test__.py"
 	pyTestEntrypointTargetname  = "__test__"
+	conftestFilename            = "conftest.py"
+	conftestTargetname          = "conftest"
 )
 
 var (
@@ -71,6 +73,7 @@
 	// be generated for this package or not.
 	hasPyTestFile := false
 	hasPyTestTarget := false
+	hasConftestFile := false
 
 	for _, f := range args.RegularFiles {
 		if cfg.IgnoresFile(filepath.Base(f)) {
@@ -81,6 +84,8 @@
 			hasPyBinary = true
 		} else if !hasPyTestFile && f == pyTestEntrypointFilename {
 			hasPyTestFile = true
+		} else if f == conftestFilename {
+			hasConftestFile = true
 		} else if strings.HasSuffix(f, "_test.py") || (strings.HasPrefix(f, "test_") && ext == ".py") {
 			pyTestFilenames.Add(f)
 		} else if ext == ".py" {
@@ -196,10 +201,10 @@
 
 		pyLibraryTargetName := cfg.RenderLibraryName(packageName)
 
-		// Check if a target with the same name we are generating alredy exists,
-		// and if it is of a different kind from the one we are generating. If
-		// so, we have to throw an error since Gazelle won't generate it
-		// correctly.
+		// Check if a target with the same name we are generating already
+		// exists, and if it is of a different kind from the one we are
+		// generating. If so, we have to throw an error since Gazelle won't
+		// generate it correctly.
 		if args.File != nil {
 			for _, t := range args.File.Rules {
 				if t.Name() == pyLibraryTargetName && t.Kind() != pyLibraryKind {
@@ -233,10 +238,10 @@
 
 		pyBinaryTargetName := cfg.RenderBinaryName(packageName)
 
-		// Check if a target with the same name we are generating alredy exists,
-		// and if it is of a different kind from the one we are generating. If
-		// so, we have to throw an error since Gazelle won't generate it
-		// correctly.
+		// Check if a target with the same name we are generating already
+		// exists, and if it is of a different kind from the one we are
+		// generating. If so, we have to throw an error since Gazelle won't
+		// generate it correctly.
 		if args.File != nil {
 			for _, t := range args.File.Rules {
 				if t.Name() == pyBinaryTargetName && t.Kind() != pyBinaryKind {
@@ -267,6 +272,43 @@
 		result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey))
 	}
 
+	var conftest *rule.Rule
+	if hasConftestFile {
+		deps, err := parser.parseSingle(conftestFilename)
+		if err != nil {
+			log.Fatalf("ERROR: %v\n", err)
+		}
+
+		// Check if a target with the same name we are generating already
+		// exists, and if it is of a different kind from the one we are
+		// generating. If so, we have to throw an error since Gazelle won't
+		// generate it correctly.
+		if args.File != nil {
+			for _, t := range args.File.Rules {
+				if t.Name() == conftestTargetname && t.Kind() != pyLibraryKind {
+					fqTarget := label.New("", args.Rel, conftestTargetname)
+					err := fmt.Errorf("failed to generate target %q of kind %q: "+
+						"a target of kind %q with the same name already exists.",
+						fqTarget.String(), pyLibraryKind, t.Kind())
+					collisionErrors.Add(err)
+				}
+			}
+		}
+
+		conftestTarget := newTargetBuilder(pyLibraryKind, conftestTargetname, pythonProjectRoot, args.Rel).
+			setUUID(uuid.Must(uuid.NewUUID()).String()).
+			addSrc(conftestFilename).
+			addModuleDependencies(deps).
+			addVisibility(visibility).
+			setTestonly().
+			generateImportsAttribute()
+
+		conftest = conftestTarget.build()
+
+		result.Gen = append(result.Gen, conftest)
+		result.Imports = append(result.Imports, conftest.PrivateAttr(config.GazelleImportsKey))
+	}
+
 	if hasPyTestFile || hasPyTestTarget {
 		if hasPyTestFile {
 			// Only add the pyTestEntrypointFilename to the pyTestFilenames if
@@ -280,10 +322,10 @@
 
 		pyTestTargetName := cfg.RenderTestName(packageName)
 
-		// Check if a target with the same name we are generating alredy exists,
-		// and if it is of a different kind from the one we are generating. If
-		// so, we have to throw an error since Gazelle won't generate it
-		// correctly.
+		// Check if a target with the same name we are generating already
+		// exists, and if it is of a different kind from the one we are
+		// generating. If so, we have to throw an error since Gazelle won't
+		// generate it correctly.
 		if args.File != nil {
 			for _, t := range args.File.Rules {
 				if t.Name() == pyTestTargetName && t.Kind() != pyTestKind {
@@ -317,6 +359,10 @@
 			pyTestTarget.addModuleDependency(module{Name: pyLibrary.PrivateAttr(uuidKey).(string)})
 		}
 
+		if conftest != nil {
+			pyTestTarget.addModuleDependency(module{Name: conftest.PrivateAttr(uuidKey).(string)})
+		}
+
 		pyTest := pyTestTarget.build()
 
 		result.Gen = append(result.Gen, pyTest)
diff --git a/gazelle/target.go b/gazelle/target.go
index 2b26067..eef3aed 100644
--- a/gazelle/target.go
+++ b/gazelle/target.go
@@ -22,6 +22,7 @@
 	visibility        *treeset.Set
 	main              *string
 	imports           []string
+	testonly          bool
 }
 
 // newTargetBuilder constructs a new targetBuilder.
@@ -96,6 +97,12 @@
 	return t
 }
 
+// setTestonly sets the testonly attribute to true.
+func (t *targetBuilder) setTestonly() *targetBuilder {
+	t.testonly = true
+	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
@@ -131,6 +138,9 @@
 	if !t.deps.Empty() {
 		r.SetPrivateAttr(config.GazelleImportsKey, t.deps)
 	}
+	if t.testonly {
+		r.SetAttr("testonly", true)
+	}
 	r.SetPrivateAttr(resolvedDepsKey, t.resolvedDeps)
 	return r
 }
diff --git a/gazelle/testdata/simple_test_with_conftest/BUILD.in b/gazelle/testdata/simple_test_with_conftest/BUILD.in
new file mode 100644
index 0000000..3f2beb3
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/BUILD.in
@@ -0,0 +1 @@
+load("@rules_python//python:defs.bzl", "py_library")
diff --git a/gazelle/testdata/simple_test_with_conftest/BUILD.out b/gazelle/testdata/simple_test_with_conftest/BUILD.out
new file mode 100644
index 0000000..18079bf
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/BUILD.out
@@ -0,0 +1,27 @@
+load("@rules_python//python:defs.bzl", "py_library", "py_test")
+
+py_library(
+    name = "simple_test_with_conftest",
+    srcs = [
+        "__init__.py",
+        "foo.py",
+    ],
+    visibility = ["//:__subpackages__"],
+)
+
+py_library(
+    name = "conftest",
+    testonly = True,
+    srcs = ["conftest.py"],
+    visibility = ["//:__subpackages__"],
+)
+
+py_test(
+    name = "simple_test_with_conftest_test",
+    srcs = ["__test__.py"],
+    main = "__test__.py",
+    deps = [
+        ":conftest",
+        ":simple_test_with_conftest",
+    ],
+)
diff --git a/gazelle/testdata/simple_test_with_conftest/README.md b/gazelle/testdata/simple_test_with_conftest/README.md
new file mode 100644
index 0000000..0ff245f
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/README.md
@@ -0,0 +1,4 @@
+# Simple test with conftest.py
+
+This test case asserts that a simple `py_test` is generated as expected when a
+`conftest.py` is present.
diff --git a/gazelle/testdata/simple_test_with_conftest/WORKSPACE b/gazelle/testdata/simple_test_with_conftest/WORKSPACE
new file mode 100644
index 0000000..faff6af
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/WORKSPACE
@@ -0,0 +1 @@
+# This is a Bazel workspace for the Gazelle test data.
diff --git a/gazelle/testdata/simple_test_with_conftest/__init__.py b/gazelle/testdata/simple_test_with_conftest/__init__.py
new file mode 100644
index 0000000..6a49193
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/__init__.py
@@ -0,0 +1,3 @@
+from foo import foo
+
+_ = foo
diff --git a/gazelle/testdata/simple_test_with_conftest/__test__.py b/gazelle/testdata/simple_test_with_conftest/__test__.py
new file mode 100644
index 0000000..d6085a4
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/__test__.py
@@ -0,0 +1,12 @@
+import unittest
+
+from __init__ import foo
+
+
+class FooTest(unittest.TestCase):
+    def test_foo(self):
+        self.assertEqual("foo", foo())
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/gazelle/testdata/simple_test_with_conftest/conftest.py b/gazelle/testdata/simple_test_with_conftest/conftest.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/conftest.py
diff --git a/gazelle/testdata/simple_test_with_conftest/foo.py b/gazelle/testdata/simple_test_with_conftest/foo.py
new file mode 100644
index 0000000..cf68624
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/foo.py
@@ -0,0 +1,2 @@
+def foo():
+    return "foo"
diff --git a/gazelle/testdata/simple_test_with_conftest/test.yaml b/gazelle/testdata/simple_test_with_conftest/test.yaml
new file mode 100644
index 0000000..36dd656
--- /dev/null
+++ b/gazelle/testdata/simple_test_with_conftest/test.yaml
@@ -0,0 +1,3 @@
+---
+expect:
+  exit_code: 0