Repository: pantsbuild/pants

Issue:
Visibility rules: Support matching on target name.
              Support matching on target name.

_Originally posted by @kaos in https://github.com/pantsbuild/pants/pull/18093#discussion_r1118007133_
            

This also implies that the syntax is the same for both selectors and rules.


Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/visibility/glob.py b/src/python/pants/backend/visibility/glob.py
--- a/src/python/pants/backend/visibility/glob.py
+++ b/src/python/pants/backend/visibility/glob.py
@@ -8,7 +8,6 @@
 from collections.abc import Mapping, Sequence
 from dataclasses import dataclass, field
 from enum import Enum
-from fnmatch import fnmatchcase
 from typing import Any, Pattern
 
 from pants.engine.addresses import Address
@@ -17,6 +16,45 @@
 from pants.util.strutil import softwrap
 
 
+def is_path_glob(spec: str) -> bool:
+    """Check if `spec` should be treated as a `path` glob."""
+    return len(spec) > 0 and (spec[0].isalnum() or spec[0] in "_.:/*")
+
+
+def glob_to_regexp(pattern: str, snap_to_path: bool = False) -> str:
+    # Escape regexp characters, then restore any `*`s.
+    glob = re.escape(pattern).replace(r"\*", "*")
+    # Translate recursive `**` globs to regexp, any adjacent `/` is optional.
+    glob = glob.replace("/**", r"(/.<<$>>)?")
+    glob = glob.replace("**/", r"/?\b")
+    glob = glob.replace("**", r".<<$>>")
+    # Translate `*` to match any path segment.
+    glob = glob.replace("*", r"[^/]<<$>>")
+    # Restore `*`s that was "escaped" during translation.
+    glob = glob.replace("<<$>>", r"*")
+    # Snap to closest `/`
+    if snap_to_path and glob and glob[0].isalnum():
+        glob = r"/?\b" + glob
+
+    return glob + r"$"
+
+
+@dataclass(frozen=True)
+class Glob:
+    raw: str
+    regexp: Pattern = field(compare=False)
+
+    @classmethod
+    def create(cls, pattern: str) -> Glob:
+        return cls(pattern, re.compile(glob_to_regexp(pattern)))
+
+    def match(self, value: str) -> bool:
+        return bool(re.match(self.regexp, value))
+
+    def __str__(self) -> str:
+        return self.raw
+
+
 class PathGlobAnchorMode(Enum):
     PROJECT_ROOT = "//"
     DECLARED_PATH = "/"
@@ -39,7 +77,7 @@ def parse(cls, pattern: str) -> PathGlobAnchorMode:
 @dataclass(frozen=True)
 class PathGlob:
     raw: str
-    anchor_mode: PathGlobAnchorMode = field(compare=False)
+    anchor_mode: PathGlobAnchorMode
     glob: Pattern = field(compare=False)
     uplvl: int
 
@@ -85,28 +123,10 @@ def parse(cls, pattern: str, base: str) -> PathGlob:
         return cls.create(  # type: ignore[call-arg]
             raw=pattern,
             anchor_mode=anchor_mode,
-            glob=cls._translate_pattern_to_regexp(pattern, snap_to_path=snap_to_path),
+            glob=glob_to_regexp(pattern, snap_to_path=snap_to_path),
             uplvl=uplvl,
         )
 
-    @staticmethod
-    def _translate_pattern_to_regexp(pattern: str, snap_to_path: bool) -> str:
-        # Escape regexp characters, then restore any `*`s.
-        glob = re.escape(pattern).replace(r"\*", "*")
-        # Translate recursive `**` globs to regexp, any adjacent `/` is optional.
-        glob = glob.replace("/**", r"(/.<<$>>)?")
-        glob = glob.replace("**/", r"/?\b")
-        glob = glob.replace("**", r".<<$>>")
-        # Translate `*` to match any path segment.
-        glob = glob.replace("*", r"[^/]<<$>>")
-        # Restore `*`s that was "escaped" during translation.
-        glob = glob.replace("<<$>>", r"*")
-        # Snap to closest `/`
-        if snap_to_path and glob and glob[0].isalnum():
-            glob = r"/?\b" + glob
-
-        return glob + r"$"
-
     def _match_path(self, path: str, base: str) -> str | None:
         if self.anchor_mode is PathGlobAnchorMode.INVOKED_PATH:
             path = os.path.relpath(path, base + "/.." * self.uplvl)
@@ -131,7 +151,7 @@ def match(self, path: str, base: str) -> bool:
 RULE_REGEXP = "|".join(
     (
         r"(?:<(?P<type>[^>]*)>)",
-        r"(?:\[(?P<path>[^]]*)\])",
+        r"(?:\[(?P<path>[^]:]*)?(?::(?P<name>[^]]*))?\])",
         r"(?:\((?P<tags>[^)]*)\))",
     )
 )
@@ -139,13 +159,15 @@ def match(self, path: str, base: str) -> bool:
 
 @dataclass(frozen=True)
 class TargetGlob:
-    type_: str | None
+    type_: Glob | None
+    name: Glob | None
     path: PathGlob | None
-    tags: tuple[str, ...] | None
+    tags: tuple[Glob, ...] | None
 
     def __post_init__(self) -> None:
-        if not isinstance(self.type_, (str, type(None))):
-            raise ValueError(f"invalid target type, expected glob but got: {self.type_!r}")
+        for what, value in (("type", self.type_), ("name", self.name)):
+            if not isinstance(value, (Glob, type(None))):
+                raise ValueError(f"invalid target {what}, expected glob but got: {value!r}")
         if not isinstance(self.path, (PathGlob, type(None))):
             raise ValueError(f"invalid target path, expected glob but got: {self.path!r}")
         if not isinstance(self.tags, (tuple, type(None))):
@@ -154,13 +176,22 @@ def __post_init__(self) -> None:
             )
 
     def __str__(self) -> str:
+        """Full syntax:
+
+            <target-type>[path:target-name](tag-1, tag-2)
+
+        If no target-type nor tags:
+
+            path:target-name
+        """
         type_ = f"<{self.type_}>" if self.type_ else ""
+        name = f":{self.name}" if self.name else ""
         tags = (
-            f"({', '.join(str(tag) if ',' not in tag else repr(tag) for tag in self.tags)})"
+            f"({', '.join(str(tag) if ',' not in tag.raw else repr(tag.raw) for tag in self.tags)})"
             if self.tags
             else ""
         )
-        path = self.path or ""
+        path = f"{self.path}{name}" if self.path else name
         if path and (type_ or tags):
             path = f"[{path}]"
         return f"{type_}{path}{tags}" or "!*"
@@ -169,13 +200,20 @@ def __str__(self) -> str:
     def create(  # type: ignore[misc]
         cls: type[TargetGlob],
         type_: str | None,
-        path: PathGlob | None,
+        name: str | None,
+        path: str | None,
+        base: str,
         tags: tuple[str, ...] | None,
     ) -> TargetGlob:
-        return cls(type_=type_, path=path, tags=tags)
+        return cls(
+            type_=Glob.create(type_) if type_ else None,
+            path=PathGlob.parse(path, base) if path else None,
+            name=Glob.create(name) if name else None,
+            tags=tuple(Glob.create(tag) for tag in tags) if tags else None,
+        )
 
     @classmethod
-    def parse(cls, spec: str | Mapping[str, Any], base: str) -> TargetGlob:
+    def parse(cls: type[TargetGlob], spec: str | Mapping[str, Any], base: str) -> TargetGlob:
         if isinstance(spec, str):
             spec_dict = cls._parse_string(spec)
         elif isinstance(spec, Mapping):
@@ -184,11 +222,13 @@ def parse(cls, spec: str | Mapping[str, Any], base: str) -> TargetGlob:
             raise ValueError(f"Invalid target spec, expected string or dict but got: {spec!r}")
 
         if not spec_dict:
-            raise ValueError("Target spec must not be empty.")
+            raise ValueError(f"Target spec must not be empty. {spec!r}")
 
         return cls.create(  # type: ignore[call-arg]
             type_=spec_dict.get("type"),
-            path=(PathGlob.parse(spec_dict["path"], base) if spec_dict.get("path") else None),
+            name=spec_dict.get("name"),
+            path=spec_dict.get("path"),
+            base=base,
             tags=cls._parse_tags(spec_dict.get("tags")),
         )
 
@@ -196,8 +236,9 @@ def parse(cls, spec: str | Mapping[str, Any], base: str) -> TargetGlob:
     def _parse_string(spec: str) -> Mapping[str, Any]:
         if not spec:
             return {}
-        if spec[0] not in "<[(":
-            return dict(path=spec)
+        if is_path_glob(spec):
+            path, _, name = spec.partition(":")
+            return dict(path=path, name=name)
         return {
             tag: val
             for tag, val in itertools.chain.from_iterable(
@@ -241,12 +282,15 @@ def address_path(address: Address) -> str:
             return address.spec_path
 
     def match(self, address: Address, adaptor: TargetAdaptor, base: str) -> bool:
-        if not (self.type_ or self.path or self.tags):
+        if not (self.type_ or self.name or self.path or self.tags):
             # Nothing rules this target in.
             return False
 
         # target type
-        if self.type_ and not fnmatchcase(adaptor.type_alias, self.type_):
+        if self.type_ and not self.type_.match(adaptor.type_alias):
+            return False
+        # target name
+        if self.name and not self.name.match(address.target_name):
             return False
         # target path (includes filename for source targets)
         if self.path and not self.path.match(self.address_path(address), base):
@@ -258,9 +302,7 @@ def match(self, address: Address, adaptor: TargetAdaptor, base: str) -> bool:
             if not isinstance(target_tags, Sequence) or isinstance(target_tags, str):
                 # Bad tags value
                 return False
-            if not all(
-                any(fnmatchcase(str(tag), pattern) for tag in target_tags) for pattern in self.tags
-            ):
+            if not all(any(glob.match(str(tag)) for tag in target_tags) for glob in self.tags):
                 return False
 
         # Nothing rules this target out.
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
@@ -10,7 +10,7 @@
 from pprint import pformat
 from typing import Any, Iterable, Iterator, Sequence, cast
 
-from pants.backend.visibility.glob import PathGlob, TargetGlob
+from pants.backend.visibility.glob import TargetGlob
 from pants.engine.addresses import Address
 from pants.engine.internals.dep_rules import (
     BuildFileDependencyRules,
@@ -40,7 +40,7 @@ def create(
         example = (
             pformat((tuple(map(str, ruleset.selectors)), *map(str, ruleset.rules), "!*"))
             if ruleset is not None
-            else '(<target patterns>, <existing rules...>, "!*"),'
+            else '(<target selector(s)>, <rule(s), ...>, "!*"),'
         )
         return cls(
             softwrap(
@@ -63,7 +63,7 @@ class VisibilityRule:
     """A single rule with an associated action when matched against a given path."""
 
     action: DependencyRuleAction
-    glob: PathGlob
+    glob: TargetGlob
 
     @classmethod
     def parse(
@@ -82,10 +82,10 @@ def parse(
         else:
             action = DependencyRuleAction.ALLOW
             pattern = rule
-        return cls(action, PathGlob.parse(pattern, relpath))
+        return cls(action, TargetGlob.parse(pattern, relpath))
 
-    def match(self, path: str, relpath: str) -> bool:
-        return self.glob.match(path, relpath)
+    def match(self, address: Address, adaptor: TargetAdaptor, relpath: str) -> bool:
+        return self.glob.match(address, adaptor, relpath)
 
     def __str__(self) -> str:
         prefix = ""
@@ -132,7 +132,7 @@ def __post_init__(self) -> None:
                     At least one target selector must have a filtering rule that can match
                     something, for example:
 
-                        ("python_*", {rules})
+                        ("<python_*>", {rules})
                     """
                 )
             )
@@ -141,12 +141,12 @@ def __post_init__(self) -> None:
     def parse(cls, build_file: str, arg: Any) -> VisibilityRuleSet:
         """Translate input `arg` from BUILD file call.
 
-        The arg is a rule spec tuple with two or more elements, where the first is the target type
-        pattern(s) and the rest are rules.
+        The arg is a rule spec tuple with two or more elements, where the first is the target
+        selector(s) and the rest are target rules.
         """
         if not isinstance(arg, Sequence) or isinstance(arg, str) or len(arg) < 2:
             raise ValueError(
-                "Invalid rule spec, expected (<target type pattern(s)>, <rule>, ...) "
+                "Invalid rule spec, expected (<target selector(s)>, <rule(s)>, <rule(s)>, ...) "
                 f"but got: {arg!r}"
             )
 
@@ -216,6 +216,7 @@ def check_dependency_rules(
                 address=origin_address,
                 adaptor=origin_adaptor,
                 other_address=dependency_address,
+                other_adaptor=dependency_adaptor,
             )
             if dependencies_rules is not None
             else (None, DependencyRuleAction.ALLOW, None)
@@ -237,6 +238,7 @@ def check_dependency_rules(
                 address=dependency_address,
                 adaptor=dependency_adaptor,
                 other_address=origin_address,
+                other_adaptor=origin_adaptor,
             )
             if dependents_rules is not None
             else (None, DependencyRuleAction.ALLOW, None)
@@ -281,6 +283,7 @@ def get_action(
         address: Address,
         adaptor: TargetAdaptor,
         other_address: Address,
+        other_adaptor: TargetAdaptor,
     ) -> tuple[VisibilityRuleSet | None, DependencyRuleAction | None, str | None]:
         """Get applicable rule for target type from `path`.
 
@@ -290,10 +293,10 @@ def get_action(
         ruleset = self.get_ruleset(address, adaptor, relpath)
         if ruleset is None:
             return None, None, None
-        path = self._get_address_path(other_address)
         for visibility_rule in ruleset.rules:
-            if visibility_rule.match(path, relpath):
+            if visibility_rule.match(other_address, other_adaptor, relpath):
                 if visibility_rule.action != DependencyRuleAction.ALLOW:
+                    path = self._get_address_path(other_address)
                     logger.debug(
                         softwrap(
                             f"""


Test patch:
diff --git a/src/python/pants/backend/visibility/glob_test.py b/src/python/pants/backend/visibility/glob_test.py
--- a/src/python/pants/backend/visibility/glob_test.py
+++ b/src/python/pants/backend/visibility/glob_test.py
@@ -274,6 +274,10 @@ def test_pathglob_match(glob: PathGlob, tests: tuple[tuple[str, str, bool], ...]
         ("(tag-a)", "(tag-a)"),
         ("(tag-a  ,  tag-b)", "(tag-a, tag-b)"),
         ("<file*>(foo, bar)[baz.txt]", "<file*>[baz.txt](foo, bar)"),
+        (":name", ":name"),
+        (dict(name="name"), ":name"),
+        (dict(path="src/*", name="name"), "src/*:name"),
+        (dict(type="target", path="src", name="name"), "<target>[src:name]"),
     ],
 )
 def test_target_glob_parse_spec(target_spec: str | Mapping[str, Any], expected: str) -> None:
@@ -283,7 +287,6 @@ def test_target_glob_parse_spec(target_spec: str | Mapping[str, Any], expected:
 @pytest.mark.parametrize(
     "expected, target_spec",
     [
-        (False, "!*"),
         (True, "*"),
         (True, "<file>"),
         (True, "(tag-c)"),
@@ -295,6 +298,12 @@ def test_target_glob_parse_spec(target_spec: str | Mapping[str, Any], expected:
         (True, "<file>(tag-a, tag-c)[src/file.ext]"),
         (False, "<file>(tag-a, tag-b)[src/file.ext]"),
         (False, "<resource>"),
+        (False, ":name"),
+        (True, ":src"),
+        (True, ":*"),
+        (False, "other.txt:src"),
+        (True, "file.ext:src"),
+        (True, "src/file.ext:src"),
     ],
 )
 def test_targetglob_match(expected: bool, target_spec: str) -> None:
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
@@ -4,6 +4,7 @@
 from __future__ import annotations
 
 import logging
+import os.path
 import re
 from pathlib import PurePath
 from textwrap import dedent
@@ -127,7 +128,7 @@ def test_flatten(expected, xs) -> None:
 
 
 @pytest.mark.parametrize(
-    "expected, rule, path, relpath",
+    "expected, spec, path, relpath",
     [
         (True, "src/a", "src/a", ""),
         (True, "?src/a", "src/a", ""),
@@ -153,10 +154,18 @@ def test_flatten(expected, xs) -> None:
         (True, "my_file.ext", "path/my_file.ext", ""),
         (False, "my_file.ext", "not_my_file.ext", ""),
         (True, "*my_file.ext", "path/some_of_my_file.ext", ""),
+        (True, ":tgt-*", "where/ever/it/is.from", "no/matter/how/far"),
+        (True, "<target>", "src/a", ""),
+        (False, "<target>[src/b]", "src/a", ""),
+        (False, "<file>", "src/a", ""),
     ],
 )
-def test_visibility_rule(expected: bool, rule: str, path: str, relpath: str) -> None:
-    assert parse_rule(rule).match(path, relpath) == expected
+def test_visibility_rule(expected: bool, spec: str, path: str, relpath: str) -> None:
+    rule = parse_rule(spec)
+    address = Address(
+        os.path.dirname(path), relative_file_path=os.path.basename(path), target_name="tgt-name"
+    )
+    assert expected == rule.match(address, TargetAdaptor("target", None), relpath)
 
 
 @pytest.mark.parametrize(
@@ -165,15 +174,15 @@ def test_visibility_rule(expected: bool, rule: str, path: str, relpath: str) ->
         (
             VisibilityRuleSet(
                 "test/path/BUILD",
-                (TargetGlob.parse("target", ""),),
+                (TargetGlob.parse("<target>", ""),),
                 (parse_rule("src/*"),),
             ),
-            ("target", "src/*"),
+            ("<target>", "src/*"),
         ),
         (
             VisibilityRuleSet(
                 "test/path/BUILD",
-                (TargetGlob.parse("files", ""), TargetGlob.parse("resources", "")),
+                (TargetGlob.parse("<files>", ""), TargetGlob.parse("<resources>", "")),
                 (
                     parse_rule(
                         "src/*",
@@ -182,7 +191,18 @@ def test_visibility_rule(expected: bool, rule: str, path: str, relpath: str) ->
                     parse_rule("!*"),
                 ),
             ),
-            (("files", "resources"), "src/*", "res/*", "!*"),
+            (("<files>", "<resources>"), "src/*", "res/*", "!*"),
+        ),
+        (
+            VisibilityRuleSet(
+                build_file="test/path/BUILD",
+                selectors=(TargetGlob.parse("<target>", ""),),
+                rules=(
+                    parse_rule("src/*"),
+                    parse_rule("src/a:lib"),
+                ),
+            ),
+            ("<target>", "src/*", "src/a:lib"),
         ),
     ],
 )
@@ -197,33 +217,39 @@ def test_visibility_rule_set_parse(expected: VisibilityRuleSet, arg: Any) -> Non
         (
             True,
             "python_sources",
-            ("<python_*>", ""),
+            ("<python_*>", "*"),
         ),
         (
             False,
             "shell_sources",
-            ("<python_*>", ""),
+            ("<python_*>", "*"),
         ),
         (
             True,
             "files",
-            (("<files>", "<resources>"), ""),
+            (("<files>", "<resources>"), "*"),
         ),
         (
             True,
             "resources",
-            (("<files>", "<resources>"), ""),
+            (("<files>", "<resources>"), "*"),
         ),
         (
             False,
             "resource",
-            (("<files>", "<resources>"), ""),
+            (("<files>", "<resources>"), "*"),
+        ),
+        (
+            True,
+            "target",
+            (":tgt-name", "*"),
         ),
     ],
 )
 def test_visibility_rule_set_match(expected: bool, target: str, rule_spec: tuple) -> None:
-    assert expected == parse_ruleset(rule_spec, "").match(
-        Address(""), TargetAdaptor(target, None), ""
+    ruleset = parse_ruleset(rule_spec, "")
+    assert expected == ruleset.match(
+        Address("", target_name="tgt-name"), TargetAdaptor(target, None), ""
     )