feat(gazelle): Add "python_visibility" directive that appends additional visibility labels (#1784)

Fixes #1783.

Add a new gazelle directive, `python_visibility`, that allows
users to add labels to the `visibility` attribute of generated targets.
out by the way, hence this PR), I noticed that the docs were a little
This directive acts similar to[^1] the [`go_visibility`
directive](https://github.com/bazelbuild/bazel-gazelle#directives).

The primary use case is for python projects that separate unit test
files from the python packages/modules that they test, like so:

```
packaging_tutorial/
├── LICENSE
├── pyproject.toml
├── README.md
├── src/
│   └── mypackage/
│       ├── __init__.py
│       └── foo.py
└── tests/
    ├── __init__.py
    └── test_foo.py
```

A future PR will add an example to the `./examples` directory (issue
#1775).

[^1]: At least, similar based on docs. I haven't done any actual
comparison.
diff --git a/gazelle/README.md b/gazelle/README.md
index 117aacb..0017034 100644
--- a/gazelle/README.md
+++ b/gazelle/README.md
@@ -198,6 +198,8 @@
 | Controls the `py_test` naming convention. Follows the same interpolation rules as `python_library_naming_convention`. | |
 | `# gazelle:resolve py ...` | n/a |
 | Instructs the plugin what target to add as a dependency to satisfy a given import statement. The syntax is `# gazelle:resolve py import-string label` where `import-string` is the symbol in the python `import` statement, and `label` is the Bazel label that Gazelle should write in `deps`. | |
+| [`# gazelle:python_visibility label`](#directive-python_visibility) | |
+| Appends additional visibility labels to each generated target. This directive can be set multiple times. | |
 
 
 #### Directive: `python_root`:
@@ -236,6 +238,50 @@
 [python-packaging-user-guide]: https://github.com/pypa/packaging.python.org/blob/4c86169a/source/tutorials/packaging-projects.rst
 
 
+#### Directive: `python_visibility`:
+
+Appends additional `visibility` labels to each generated target.
+
+This directive can be set multiple times. The generated `visibility` attribute
+will include the default visibility and all labels defined by this directive.
+All labels will be ordered alphabetically.
+
+```starlark
+# ./BUILD.bazel
+# gazelle:python_visibility //tests:__pkg__
+# gazelle:python_visibility //bar:baz
+
+py_library(
+   ...
+   visibility = [
+       "//:__subpackages__",  # default visibility
+       "//bar:baz",
+       "//tests:__pkg__",
+   ],
+   ...
+)
+```
+
+Child Bazel packages inherit values from parents:
+
+```starlark
+# ./bar/BUILD.bazel
+# gazelle:python_visibility //tests:__subpackages__
+
+py_library(
+   ...
+   visibility = [
+       "//:__subpackages__",       # default visibility
+       "//bar:baz",                # defined in ../BUILD.bazel
+       "//tests:__pkg__",          # defined in ../BUILD.bazel
+       "//tests:__subpackages__",  # defined in this ./BUILD.bazel
+   ],
+   ...
+)
+
+```
+
+
 ### Libraries
 
 Python source files are those ending in `.py` but not ending in `_test.py`.
diff --git a/gazelle/python/configure.go b/gazelle/python/configure.go
index 69d2762..4315688 100644
--- a/gazelle/python/configure.go
+++ b/gazelle/python/configure.go
@@ -63,6 +63,7 @@
 		pythonconfig.LibraryNamingConvention,
 		pythonconfig.BinaryNamingConvention,
 		pythonconfig.TestNamingConvention,
+		pythonconfig.Visibility,
 	}
 }
 
@@ -162,6 +163,8 @@
 			config.SetBinaryNamingConvention(strings.TrimSpace(d.Value))
 		case pythonconfig.TestNamingConvention:
 			config.SetTestNamingConvention(strings.TrimSpace(d.Value))
+		case pythonconfig.Visibility:
+			config.AppendVisibility(strings.TrimSpace(d.Value))
 		}
 	}
 
diff --git a/gazelle/python/generate.go b/gazelle/python/generate.go
index ba273be..6973d2d 100644
--- a/gazelle/python/generate.go
+++ b/gazelle/python/generate.go
@@ -212,7 +212,8 @@
 	}
 
 	parser := newPython3Parser(args.Config.RepoRoot, args.Rel, cfg.IgnoresDependency)
-	visibility := fmt.Sprintf("//%s:__subpackages__", pythonProjectRoot)
+	visibility := []string{fmt.Sprintf("//%s:__subpackages__", pythonProjectRoot)}
+	visibility = append(visibility, cfg.Visibility()...)
 
 	var result language.GenerateResult
 	result.Gen = make([]*rule.Rule, 0)
diff --git a/gazelle/python/target.go b/gazelle/python/target.go
index e310405..a941a7c 100644
--- a/gazelle/python/target.go
+++ b/gazelle/python/target.go
@@ -99,9 +99,11 @@
 	return t
 }
 
-// addVisibility adds a visibility to the target.
-func (t *targetBuilder) addVisibility(visibility string) *targetBuilder {
-	t.visibility.Add(visibility)
+// addVisibility adds visibility labels to the target.
+func (t *targetBuilder) addVisibility(visibility []string) *targetBuilder {
+	for _, item := range visibility {
+		t.visibility.Add(item)
+	}
 	return t
 }
 
diff --git a/gazelle/python/testdata/directive_python_visibility/BUILD.in b/gazelle/python/testdata/directive_python_visibility/BUILD.in
new file mode 100644
index 0000000..c1ba9e4
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/BUILD.in
@@ -0,0 +1,4 @@
+# Directives can be added in any order. They will be ordered alphabetically
+# when added.
+# gazelle:python_visibility //tests:__pkg__
+# gazelle:python_visibility //bar:baz
diff --git a/gazelle/python/testdata/directive_python_visibility/BUILD.out b/gazelle/python/testdata/directive_python_visibility/BUILD.out
new file mode 100644
index 0000000..70715e8
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/BUILD.out
@@ -0,0 +1,16 @@
+load("@rules_python//python:defs.bzl", "py_library")
+
+# Directives can be added in any order. They will be ordered alphabetically
+# when added.
+# gazelle:python_visibility //tests:__pkg__
+# gazelle:python_visibility //bar:baz
+
+py_library(
+    name = "directive_python_visibility",
+    srcs = ["foo.py"],
+    visibility = [
+        "//:__subpackages__",
+        "//bar:baz",
+        "//tests:__pkg__",
+    ],
+)
diff --git a/gazelle/python/testdata/directive_python_visibility/README.md b/gazelle/python/testdata/directive_python_visibility/README.md
new file mode 100644
index 0000000..51ab7ae
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/README.md
@@ -0,0 +1,4 @@
+# Directive: `python_visibility`
+
+This test case asserts that the `# gazelle:python_visibility` directive correctly
+appends multiple labels to the target's `visibility` parameter.
diff --git a/gazelle/python/testdata/directive_python_visibility/WORKSPACE b/gazelle/python/testdata/directive_python_visibility/WORKSPACE
new file mode 100644
index 0000000..faff6af
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/WORKSPACE
@@ -0,0 +1 @@
+# This is a Bazel workspace for the Gazelle test data.
diff --git a/gazelle/python/testdata/directive_python_visibility/foo.py b/gazelle/python/testdata/directive_python_visibility/foo.py
new file mode 100644
index 0000000..98907eb
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/foo.py
@@ -0,0 +1,2 @@
+def func():
+    print("library_func")
diff --git a/gazelle/python/testdata/directive_python_visibility/subdir/BUILD.in b/gazelle/python/testdata/directive_python_visibility/subdir/BUILD.in
new file mode 100644
index 0000000..5193e69
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/subdir/BUILD.in
@@ -0,0 +1,4 @@
+# python_visibilty directive applies to all child bazel packages.
+# Thus, the generated file for this package will also have vis for
+# //tests:__pkg__ and //bar:baz in addition to the default.
+# gazelle:python_visibility //tests:__subpackages__
diff --git a/gazelle/python/testdata/directive_python_visibility/subdir/BUILD.out b/gazelle/python/testdata/directive_python_visibility/subdir/BUILD.out
new file mode 100644
index 0000000..722c840
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/subdir/BUILD.out
@@ -0,0 +1,20 @@
+load("@rules_python//python:defs.bzl", "py_library")
+
+# python_visibilty directive applies to all child bazel packages.
+# Thus, the generated file for this package will also have vis for
+# //tests:__pkg__ and //bar:baz in addition to the default.
+# gazelle:python_visibility //tests:__subpackages__
+
+py_library(
+    name = "subdir",
+    srcs = [
+        "__init__.py",
+        "bar.py",
+    ],
+    visibility = [
+        "//:__subpackages__",
+        "//bar:baz",
+        "//tests:__pkg__",
+        "//tests:__subpackages__",
+    ],
+)
diff --git a/gazelle/python/testdata/directive_python_visibility/subdir/__init__.py b/gazelle/python/testdata/directive_python_visibility/subdir/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/subdir/__init__.py
diff --git a/gazelle/python/testdata/directive_python_visibility/subdir/bar.py b/gazelle/python/testdata/directive_python_visibility/subdir/bar.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/subdir/bar.py
diff --git a/gazelle/python/testdata/directive_python_visibility/test.yaml b/gazelle/python/testdata/directive_python_visibility/test.yaml
new file mode 100644
index 0000000..2410223
--- /dev/null
+++ b/gazelle/python/testdata/directive_python_visibility/test.yaml
@@ -0,0 +1,17 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+expect:
+  exit_code: 0
diff --git a/gazelle/pythonconfig/pythonconfig.go b/gazelle/pythonconfig/pythonconfig.go
index cecf9dc..e350a7c 100644
--- a/gazelle/pythonconfig/pythonconfig.go
+++ b/gazelle/pythonconfig/pythonconfig.go
@@ -67,6 +67,10 @@
 	// naming convention. See python_library_naming_convention for more info on
 	// the package name interpolation.
 	TestNamingConvention = "python_test_naming_convention"
+	// Visibility represents the directive that controls what additional
+	// visibility labels are added to generated targets. It mimics the behavior
+	// of the `go_visibility` directive.
+	Visibility = "python_visibility"
 )
 
 // GenerationModeType represents one of the generation modes for the Python
@@ -136,6 +140,7 @@
 	libraryNamingConvention      string
 	binaryNamingConvention       string
 	testNamingConvention         string
+	visibility                   []string
 }
 
 // New creates a new Config.
@@ -157,6 +162,7 @@
 		libraryNamingConvention:      packageNameNamingConventionSubstitution,
 		binaryNamingConvention:       fmt.Sprintf("%s_bin", packageNameNamingConventionSubstitution),
 		testNamingConvention:         fmt.Sprintf("%s_test", packageNameNamingConventionSubstitution),
+		visibility:                   []string{},
 	}
 }
 
@@ -183,6 +189,7 @@
 		libraryNamingConvention:      c.libraryNamingConvention,
 		binaryNamingConvention:       c.binaryNamingConvention,
 		testNamingConvention:         c.testNamingConvention,
+		visibility:                   c.visibility,
 	}
 }
 
@@ -388,3 +395,13 @@
 func (c *Config) RenderTestName(packageName string) string {
 	return strings.ReplaceAll(c.testNamingConvention, packageNameNamingConventionSubstitution, packageName)
 }
+
+// AppendVisibility adds additional items to the target's visibility.
+func (c *Config) AppendVisibility(visibility string) {
+	c.visibility = append(c.visibility, visibility)
+}
+
+// Visibility returns the target's visibility.
+func (c *Config) Visibility() []string {
+	return c.visibility
+}