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

pantsbuild / pants / 20332790708

18 Dec 2025 09:48AM UTC coverage: 64.992% (-15.3%) from 80.295%
20332790708

Pull #22949

github

web-flow
Merge f730a56cd into 407284c67
Pull Request #22949: Add experimental uv resolver for Python lockfiles

54 of 97 new or added lines in 5 files covered. (55.67%)

8270 existing lines in 295 files now uncovered.

48990 of 75379 relevant lines covered (64.99%)

1.81 hits per line

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

87.13
/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).
3
from __future__ import annotations
1✔
4

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

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

19

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

24

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

40
    return glob + r"$"
1✔
41

42

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

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

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

55
    def __str__(self) -> str:
1✔
56
        return self.raw
1✔
57

58

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

65
    @classmethod
1✔
66
    def parse(cls, pattern: str) -> PathGlobAnchorMode:
1✔
67
        for mode in cls.__members__.values():
1✔
68
            if pattern.startswith(mode.value):
1✔
69
                if mode is PathGlobAnchorMode.INVOKED_PATH:
1✔
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)
72
                    if pattern.lstrip(".")[:1] not in ("", "/"):
1✔
73
                        return PathGlobAnchorMode.FLOATING
1✔
74
                return mode
1✔
75
        raise TypeError("Internal Error: should not get here, please file a bug report!")
×
76

77

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

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

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

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

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

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

118
        if uplvl > 0 and anchor_mode is not PathGlobAnchorMode.INVOKED_PATH:
1✔
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

124
        return cls.create(  # type: ignore[call-arg]
1✔
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

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

139
    def match(self, path: str, base: str) -> bool:
1✔
140
        match_path = self._match_path(path, base)
1✔
141
        return (
1✔
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

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

160

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

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

179
    def __str__(self) -> str:
1✔
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
        """
188
        type_ = f"<{self.type_}>" if self.type_ else ""
1✔
189
        name = f":{self.name}" if self.name else ""
1✔
190
        tags = (
1✔
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
        )
195
        path = f"{self.path}{name}" if self.path else name
1✔
196
        if path and (type_ or tags):
1✔
UNCOV
197
            path = f"[{path}]"
×
198
        return f"{type_}{path}{tags}" or "!*"
1✔
199

200
    @memoized_classmethod
1✔
201
    def create(  # type: ignore[misc]
1✔
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:
209
        return cls(
1✔
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

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

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

228
        return cls.create(  # type: ignore[call-arg]
1✔
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

236
    @staticmethod
1✔
237
    def _parse_string(spec: str) -> Mapping[str, Any]:
1✔
238
        if not spec:
1✔
239
            return {}
×
240
        if is_path_glob(spec):
1✔
241
            path, _, name = spec.partition(":")
1✔
242
            return dict(path=path, name=name)
1✔
243
        return {
1✔
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

251
    @staticmethod
1✔
252
    def _parse_tags(tags: str | Sequence[str] | None) -> tuple[Any, ...] | None:
1✔
253
        if tags is None:
1✔
254
            return None
1✔
255
        if isinstance(tags, str):
1✔
256
            if "'" in tags or '"' in tags:
1✔
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
                )
268
            tags = tags.split(",")
1✔
269
        if not isinstance(tags, Sequence):
1✔
270
            raise ValueError(
×
271
                f"invalid tags, expected a tag or a list of tags but got: {type(tags).__name__}"
272
            )
273
        tags = tuple(str(tag).strip() for tag in tags)
1✔
274
        return tags
1✔
275

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

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

290
        # target type
291
        if self.type_ and not self.type_.match(adaptor.type_alias):
1✔
292
            return False
1✔
293
        # target name
294
        if self.name and not self.name.match(address.target_name):
1✔
UNCOV
295
            return False
×
296
        # target path (includes filename for source targets)
297
        if self.path and not self.path.match(self.address_path(address), base):
1✔
298
            return False
1✔
299
        # target tags
300
        if self.tags:
1✔
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.
310
        return True
1✔
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

© 2025 Coveralls, Inc