• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 19015773527

02 Nov 2025 05:33PM UTC coverage: 17.872% (-62.4%) from 80.3%
19015773527

Pull #22816

github

web-flow
Merge a12d75757 into 6c024e162
Pull Request #22816: Update Pants internal Python to 3.14

4 of 5 new or added lines in 3 files covered. (80.0%)

28452 existing lines in 683 files now uncovered.

9831 of 55007 relevant lines covered (17.87%)

0.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/python/pants/backend/visibility/glob.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
UNCOV
3
from __future__ import annotations
×
4

UNCOV
5
import itertools
×
UNCOV
6
import os.path
×
UNCOV
7
import re
×
UNCOV
8
from collections.abc import Mapping, Sequence
×
UNCOV
9
from dataclasses import dataclass, field
×
UNCOV
10
from enum import Enum
×
UNCOV
11
from re import Pattern
×
UNCOV
12
from typing import Any
×
13

UNCOV
14
from pants.engine.addresses import Address
×
UNCOV
15
from pants.engine.internals.target_adaptor import TargetAdaptor
×
UNCOV
16
from pants.util.memo import memoized_classmethod
×
UNCOV
17
from pants.util.strutil import softwrap
×
18

19

UNCOV
20
def is_path_glob(spec: str) -> bool:
×
21
    """Check if `spec` should be treated as a `path` glob."""
UNCOV
22
    return len(spec) > 0 and (spec[0].isalnum() or spec[0] in "_.:/*")
×
23

24

UNCOV
25
def glob_to_regexp(pattern: str, snap_to_path: bool = False) -> str:
×
26
    # Escape regexp characters, then restore any `*`s.
UNCOV
27
    glob = re.escape(pattern).replace(r"\*", "*")
×
28
    # Translate recursive `**` globs to regexp, any adjacent `/` is optional.
UNCOV
29
    glob = glob.replace("/**", r"(/.<<$>>)?")
×
UNCOV
30
    glob = glob.replace("**/", r"/?\b")
×
UNCOV
31
    glob = glob.replace("**", r".<<$>>")
×
32
    # Translate `*` to match any path segment.
UNCOV
33
    glob = glob.replace("*", r"[^/]<<$>>")
×
34
    # Restore `*`s that was "escaped" during translation.
UNCOV
35
    glob = glob.replace("<<$>>", r"*")
×
36
    # Snap to closest `/`
UNCOV
37
    if snap_to_path and glob and glob[0].isalnum():
×
UNCOV
38
        glob = r"/?\b" + glob
×
39

UNCOV
40
    return glob + r"$"
×
41

42

UNCOV
43
@dataclass(frozen=True)
×
UNCOV
44
class Glob:
×
UNCOV
45
    raw: str
×
UNCOV
46
    regexp: Pattern = field(compare=False)
×
47

UNCOV
48
    @classmethod
×
UNCOV
49
    def create(cls, pattern: str) -> Glob:
×
UNCOV
50
        return cls(pattern, re.compile(glob_to_regexp(pattern)))
×
51

UNCOV
52
    def match(self, value: str) -> bool:
×
UNCOV
53
        return bool(re.match(self.regexp, value))
×
54

UNCOV
55
    def __str__(self) -> str:
×
UNCOV
56
        return self.raw
×
57

58

UNCOV
59
class PathGlobAnchorMode(Enum):
×
UNCOV
60
    PROJECT_ROOT = "//"
×
UNCOV
61
    DECLARED_PATH = "/"
×
UNCOV
62
    INVOKED_PATH = "."
×
UNCOV
63
    FLOATING = ""
×
64

UNCOV
65
    @classmethod
×
UNCOV
66
    def parse(cls, pattern: str) -> PathGlobAnchorMode:
×
UNCOV
67
        for mode in cls.__members__.values():
×
UNCOV
68
            if pattern.startswith(mode.value):
×
UNCOV
69
                if mode is PathGlobAnchorMode.INVOKED_PATH:
×
70
                    # Special case "invoked path", to not select ".text"; only "." "../" or "./" are
71
                    # valid for "invoked path" mode. (we're not picky on the number of leading dots)
UNCOV
72
                    if pattern.lstrip(".")[:1] not in ("", "/"):
×
UNCOV
73
                        return PathGlobAnchorMode.FLOATING
×
UNCOV
74
                return mode
×
75
        raise TypeError("Internal Error: should not get here, please file a bug report!")
×
76

77

UNCOV
78
@dataclass(frozen=True)
×
UNCOV
79
class PathGlob:
×
UNCOV
80
    raw: str
×
UNCOV
81
    anchor_mode: PathGlobAnchorMode
×
UNCOV
82
    glob: Pattern = field(compare=False)
×
UNCOV
83
    uplvl: int
×
84

UNCOV
85
    def __str__(self) -> str:
×
UNCOV
86
        if self.anchor_mode is PathGlobAnchorMode.INVOKED_PATH and self.raw:
×
UNCOV
87
            return f"./{self.raw}" if not self.uplvl else "../" * self.uplvl + self.raw
×
UNCOV
88
        elif self.anchor_mode is PathGlobAnchorMode.DECLARED_PATH:
×
UNCOV
89
            return self.raw
×
90
        else:
UNCOV
91
            return f"{self.anchor_mode.value}{self.raw}"
×
92

UNCOV
93
    @memoized_classmethod
×
UNCOV
94
    def create(  # type: ignore[misc]
×
95
        cls: type[PathGlob], raw: str, anchor_mode: PathGlobAnchorMode, glob: str, uplvl: int
96
    ) -> PathGlob:
UNCOV
97
        return cls(raw=raw, anchor_mode=anchor_mode, glob=re.compile(glob), uplvl=uplvl)
×
98

UNCOV
99
    @classmethod
×
UNCOV
100
    def parse(cls, pattern: str, base: str) -> PathGlob:
×
UNCOV
101
        org_pattern = pattern
×
UNCOV
102
        if not isinstance(pattern, str):
×
103
            raise ValueError(f"invalid path glob, expected string but got: {pattern!r}")
×
UNCOV
104
        anchor_mode = PathGlobAnchorMode.parse(pattern)
×
UNCOV
105
        if anchor_mode is PathGlobAnchorMode.DECLARED_PATH:
×
UNCOV
106
            pattern = os.path.join(base, pattern.lstrip("/"))
×
107

UNCOV
108
        if anchor_mode is PathGlobAnchorMode.FLOATING:
×
UNCOV
109
            snap_to_path = not pattern.startswith(".")
×
110
        else:
UNCOV
111
            snap_to_path = False
×
112

UNCOV
113
        pattern = os.path.normpath(pattern)
×
UNCOV
114
        uplvl = pattern.count("../")
×
UNCOV
115
        if anchor_mode is not PathGlobAnchorMode.FLOATING:
×
UNCOV
116
            pattern = pattern.lstrip("./")
×
117

UNCOV
118
        if uplvl > 0 and anchor_mode is not PathGlobAnchorMode.INVOKED_PATH:
×
119
            raise ValueError(
×
120
                f"Internal Error: unexpected `uplvl` {uplvl} for pattern={org_pattern!r}, "
121
                f"{anchor_mode}, base={base!r}. Please file a bug report!"
122
            )
123

UNCOV
124
        return cls.create(  # type: ignore[call-arg]
×
125
            raw=pattern,
126
            anchor_mode=anchor_mode,
127
            glob=glob_to_regexp(pattern, snap_to_path=snap_to_path),
128
            uplvl=uplvl,
129
        )
130

UNCOV
131
    def _match_path(self, path: str, base: str) -> str | None:
×
UNCOV
132
        if self.anchor_mode is PathGlobAnchorMode.INVOKED_PATH:
×
UNCOV
133
            path = os.path.relpath(path or ".", base + "/.." * self.uplvl)
×
UNCOV
134
            if path.startswith(".."):
×
135
                # The `path` is not in the sub tree of `base`.
UNCOV
136
                return None
×
UNCOV
137
        return path.lstrip(".")
×
138

UNCOV
139
    def match(self, path: str, base: str) -> bool:
×
UNCOV
140
        match_path = self._match_path(path, base)
×
UNCOV
141
        return (
×
142
            False
143
            if match_path is None
144
            else bool(
145
                (re.search if self.anchor_mode is PathGlobAnchorMode.FLOATING else re.match)(
146
                    self.glob, match_path
147
                )
148
            )
149
        )
150

151

UNCOV
152
RULE_REGEXP = "|".join(
×
153
    (
154
        r"(?:<(?P<type>[^>]*)>)",
155
        r"(?:\[(?P<path>[^]:]*)?(?::(?P<name>[^]]*))?\])",
156
        r"(?:\((?P<tags>[^)]*)\))",
157
    )
158
)
159

160

UNCOV
161
@dataclass(frozen=True)
×
UNCOV
162
class TargetGlob:
×
UNCOV
163
    type_: Glob | None
×
UNCOV
164
    name: Glob | None
×
UNCOV
165
    path: PathGlob | None
×
UNCOV
166
    tags: tuple[Glob, ...] | None
×
167

UNCOV
168
    def __post_init__(self) -> None:
×
UNCOV
169
        for what, value in (("type", self.type_), ("name", self.name)):
×
UNCOV
170
            if not isinstance(value, (Glob, type(None))):
×
171
                raise ValueError(f"invalid target {what}, expected glob but got: {value!r}")
×
UNCOV
172
        if not isinstance(self.path, (PathGlob, type(None))):
×
173
            raise ValueError(f"invalid target path, expected glob but got: {self.path!r}")
×
UNCOV
174
        if not isinstance(self.tags, (tuple, type(None))):
×
175
            raise ValueError(
×
176
                f"invalid target tags, expected sequence of values but got: {self.tags!r}"
177
            )
178

UNCOV
179
    def __str__(self) -> str:
×
180
        """Full syntax:
181

182
            <target-type>[path:target-name](tag-1, tag-2)
183

184
        If no target-type nor tags:
185

186
            path:target-name
187
        """
UNCOV
188
        type_ = f"<{self.type_}>" if self.type_ else ""
×
UNCOV
189
        name = f":{self.name}" if self.name else ""
×
UNCOV
190
        tags = (
×
191
            f"({', '.join(str(tag) if ',' not in tag.raw else repr(tag.raw) for tag in self.tags)})"
192
            if self.tags
193
            else ""
194
        )
UNCOV
195
        path = f"{self.path}{name}" if self.path else name
×
UNCOV
196
        if path and (type_ or tags):
×
UNCOV
197
            path = f"[{path}]"
×
UNCOV
198
        return f"{type_}{path}{tags}" or "!*"
×
199

UNCOV
200
    @memoized_classmethod
×
UNCOV
201
    def create(  # type: ignore[misc]
×
202
        cls: type[TargetGlob],
203
        type_: str | None,
204
        name: str | None,
205
        path: str | None,
206
        base: str,
207
        tags: tuple[str, ...] | None,
208
    ) -> TargetGlob:
UNCOV
209
        return cls(
×
210
            type_=Glob.create(type_) if type_ else None,
211
            path=PathGlob.parse(path, base) if path else None,
212
            name=Glob.create(name) if name else None,
213
            tags=tuple(Glob.create(tag) for tag in tags) if tags else None,
214
        )
215

UNCOV
216
    @classmethod
×
UNCOV
217
    def parse(cls: type[TargetGlob], spec: str | Mapping[str, Any], base: str) -> TargetGlob:
×
UNCOV
218
        if isinstance(spec, str):
×
UNCOV
219
            spec_dict = cls._parse_string(spec)
×
UNCOV
220
        elif isinstance(spec, Mapping):
×
UNCOV
221
            spec_dict = spec
×
222
        else:
223
            raise ValueError(f"Invalid target spec, expected string or dict but got: {spec!r}")
×
224

UNCOV
225
        if not spec_dict:
×
226
            raise ValueError(f"Target spec must not be empty. {spec!r}")
×
227

UNCOV
228
        return cls.create(  # type: ignore[call-arg]
×
229
            type_=str(spec_dict["type"]) if "type" in spec_dict else None,
230
            name=str(spec_dict["name"]) if "name" in spec_dict else None,
231
            path=str(spec_dict["path"]) if "path" in spec_dict else None,
232
            base=base,
233
            tags=cls._parse_tags(spec_dict.get("tags")),
234
        )
235

UNCOV
236
    @staticmethod
×
UNCOV
237
    def _parse_string(spec: str) -> Mapping[str, Any]:
×
UNCOV
238
        if not spec:
×
239
            return {}
×
UNCOV
240
        if is_path_glob(spec):
×
UNCOV
241
            path, _, name = spec.partition(":")
×
UNCOV
242
            return dict(path=path, name=name)
×
UNCOV
243
        return {
×
244
            tag: val
245
            for tag, val in itertools.chain.from_iterable(
246
                m.groupdict().items() for m in re.finditer(RULE_REGEXP, spec)
247
            )
248
            if val is not None
249
        }
250

UNCOV
251
    @staticmethod
×
UNCOV
252
    def _parse_tags(tags: str | Sequence[str] | None) -> tuple[Any, ...] | None:
×
UNCOV
253
        if tags is None:
×
UNCOV
254
            return None
×
UNCOV
255
        if isinstance(tags, str):
×
UNCOV
256
            if "'" in tags or '"' in tags:
×
257
                raise ValueError(
×
258
                    softwrap(
259
                        f"""
260
                        Quotes not supported for tags rule selector in string form, quotes only
261
                        needed for tag values with commas in them otherwise simply remove the
262
                        quotes. For embedded commas, use the dictionary syntax:
263

264
                          {{"tags": [{tags!r}, ...], ...}}
265
                        """
266
                    )
267
                )
UNCOV
268
            tags = tags.split(",")
×
UNCOV
269
        if not isinstance(tags, Sequence):
×
270
            raise ValueError(
×
271
                f"invalid tags, expected a tag or a list of tags but got: {type(tags).__name__}"
272
            )
UNCOV
273
        tags = tuple(str(tag).strip() for tag in tags)
×
UNCOV
274
        return tags
×
275

UNCOV
276
    @staticmethod
×
UNCOV
277
    def address_path(address: Address) -> str:
×
UNCOV
278
        if address.is_file_target:
×
UNCOV
279
            return address.filename
×
UNCOV
280
        elif address.is_generated_target:
×
UNCOV
281
            return address.spec.replace(":", "/").lstrip("/")
×
282
        else:
UNCOV
283
            return address.spec_path
×
284

UNCOV
285
    def match(self, address: Address, adaptor: TargetAdaptor, base: str) -> bool:
×
UNCOV
286
        if not (self.type_ or self.name or self.path or self.tags):
×
287
            # Nothing rules this target in.
288
            return False
×
289

290
        # target type
UNCOV
291
        if self.type_ and not self.type_.match(adaptor.type_alias):
×
UNCOV
292
            return False
×
293
        # target name
UNCOV
294
        if self.name and not self.name.match(address.target_name):
×
UNCOV
295
            return False
×
296
        # target path (includes filename for source targets)
UNCOV
297
        if self.path and not self.path.match(self.address_path(address), base):
×
UNCOV
298
            return False
×
299
        # target tags
UNCOV
300
        if self.tags:
×
301
            # Use adaptor.kwargs with caution, unvalidated input data from BUILD file.
UNCOV
302
            target_tags = adaptor.kwargs.get("tags")
×
UNCOV
303
            if not isinstance(target_tags, Sequence) or isinstance(target_tags, str):
×
304
                # Bad tags value
305
                return False
×
UNCOV
306
            if not all(any(glob.match(str(tag)) for tag in target_tags) for glob in self.tags):
×
UNCOV
307
                return False
×
308

309
        # Nothing rules this target out.
UNCOV
310
        return True
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc