Repository: pantsbuild/pants

Issue:
Visibility rules doesn't support dict formated rules
**Is your feature request related to a problem? Please describe.**

The current visibility rules are splitted into `selectors` or `visibility rules`. As documented, selector can either be string glob or dictionnary with explicit attribute naming (eg. `{"tags": ["core"]}` or `(core)`). However, `visibility rules` documentation example are only glob strings. It appears that visibility rules also support attributes matching by using the special syntax `<type>[path](tag)`. Looking at the code, they actually share the same matching logic for parsing and comparaison. However the `__dependencies_rule__` function define `visibility rules` as `str`, therefor passing attribute dictionnary (`{"tags": ["core"]}`) is not supported. Is there any reason for this limitation?

**Describe the solution you'd like**

It seems to me that we could just accept `dict` for visibility rules and everything should work as expected.


Discussion:
Good observation. And no, there is no reason for this, merely (recent) legacy that it initially wasn't using the same but was refactored to streamline the parsing/processing of the patterns, so this is now possible.

Resolving patch:
diff --git a/src/python/pants/backend/visibility/rule_types.py b/src/python/pants/backend/visibility/rule_types.py
--- a/src/python/pants/backend/visibility/rule_types.py
+++ b/src/python/pants/backend/visibility/rule_types.py
@@ -68,20 +68,25 @@ class VisibilityRule:
     @classmethod
     def parse(
         cls,
-        rule: str,
+        rule: str | dict,
         relpath: str,
     ) -> VisibilityRule:
-        if not isinstance(rule, str):
-            raise ValueError(f"expected a path pattern string but got: {rule!r}")
-        if rule.startswith("!"):
-            action = DependencyRuleAction.DENY
-            pattern = rule[1:]
-        elif rule.startswith("?"):
-            action = DependencyRuleAction.WARN
-            pattern = rule[1:]
-        else:
-            action = DependencyRuleAction.ALLOW
+        pattern: str | dict
+        if isinstance(rule, str):
+            if rule.startswith("!"):
+                action = DependencyRuleAction.DENY
+                pattern = rule[1:]
+            elif rule.startswith("?"):
+                action = DependencyRuleAction.WARN
+                pattern = rule[1:]
+            else:
+                action = DependencyRuleAction.ALLOW
+                pattern = rule
+        elif isinstance(rule, dict):
+            action = DependencyRuleAction(rule.get("action", "allow"))
             pattern = rule
+        else:
+            raise ValueError(f"invalid visibility rule: {rule!r}")
         return cls(action, TargetGlob.parse(pattern, relpath))
 
     def match(self, address: Address, adaptor: TargetAdaptor, relpath: str) -> bool:
@@ -153,7 +158,7 @@ def parse(cls, build_file: str, arg: Any) -> VisibilityRuleSet:
         relpath = os.path.dirname(build_file)
         try:
             selectors = cast("Iterator[str | dict]", flatten(arg[0], str, dict))
-            rules = cast("Iterator[str]", flatten(arg[1:], str))
+            rules = cast("Iterator[str | dict]", flatten(arg[1:], str, dict))
             return cls(
                 build_file,
                 tuple(TargetGlob.parse(selector, relpath) for selector in selectors),
@@ -173,8 +178,8 @@ def peek(self) -> tuple[str, ...]:
         return tuple(map(str, self.rules))
 
     @staticmethod
-    def _noop_rule(rule: str) -> bool:
-        return not rule or rule.startswith("#")
+    def _noop_rule(rule: str | dict) -> bool:
+        return not rule or isinstance(rule, str) and rule.startswith("#")
 
     def match(self, address: Address, adaptor: TargetAdaptor, relpath: str) -> bool:
         return any(selector.match(address, adaptor, relpath) for selector in self.selectors)


Test patch:
diff --git a/src/python/pants/backend/visibility/rule_types_test.py b/src/python/pants/backend/visibility/rule_types_test.py
--- a/src/python/pants/backend/visibility/rule_types_test.py
+++ b/src/python/pants/backend/visibility/rule_types_test.py
@@ -45,7 +45,7 @@ def parse_address(raw: str, description_of_origin: str = repr("test"), **kwargs)
     return parsed.dir_to_address() if "." not in raw else parsed.file_to_address()
 
 
-def parse_rule(rule: str, relpath: str = "test/path") -> VisibilityRule:
+def parse_rule(rule: str | dict, relpath: str = "test/path") -> VisibilityRule:
     return VisibilityRule.parse(rule, relpath)
 
 
@@ -158,6 +158,7 @@ def test_flatten(expected, xs) -> None:
         (True, "<target>", "src/a", ""),
         (False, "<target>[src/b]", "src/a", ""),
         (False, "<file>", "src/a", ""),
+        (True, {"path": "src/a"}, "src/a", ""),
     ],
 )
 def test_visibility_rule(expected: bool, spec: str, path: str, relpath: str) -> None:
@@ -206,6 +207,23 @@ def test_visibility_rule(expected: bool, spec: str, path: str, relpath: str) ->
             ),
             ("<target>", "src/*", "src/a:lib"),
         ),
+        (
+            VisibilityRuleSet(
+                build_file="test/path/BUILD",
+                selectors=(TargetGlob.parse("<target>", ""),),
+                rules=(
+                    parse_rule("!src/*"),
+                    parse_rule("?src/a:lib"),
+                    parse_rule("(this, is, ok)"),
+                ),
+            ),
+            (
+                "<target>",
+                {"action": "deny", "path": "src/*"},
+                {"action": "warn", "path": "src/a", "name": "lib"},
+                {"tags": ["this", "is", "ok"]},
+            ),
+        ),
     ],
 )
 def test_visibility_rule_set_parse(expected: VisibilityRuleSet, arg: Any) -> None: