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

pantsbuild / pants / 18252174847

05 Oct 2025 01:36AM UTC coverage: 43.382% (-36.9%) from 80.261%
18252174847

push

github

web-flow
run tests on mac arm (#22717)

Just doing the minimal to pull forward the x86_64 pattern.

ref #20993

25776 of 59416 relevant lines covered (43.38%)

1.3 hits per line

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

37.44
/src/python/pants/option/custom_types.py
1
# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
3✔
5

6
import inspect
3✔
7
import os
3✔
8
import re
3✔
9
import shlex
3✔
10
from collections.abc import Iterable, Sequence
3✔
11
from enum import Enum
3✔
12
from re import Pattern
3✔
13

14
from pants.option.errors import ParseError
3✔
15
from pants.util.eval import parse_expression
3✔
16
from pants.util.memo import memoized_method
3✔
17
from pants.util.strutil import softwrap
3✔
18

19

20
class UnsetBool:
3✔
21
    """A type that can be used as the default value for a bool typed option to indicate un-set.
22

23
    In other words, `bool`-typed options with a `default=UnsetBool` that are not explicitly set will
24
    have the value `None`, enabling a tri-state.
25

26
    :API: public
27
    """
28

29
    def __init__(self) -> None:
3✔
30
        raise NotImplementedError(
×
31
            "UnsetBool cannot be instantiated. It should only be used as a sentinel type."
32
        )
33

34
    @classmethod
3✔
35
    def coerce_bool(cls, value: type[UnsetBool] | bool | None, default: bool) -> bool:
3✔
36
        if value is None:
×
37
            return default
×
38
        if value is cls:
×
39
            return default
×
40
        assert isinstance(value, bool)
×
41
        return value
×
42

43

44
def target_option(s: str) -> str:
3✔
45
    """Same type as 'str', but indicates a single target spec.
46

47
    :API: public
48

49
    TODO(stuhood): Eagerly convert these to Addresses: see https://rbcommons.com/s/twitter/r/2937/
50
    """
51
    return s
×
52

53

54
def _normalize_directory_separators(s: str) -> str:
3✔
55
    """Coalesce runs of consecutive instances of `os.sep` in `s`, e.g. '//' -> '/' on POSIX.
56

57
    The engine will use paths or target addresses either to form globs or to string-match against, and
58
    including the directory separator '/' multiple times in a row e.g. '//' produces an equivalent
59
    glob as with a single '/', but produces a different actual string, which will cause the engine to
60
    fail to glob file paths or target specs correctly.
61

62
    TODO: give the engine more control over matching paths so we don't have to sanitize the input!
63
    """
64
    return os.path.normpath(s)
×
65

66

67
def dir_option(s: str) -> str:
3✔
68
    """Same type as 'str', but indicates string represents a directory path.
69

70
    :API: public
71
    """
72
    return _normalize_directory_separators(s)
×
73

74

75
def file_option(s: str) -> str:
3✔
76
    """Same type as 'str', but indicates string represents a filepath.
77

78
    :API: public
79
    """
80
    return _normalize_directory_separators(s)
×
81

82

83
def dict_with_files_option(s):
3✔
84
    """Same as 'dict', but fingerprints the file contents of any values which are file paths.
85

86
    For any value which matches the path of a file on disk, the file path is not fingerprinted -- only
87
    its contents.
88

89
    :API: public
90
    """
91
    return DictValueComponent.create(s)
×
92

93

94
def shell_str(s: str) -> str:
3✔
95
    """A member_type for strings that should be split upon parsing through `shlex.split()`.
96

97
    For example, the option value `--foo --bar=val` would be split into `['--foo', '--bar=val']`,
98
    and then the parser will safely merge this expanded list with any other values defined for the
99
    option.
100

101
    :API: public
102
    """
103
    return s
3✔
104

105

106
def workspace_path(s: str) -> str:
3✔
107
    """Same type as 'str', but indicates string represents a directory path that is relative to
108
    either the build root, or a BUILD file if prefix with `./`.
109

110
    :API: public
111
    """
112
    if s.startswith("/"):
3✔
113
        raise ParseError(
×
114
            softwrap(
115
                f"""
116
                Invalid value: `{s}`. Expected a relative path, optionally in the form
117
                `./relative/path` to make it relative to the BUILD files rather than the build root.
118
                """
119
            )
120
        )
121
    return s
3✔
122

123

124
def memory_size(s: str | int | float) -> int:
3✔
125
    """A string that normalizes the suffixes {GiB, MiB, KiB, B} into the number of bytes.
126

127
    :API: public
128
    """
129
    if isinstance(s, (int, float)):
3✔
130
        return int(s)
×
131
    if not s:
3✔
132
        raise ParseError("Missing value.")
×
133

134
    original = s
3✔
135
    s = s.lower().strip()
3✔
136

137
    try:
3✔
138
        return int(float(s))
3✔
139
    except ValueError:
3✔
140
        pass
3✔
141

142
    invalid = ParseError(
3✔
143
        softwrap(
144
            f"""
145
            Invalid value: `{original}`. Expected either a bare number or a number with one of
146
            `GiB`, `MiB`, `KiB`, or `B`.
147
            """
148
        )
149
    )
150

151
    def convert_to_bytes(power_of_2) -> int:
3✔
152
        try:
3✔
153
            return int(float(s[:-3]) * (2**power_of_2))
3✔
154
        except TypeError:
×
155
            raise invalid
×
156

157
    if s.endswith("gib"):
3✔
158
        return convert_to_bytes(30)
3✔
159
    elif s.endswith("mib"):
3✔
160
        return convert_to_bytes(20)
3✔
161
    elif s.endswith("kib"):
×
162
        return convert_to_bytes(10)
×
163
    elif s.endswith("b"):
×
164
        try:
×
165
            return int(float(s[:-1]))
×
166
        except TypeError:
×
167
            raise invalid
×
168
    raise invalid
×
169

170

171
def _convert(val, acceptable_types):
3✔
172
    """Ensure that val is one of the acceptable types, converting it if needed.
173

174
    :param val: The value we're parsing (either a string or one of the acceptable types).
175
    :param acceptable_types: A tuple of expected types for val.
176
    :returns: The parsed value.
177
    :raises :class:`pants.options.errors.ParseError`: if there was a problem parsing the val as an
178
                                                      acceptable type.
179
    """
180
    if isinstance(val, acceptable_types):
×
181
        return val
×
182
    try:
×
183
        return parse_expression(val, acceptable_types)
×
184
    except ValueError as e:
×
185
        raise ParseError(str(e)) from e
×
186

187

188
def _convert_list(val, member_type, is_enum):
3✔
189
    converted = _convert(val, (list, tuple))
×
190
    if not is_enum:
×
191
        return converted
×
192
    return [item if isinstance(item, member_type) else member_type(item) for item in converted]
×
193

194

195
def _flatten_shlexed_list(shlexed_args: Sequence[str]) -> list[str]:
3✔
196
    """Convert a list of shlexed args into a flattened list of individual args.
197

198
    For example, ['arg1 arg2=foo', '--arg3'] would be converted to ['arg1', 'arg2=foo', '--arg3'].
199
    """
200
    return [arg for shlexed_arg in shlexed_args for arg in shlex.split(shlexed_arg)]
×
201

202

203
class ListValueComponent:
3✔
204
    """A component of the value of a list-typed option.
205

206
    One or more instances of this class can be merged to form a list value.
207

208
    A component consists of values to append and values to filter while constructing the final list.
209

210
    Each component may either replace or modify the preceding component.  So that, e.g., a config
211
    file can append to and/or filter the default value list, instead of having to repeat most
212
    of the contents of the default value list.
213
    """
214

215
    REPLACE = "REPLACE"
3✔
216
    MODIFY = "MODIFY"
3✔
217

218
    # We use a regex to parse the comma-separated lists of modifier expressions (each of which is
219
    # a list or tuple literal preceded by a + or a -).  Note that these expressions are technically
220
    # a context-free grammar, but in practice using this regex as a heuristic will work fine. The
221
    # values that could defeat it are extremely unlikely to be encountered in practice.
222
    # If we do ever encounter them, we'll have to replace this with a real parser.
223
    @classmethod
3✔
224
    @memoized_method
3✔
225
    def _get_modifier_expr_re(cls) -> Pattern[str]:
3✔
226
        # Note that the regex consists of a positive lookbehind assertion for a ] or a ),
227
        # followed by a comma (possibly surrounded by whitespace), followed by a
228
        # positive lookahead assertion for [ or (.  The lookahead/lookbehind assertions mean that
229
        # the bracket/paren characters don't get consumed in the split.
230
        return re.compile(r"(?<=\]|\))\s*,\s*(?=[+-](?:\[|\())")
×
231

232
    @classmethod
3✔
233
    def _split_modifier_expr(cls, s: str) -> list[str]:
3✔
234
        # This check ensures that the first expression (before the first split point) is a modification.
235
        if s.startswith("+") or s.startswith("-"):
×
236
            return cls._get_modifier_expr_re().split(s)
×
237
        return [s]
×
238

239
    @classmethod
3✔
240
    def merge(cls, components: Iterable[ListValueComponent]) -> ListValueComponent:
3✔
241
        """Merges components into a single component, applying their actions appropriately.
242

243
        This operation is associative:  M(M(a, b), c) == M(a, M(b, c)) == M(a, b, c).
244
        """
245
        # Note that action of the merged component is MODIFY until the first REPLACE is encountered.
246
        # This guarantees associativity.
247
        action = cls.MODIFY
×
248
        appends = []
×
249
        filters = []
×
250
        for component in components:
×
251
            if component._action is cls.REPLACE:
×
252
                appends = component._appends
×
253
                filters = component._filters
×
254
                action = cls.REPLACE
×
255
            elif component._action is cls.MODIFY:
×
256
                appends.extend(component._appends)
×
257
                filters.extend(component._filters)
×
258
            else:
259
                raise ParseError(f"Unknown action for list value: {component._action}")
×
260
        return cls(action, appends, filters)
×
261

262
    def __init__(self, action: str, appends: list, filters: list) -> None:
3✔
263
        self._action = action
×
264
        self._appends = appends
×
265
        self._filters = filters
×
266

267
    @property
3✔
268
    def val(self) -> list:
3✔
269
        ret = list(self._appends)
×
270
        for x in self._filters:
×
271
            # Note: can't do ret.remove(x) because that only removes the first instance of x.
272
            ret = [y for y in ret if y != x]
×
273
        return ret
×
274

275
    @property
3✔
276
    def action(self):
3✔
277
        return self._action
×
278

279
    @classmethod
3✔
280
    def create(cls, value, member_type=str) -> ListValueComponent:
3✔
281
        """Interpret value as either a list or something to extend another list with.
282

283
        Note that we accept tuple literals, but the internal value is always a list.
284

285
        :param value: The value to convert.  Can be an instance of ListValueComponent, a list, a tuple,
286
                      a string representation of a list or tuple (possibly prefixed by + or -
287
                      indicating modification instead of replacement), or any allowed member_type.
288
                      May also be a comma-separated sequence of modifications.
289
        """
290
        if isinstance(value, cls):  # Ensure idempotency.
×
291
            return value
×
292

293
        if isinstance(value, bytes):
×
294
            value = value.decode()
×
295

296
        if isinstance(value, str):
×
297
            comma_separated_exprs = cls._split_modifier_expr(value)
×
298
            if len(comma_separated_exprs) > 1:
×
299
                return cls.merge([cls.create(x) for x in comma_separated_exprs])
×
300

301
        action = cls.MODIFY
×
302
        appends: Sequence[str] = []
×
303
        filters: Sequence[str] = []
×
304
        is_enum = inspect.isclass(member_type) and issubclass(member_type, Enum)
×
305
        if isinstance(value, (list, tuple)):  # Ensure we can handle list-typed default values.
×
306
            action = cls.REPLACE
×
307
            appends = value
×
308
        elif value.startswith("[") or value.startswith("("):
×
309
            action = cls.REPLACE
×
310
            appends = _convert_list(value, member_type, is_enum)
×
311
        elif value.startswith("+[") or value.startswith("+("):
×
312
            appends = _convert_list(value[1:], member_type, is_enum)
×
313
        elif value.startswith("-[") or value.startswith("-("):
×
314
            filters = _convert_list(value[1:], member_type, is_enum)
×
315
        elif is_enum and isinstance(value, str):
×
316
            appends = _convert_list([value], member_type, True)
×
317
        elif isinstance(value, str):
×
318
            appends = [value]
×
319
        else:
320
            appends = _convert(f"[{value}]", list)
×
321

322
        if member_type == shell_str:
×
323
            appends = _flatten_shlexed_list(appends)
×
324
            filters = _flatten_shlexed_list(filters)
×
325

326
        return cls(action, list(appends), list(filters))
×
327

328
    def __repr__(self) -> str:
3✔
329
        return f"{self._action} +{self._appends} -{self._filters}"
×
330

331

332
class DictValueComponent:
3✔
333
    """A component of the value of a dict-typed option.
334

335
    One or more instances of this class can be merged to form a dict value.
336

337
    Each component may either replace or extend the preceding component.  So that, e.g., a config
338
    file can extend the default value of a dict, instead of having to repeat it.
339
    """
340

341
    REPLACE = "REPLACE"
3✔
342
    EXTEND = "EXTEND"
3✔
343

344
    @classmethod
3✔
345
    def merge(cls, components: Iterable[DictValueComponent]) -> DictValueComponent:
3✔
346
        """Merges components into a single component, applying their actions appropriately.
347

348
        This operation is associative:  M(M(a, b), c) == M(a, M(b, c)) == M(a, b, c).
349
        """
350
        # Note that action of the merged component is EXTEND until the first REPLACE is encountered.
351
        # This guarantees associativity.
352
        action = cls.EXTEND
×
353
        val = {}
×
354
        for component in components:
×
355
            if component.action is cls.REPLACE:
×
356
                val = component.val
×
357
                action = cls.REPLACE
×
358
            elif component.action is cls.EXTEND:
×
359
                val.update(component.val)
×
360
            else:
361
                raise ParseError(f"Unknown action for dict value: {component.action}")
×
362
        return cls(action, val)
×
363

364
    def __init__(self, action: str, val: dict) -> None:
3✔
365
        self.action = action
×
366
        self.val = val
×
367

368
    @classmethod
3✔
369
    def create(cls, value) -> DictValueComponent:
3✔
370
        """Interpret value as either a dict or something to extend another dict with.
371

372
        :param value: The value to convert.  Can be an instance of DictValueComponent, a dict,
373
                      or a string representation (possibly prefixed by +) of a dict.
374
        """
375
        if isinstance(value, bytes):
×
376
            value = value.decode()
×
377
        if isinstance(value, cls):  # Ensure idempotency.
×
378
            action = value.action
×
379
            val = value.val
×
380
        elif isinstance(value, dict):  # Ensure we can handle dict-typed default values.
×
381
            action = cls.REPLACE
×
382
            val = value
×
383
        elif value.startswith("{"):
×
384
            action = cls.REPLACE
×
385
            val = _convert(value, dict)
×
386
        elif value.startswith("+{"):
×
387
            action = cls.EXTEND
×
388
            val = _convert(value[1:], dict)
×
389
        else:
390
            raise ParseError(f"Invalid dict value: {value}")
×
391
        return cls(action, dict(val))
×
392

393
    def __repr__(self) -> str:
3✔
394
        return f"{self.action} {self.val}"
×
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