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

pantsbuild / pants / 22285885220

22 Feb 2026 09:38PM UTC coverage: 92.906% (-0.03%) from 92.936%
22285885220

Pull #23121

github

web-flow
Merge 6eb6409f9 into 2f57dcead
Pull Request #23121: fix issue with optional fields in dependency validator

39 of 40 new or added lines in 3 files covered. (97.5%)

40 existing lines in 3 files now uncovered.

90882 of 97821 relevant lines covered (92.91%)

4.06 hits per line

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

95.83
/src/python/pants/engine/target.py
1
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
12✔
5

6
import collections.abc
12✔
7
import dataclasses
12✔
8
import enum
12✔
9
import glob as glob_stdlib
12✔
10
import itertools
12✔
11
import logging
12✔
12
import os.path
12✔
13
import textwrap
12✔
14
import zlib
12✔
15
from abc import ABC, ABCMeta, abstractmethod
12✔
16
from collections import deque
12✔
17
from collections.abc import Callable, Iterable, Iterator, KeysView, Mapping, Sequence
12✔
18
from dataclasses import dataclass
12✔
19
from enum import Enum
12✔
20
from operator import attrgetter
12✔
21
from pathlib import PurePath
12✔
22
from types import UnionType
12✔
23
from typing import (
12✔
24
    AbstractSet,
25
    Any,
26
    ClassVar,
27
    Generic,
28
    Protocol,
29
    Self,
30
    TypeVar,
31
    Union,
32
    cast,
33
    final,
34
    get_args,
35
    get_origin,
36
    get_type_hints,
37
)
38

39
from pants.base.deprecated import warn_or_error
12✔
40
from pants.engine.addresses import Address, Addresses, UnparsedAddressInputs, assert_single_address
12✔
41
from pants.engine.collection import Collection
12✔
42
from pants.engine.engine_aware import EngineAwareParameter
12✔
43
from pants.engine.environment import EnvironmentName
12✔
44
from pants.engine.fs import (
12✔
45
    GlobExpansionConjunction,
46
    GlobMatchErrorBehavior,
47
    PathGlobs,
48
    Paths,
49
    Snapshot,
50
)
51
from pants.engine.internals.dep_rules import (
12✔
52
    DependencyRuleActionDeniedError,
53
    DependencyRuleApplication,
54
)
55
from pants.engine.internals.native_engine import NO_VALUE as NO_VALUE  # noqa: F401
12✔
56
from pants.engine.internals.native_engine import Field as Field
12✔
57
from pants.engine.internals.target_adaptor import SourceBlock, SourceBlocks  # noqa: F401
12✔
58
from pants.engine.rules import rule
12✔
59
from pants.engine.unions import UnionMembership, UnionRule, distinct_union_type_per_subclass, union
12✔
60
from pants.option.bootstrap_options import UnmatchedBuildFileGlobs
12✔
61
from pants.source.filespec import Filespec, FilespecMatcher
12✔
62
from pants.util.collections import ensure_list, ensure_str_list
12✔
63
from pants.util.dirutil import fast_relpath
12✔
64
from pants.util.docutil import bin_name, doc_url
12✔
65
from pants.util.frozendict import FrozenDict
12✔
66
from pants.util.memo import memoized_classproperty, memoized_method, memoized_property
12✔
67
from pants.util.ordered_set import FrozenOrderedSet
12✔
68
from pants.util.strutil import bullet_list, help_text, pluralize, softwrap
12✔
69

70
logger = logging.getLogger(__name__)
12✔
71

72
# -----------------------------------------------------------------------------------------------
73
# Core Field abstractions
74
# -----------------------------------------------------------------------------------------------
75

76
# Type alias to express the intent that the type should be immutable and hashable. There's nothing
77
# to actually enforce this, outside of convention. Maybe we could develop a MyPy plugin?
78
ImmutableValue = Any
12✔
79

80

81
# NB: By subclassing `Field`, MyPy understands our type hints, and it means it doesn't matter which
82
# order you use for inheriting the field template vs. the mixin.
83
class AsyncFieldMixin(Field):
12✔
84
    """A mixin to store the field's original `Address` for use during hydration by the engine.
85

86
    Typically, you should also create a dataclass representing the hydrated value and another for
87
    the request, then a rule to go from the request to the hydrated value. The request class should
88
    store the async field as a property.
89

90
    (Why use the request class as the rule input, rather than the field itself? It's a wrapper so
91
    that subclasses of the async field work properly, given that the engine uses exact type IDs.
92
    This is like WrappedTarget.)
93

94
    For example:
95

96
        class Sources(StringSequenceField, AsyncFieldMixin):
97
            alias = "sources"
98

99
            # Often, async fields will want to define entry points like this to allow subclasses to
100
            # change behavior.
101
            def validate_resolved_files(self, files: Sequence[str]) -> None:
102
                pass
103

104

105
        @dataclass(frozen=True)
106
        class HydrateSourcesRequest:
107
            field: Sources
108

109

110
        @dataclass(frozen=True)
111
        class HydratedSources:
112
            snapshot: Snapshot
113

114

115
        @rule
116
        async def hydrate_sources(request: HydrateSourcesRequest) -> HydratedSources:
117
            digest = await path_globs_to_digest(PathGlobs(request.field.value))
118
            result = await digest_to_snapshot(digest)
119
            request.field.validate_resolved_files(result.files)
120
            ...
121
            return HydratedSources(result)
122

123
    Then, call sites can `await` if they need to hydrate the field, even if they subclassed
124
    the original async field to have custom behavior:
125

126
        sources1 = hydrate_sources(HydrateSourcesRequest(my_tgt.get(Sources)))
127
        sources2 = hydrate_sources(HydrateSourcesRequest(custom_tgt.get(CustomSources)))
128
    """
129

130
    address: Address
12✔
131

132
    @final
12✔
133
    def __new__(cls, raw_value: Any | None, address: Address) -> Self:
12✔
134
        obj = super().__new__(cls, raw_value, address)  # type: ignore[call-arg]
12✔
135
        # N.B.: We store the address here and not in the Field base class, because the memory usage
136
        # of storing this value in every field was shown to be excessive / lead to performance
137
        # issues.
138
        object.__setattr__(obj, "address", address)
12✔
139
        return obj
12✔
140

141
    def __repr__(self) -> str:
12✔
142
        params = [
×
143
            f"alias={self.alias!r}",
144
            f"address={self.address}",
145
            f"value={self.value!r}",
146
        ]
147
        if hasattr(self, "default"):
×
148
            params.append(f"default={self.default!r}")
×
149
        return f"{self.__class__}({', '.join(params)})"
×
150

151
    def __hash__(self) -> int:
12✔
152
        return hash((self.__class__, self.value, self.address))
12✔
153

154
    def __eq__(self, other: Any) -> bool:
12✔
155
        if not isinstance(other, AsyncFieldMixin):
12✔
156
            return False
×
157
        return (
12✔
158
            self.__class__ == other.__class__
159
            and self.value == other.value
160
            and self.address == other.address
161
        )
162

163
    def __ne__(self, other: Any) -> bool:
12✔
164
        return not (self == other)
1✔
165

166

167
@union
12✔
168
@dataclass(frozen=True)
12✔
169
class FieldDefaultFactoryRequest:
12✔
170
    """Registers a dynamic default for a Field.
171

172
    See `FieldDefaults`.
173
    """
174

175
    field_type: ClassVar[type[Field]]
12✔
176

177

178
# TODO: Workaround for https://github.com/python/mypy/issues/5485, because we cannot directly use
179
# a Callable.
180
class FieldDefaultFactory(Protocol):
12✔
181
    def __call__(self, field: Field) -> Any:
12✔
182
        pass
×
183

184

185
@dataclass(frozen=True)
12✔
186
class FieldDefaultFactoryResult:
12✔
187
    """A wrapper for a function which computes the default value of a Field."""
188

189
    default_factory: FieldDefaultFactory
12✔
190

191

192
@dataclass(frozen=True)
12✔
193
class FieldDefaults:
12✔
194
    """Generic Field default values. To install a default, see `FieldDefaultFactoryRequest`.
195

196
    TODO: This is to work around the fact that Field value defaulting cannot have arbitrary
197
    subsystem requirements, and so e.g. `JvmResolveField` and `PythonResolveField` have methods
198
    which compute the true value of the field given a subsystem argument. Consumers need to
199
    be type aware, and `@rules` cannot have dynamic requirements.
200

201
    Additionally, `__defaults__` should mean that computed default Field values should become
202
    more rare: i.e. `JvmResolveField` and `PythonResolveField` could potentially move to
203
    hardcoded default values which users override with `__defaults__` if they'd like to change
204
    the default resolve names.
205

206
    See https://github.com/pantsbuild/pants/issues/12934 about potentially allowing unions
207
    (including Field registrations) to have `@rule_helper` methods, which would allow the
208
    computation of an AsyncField to directly require a subsystem. Since
209
    https://github.com/pantsbuild/pants/pull/17947 rules may use any methods as rule helpers without
210
    special decoration so this should now be possible to implement.
211
    """
212

213
    _factories: FrozenDict[type[Field], FieldDefaultFactory]
12✔
214

215
    @memoized_method
12✔
216
    def factory(self, field_type: type[Field]) -> FieldDefaultFactory:
12✔
217
        """Looks up a Field default factory in a subclass-aware way."""
218
        factory = self._factories.get(field_type, None)
4✔
219
        if factory is not None:
4✔
220
            return factory
4✔
221

222
        for ft, factory in self._factories.items():
3✔
223
            if issubclass(field_type, ft):
3✔
224
                return factory
2✔
225

226
        return lambda f: f.value
2✔
227

228
    def value_or_default(self, field: Field) -> Any:
12✔
229
        return (self.factory(type(field)))(field)
4✔
230

231

232
# -----------------------------------------------------------------------------------------------
233
# Core Target abstractions
234
# -----------------------------------------------------------------------------------------------
235

236

237
# NB: This TypeVar is what allows `Target.get()` to properly work with MyPy so that MyPy knows
238
# the precise Field returned.
239
_F = TypeVar("_F", bound=Field)
12✔
240

241

242
@dataclass(frozen=True)
12✔
243
class Target:
12✔
244
    """A Target represents an addressable set of metadata.
245

246
    Set the `help` class property with a description, which will be used in `./pants help`. For the
247
    best rendering, use soft wrapping (e.g. implicit string concatenation) within paragraphs, but
248
    hard wrapping (`\n`) to separate distinct paragraphs and/or lists.
249
    """
250

251
    # Subclasses must define these
252
    alias: ClassVar[str]
12✔
253
    core_fields: ClassVar[tuple[type[Field], ...]]
12✔
254
    help: ClassVar[str | Callable[[], str]]
12✔
255

256
    removal_version: ClassVar[str | None] = None
12✔
257
    removal_hint: ClassVar[str | None] = None
12✔
258

259
    deprecated_alias: ClassVar[str | None] = None
12✔
260
    deprecated_alias_removal_version: ClassVar[str | None] = None
12✔
261

262
    # These get calculated in the constructor
263
    address: Address
12✔
264
    field_values: FrozenDict[type[Field], Field]
12✔
265
    residence_dir: str
12✔
266
    name_explicitly_set: bool
12✔
267
    description_of_origin: str
12✔
268
    origin_sources_blocks: FrozenDict[str, SourceBlocks]
12✔
269

270
    @final
12✔
271
    def __init__(
12✔
272
        self,
273
        unhydrated_values: Mapping[str, Any],
274
        address: Address,
275
        # NB: `union_membership` is only optional to facilitate tests. In production, we should
276
        # always provide this parameter. This should be safe to do because production code should
277
        # rarely directly instantiate Targets and should instead use the engine to request them.
278
        union_membership: UnionMembership | None = None,
279
        *,
280
        name_explicitly_set: bool = True,
281
        residence_dir: str | None = None,
282
        ignore_unrecognized_fields: bool = False,
283
        description_of_origin: str | None = None,
284
        origin_sources_blocks: FrozenDict[str, SourceBlocks] = FrozenDict(),
285
    ) -> None:
286
        """Create a target.
287

288
        :param unhydrated_values: A mapping of field aliases to their raw values. Any left off
289
            fields will either use their default or error if required=True.
290
        :param address: How to uniquely identify this target.
291
        :param union_membership: Used to determine plugin fields. This must be set in production!
292
        :param residence_dir: Where this target "lives". If unspecified, will be the `spec_path`
293
            of the `address`, i.e. where the target was either explicitly defined or where its
294
            target generator was explicitly defined. Target generators can, however, set this to
295
            the directory where the generated target provides metadata for. For example, a
296
            file-based target like `python_source` should set this to the parent directory of
297
            its file. A file-less target like `go_third_party_package` should keep the default of
298
            `address.spec_path`. This field impacts how command line specs work, so that globs
299
            like `dir:` know whether to match the target or not.
300
        :param ignore_unrecognized_fields: Don't error if fields are not recognized. This is only
301
            intended for when Pants is bootstrapping itself.
302
        :param description_of_origin: Where this target was declared, such as a path to BUILD file
303
            and line number.
304
        """
305
        if self.removal_version and not address.is_generated_target:
12✔
306
            if not self.removal_hint:
×
307
                raise ValueError(
×
308
                    f"You specified `removal_version` for {self.__class__}, but not "
309
                    "the class property `removal_hint`."
310
                )
311
            warn_or_error(
×
312
                self.removal_version,
313
                entity=f"the {repr(self.alias)} target type",
314
                hint=f"Using the `{self.alias}` target type for {address}. {self.removal_hint}",
315
            )
316

317
        if origin_sources_blocks:
12✔
318
            _validate_origin_sources_blocks(origin_sources_blocks)
1✔
319

320
        object.__setattr__(
12✔
321
            self, "residence_dir", residence_dir if residence_dir is not None else address.spec_path
322
        )
323
        object.__setattr__(self, "address", address)
12✔
324
        object.__setattr__(
12✔
325
            self, "description_of_origin", description_of_origin or self.residence_dir
326
        )
327
        object.__setattr__(self, "origin_sources_blocks", origin_sources_blocks)
12✔
328
        object.__setattr__(self, "name_explicitly_set", name_explicitly_set)
12✔
329
        try:
12✔
330
            object.__setattr__(
12✔
331
                self,
332
                "field_values",
333
                self._calculate_field_values(
334
                    unhydrated_values,
335
                    address,
336
                    union_membership,
337
                    ignore_unrecognized_fields=ignore_unrecognized_fields,
338
                ),
339
            )
340

341
            self.validate()
12✔
342
        except Exception as e:
8✔
343
            raise InvalidTargetException(
8✔
344
                str(e), description_of_origin=self.description_of_origin
345
            ) from e
346

347
    @final
12✔
348
    def _calculate_field_values(
12✔
349
        self,
350
        unhydrated_values: Mapping[str, Any],
351
        address: Address,
352
        # See `__init__`.
353
        union_membership: UnionMembership | None,
354
        *,
355
        ignore_unrecognized_fields: bool,
356
    ) -> FrozenDict[type[Field], Field]:
357
        all_field_types = self.class_field_types(union_membership)
12✔
358
        field_values = {}
12✔
359
        aliases_to_field_types = self._get_field_aliases_to_field_types(all_field_types)
12✔
360

361
        for alias, value in unhydrated_values.items():
12✔
362
            if alias not in aliases_to_field_types:
12✔
363
                if ignore_unrecognized_fields:
5✔
364
                    continue
1✔
365
                valid_aliases = set(aliases_to_field_types.keys())
5✔
366
                if isinstance(self, TargetGenerator):
5✔
367
                    # Even though moved_fields don't live on the target generator, they are valid
368
                    # for users to specify. It's intentional that these are only used for
369
                    # `InvalidFieldException` and are not stored as normal fields with
370
                    # `aliases_to_field_types`.
371
                    for field_type in self.moved_fields:
1✔
372
                        valid_aliases.add(field_type.alias)
1✔
373
                        if field_type.deprecated_alias is not None:
1✔
374
                            valid_aliases.add(field_type.deprecated_alias)
1✔
375
                raise InvalidFieldException(
5✔
376
                    f"Unrecognized field `{alias}={value}` in target {address}. Valid fields for "
377
                    f"the target type `{self.alias}`: {sorted(valid_aliases)}.",
378
                )
379
            field_type = aliases_to_field_types[alias]
12✔
380
            field_values[field_type] = field_type(value, address)
12✔
381

382
        # For undefined fields, mark the raw value as missing.
383
        for field_type in all_field_types:
12✔
384
            if field_type in field_values:
12✔
385
                continue
12✔
386
            field_values[field_type] = field_type(NO_VALUE, address)
12✔
387
        return FrozenDict(
12✔
388
            sorted(
389
                field_values.items(),
390
                key=lambda field_type_to_val_pair: field_type_to_val_pair[0].alias,
391
            )
392
        )
393

394
    @final
12✔
395
    @classmethod
12✔
396
    def _get_field_aliases_to_field_types(
12✔
397
        cls, field_types: Iterable[type[Field]]
398
    ) -> dict[str, type[Field]]:
399
        aliases_to_field_types = {}
12✔
400
        for field_type in field_types:
12✔
401
            aliases_to_field_types[field_type.alias] = field_type
12✔
402
            if field_type.deprecated_alias is not None:
12✔
403
                aliases_to_field_types[field_type.deprecated_alias] = field_type
1✔
404
        return aliases_to_field_types
12✔
405

406
    @final
12✔
407
    @property
12✔
408
    def field_types(self) -> KeysView[type[Field]]:
12✔
409
        return self.field_values.keys()
12✔
410

411
    @distinct_union_type_per_subclass
12✔
412
    class PluginField:
12✔
413
        pass
12✔
414

415
    def __repr__(self) -> str:
12✔
416
        fields = ", ".join(str(field) for field in self.field_values.values())
×
417
        return (
×
418
            f"{self.__class__}("
419
            f"address={self.address}, "
420
            f"alias={self.alias!r}, "
421
            f"residence_dir={self.residence_dir!r}, "
422
            f"origin={self.description_of_origin}, "
423
            f"{fields})"
424
        )
425

426
    def __str__(self) -> str:
12✔
427
        fields = ", ".join(str(field) for field in self.field_values.values())
5✔
428
        address = f'address="{self.address}"{", " if fields else ""}'
5✔
429
        return f"{self.alias}({address}{fields})"
5✔
430

431
    def __hash__(self) -> int:
12✔
432
        return hash((self.__class__, self.address, self.residence_dir, self.field_values))
12✔
433

434
    def __eq__(self, other: Target | Any) -> bool:
12✔
435
        if not isinstance(other, Target):
12✔
436
            return NotImplemented
6✔
437
        return (self.__class__, self.address, self.residence_dir, self.field_values) == (
12✔
438
            other.__class__,
439
            other.address,
440
            other.residence_dir,
441
            other.field_values,
442
        )
443

444
    def __lt__(self, other: Any) -> bool:
12✔
445
        if not isinstance(other, Target):
10✔
446
            return NotImplemented
×
447
        return self.address < other.address
10✔
448

449
    def __gt__(self, other: Any) -> bool:
12✔
450
        if not isinstance(other, Target):
×
451
            return NotImplemented
×
452
        return self.address > other.address
×
453

454
    @classmethod
12✔
455
    @memoized_method
12✔
456
    def _find_plugin_fields(cls, union_membership: UnionMembership) -> tuple[type[Field], ...]:
12✔
457
        result: set[type[Field]] = set()
12✔
458
        classes = [cls]
12✔
459
        while classes:
12✔
460
            cls = classes.pop()
12✔
461
            classes.extend(cls.__bases__)
12✔
462
            if issubclass(cls, Target):
12✔
463
                result.update(cast("set[type[Field]]", union_membership.get(cls.PluginField)))
12✔
464

465
        return tuple(sorted(result, key=attrgetter("alias")))
12✔
466

467
    @final
12✔
468
    @classmethod
12✔
469
    def _find_registered_field_subclass(
12✔
470
        cls, requested_field: type[_F], *, registered_fields: Iterable[type[Field]]
471
    ) -> type[_F] | None:
472
        """Check if the Target has registered a subclass of the requested Field.
473

474
        This is necessary to allow targets to override the functionality of common fields. For
475
        example, you could subclass `Tags` to define `CustomTags` with a different default. At the
476
        same time, we still want to be able to call `tgt.get(Tags)`, in addition to
477
        `tgt.get(CustomTags)`.
478
        """
479
        subclass = next(
12✔
480
            (
481
                registered_field
482
                for registered_field in registered_fields
483
                if issubclass(registered_field, requested_field)
484
            ),
485
            None,
486
        )
487
        return subclass
12✔
488

489
    @final
12✔
490
    def _maybe_get(self, field: type[_F]) -> _F | None:
12✔
491
        result = self.field_values.get(field, None)
12✔
492
        if result is not None:
12✔
493
            return cast(_F, result)
12✔
494
        field_subclass = self._find_registered_field_subclass(
12✔
495
            field, registered_fields=self.field_values.keys()
496
        )
497
        if field_subclass is not None:
12✔
498
            return cast(_F, self.field_values[field_subclass])
12✔
499
        return None
12✔
500

501
    @final
12✔
502
    def __getitem__(self, field: type[_F]) -> _F:
12✔
503
        """Get the requested `Field` instance belonging to this target.
504

505
        If the `Field` is not registered on this `Target` type, this method will raise a
506
        `KeyError`. To avoid this, you should first call `tgt.has_field()` or `tgt.has_fields()`
507
        to ensure that the field is registered, or, alternatively, use `Target.get()`.
508

509
        See the docstring for `Target.get()` for how this method handles subclasses of the
510
        requested Field and for tips on how to use the returned value.
511
        """
512
        result = self._maybe_get(field)
12✔
513
        if result is not None:
12✔
514
            return result
12✔
515
        raise KeyError(
1✔
516
            f"The target `{self}` does not have a field `{field.__name__}`. Before calling "
517
            f"`my_tgt[{field.__name__}]`, call `my_tgt.has_field({field.__name__})` to "
518
            f"filter out any irrelevant Targets or call `my_tgt.get({field.__name__})` to use the "
519
            f"default Field value."
520
        )
521

522
    @final
12✔
523
    def get(self, field: type[_F], *, default_raw_value: Any | None = None) -> _F:
12✔
524
        """Get the requested `Field` instance belonging to this target.
525

526
        This will return an instance of the requested field type, e.g. an instance of
527
        `InterpreterConstraints`, `SourcesField`, `EntryPoint`, etc. Usually, you will want to
528
        grab the `Field`'s inner value, e.g. `tgt.get(Compatibility).value`. (For async fields like
529
        `SourcesField`, you may need to hydrate the value.).
530

531
        This works with subclasses of `Field`. For example, if you subclass `Tags`
532
        to define a custom subclass `CustomTags`, both `tgt.get(Tags)` and
533
        `tgt.get(CustomTags)` will return the same `CustomTags` instance.
534

535
        If the `Field` is not registered on this `Target` type, this will return an instance of
536
        the requested Field by using `default_raw_value` to create the instance. Alternatively,
537
        first call `tgt.has_field()` or `tgt.has_fields()` to ensure that the field is registered,
538
        or, alternatively, use indexing (e.g. `tgt[Compatibility]`) to raise a KeyError when the
539
        field is not registered.
540
        """
541
        result = self._maybe_get(field)
12✔
542
        if result is not None:
12✔
543
            return result
12✔
544
        return field(default_raw_value, self.address)
12✔
545

546
    @final
12✔
547
    @classmethod
12✔
548
    def _has_fields(
12✔
549
        cls, fields: Iterable[type[Field]], *, registered_fields: AbstractSet[type[Field]]
550
    ) -> bool:
551
        unrecognized_fields = [field for field in fields if field not in registered_fields]
12✔
552
        if not unrecognized_fields:
12✔
553
            return True
12✔
554
        for unrecognized_field in unrecognized_fields:
12✔
555
            maybe_subclass = cls._find_registered_field_subclass(
12✔
556
                unrecognized_field, registered_fields=registered_fields
557
            )
558
            if maybe_subclass is None:
12✔
559
                return False
12✔
560
        return True
12✔
561

562
    @final
12✔
563
    def has_field(self, field: type[Field]) -> bool:
12✔
564
        """Check that this target has registered the requested field.
565

566
        This works with subclasses of `Field`. For example, if you subclass `Tags` to define a
567
        custom subclass `CustomTags`, both `tgt.has_field(Tags)` and
568
        `python_tgt.has_field(CustomTags)` will return True.
569
        """
570
        return self.has_fields([field])
12✔
571

572
    @final
12✔
573
    def has_fields(self, fields: Iterable[type[Field]]) -> bool:
12✔
574
        """Check that this target has registered all of the requested fields.
575

576
        This works with subclasses of `Field`. For example, if you subclass `Tags` to define a
577
        custom subclass `CustomTags`, both `tgt.has_fields([Tags])` and
578
        `python_tgt.has_fields([CustomTags])` will return True.
579
        """
580
        return self._has_fields(fields, registered_fields=self.field_values.keys())
12✔
581

582
    @final
12✔
583
    @classmethod
12✔
584
    @memoized_method
12✔
585
    def class_field_types(
12✔
586
        cls, union_membership: UnionMembership | None
587
    ) -> FrozenOrderedSet[type[Field]]:
588
        """Return all registered Fields belonging to this target type.
589

590
        You can also use the instance property `tgt.field_types` to avoid having to pass the
591
        parameter UnionMembership.
592
        """
593
        if union_membership is None:
12✔
594
            return FrozenOrderedSet(cls.core_fields)
12✔
595
        else:
596
            return FrozenOrderedSet((*cls.core_fields, *cls._find_plugin_fields(union_membership)))
12✔
597

598
    @final
12✔
599
    @classmethod
12✔
600
    def class_has_field(cls, field: type[Field], union_membership: UnionMembership) -> bool:
12✔
601
        """Behaves like `Target.has_field()`, but works as a classmethod rather than an instance
602
        method."""
603
        return cls.class_has_fields([field], union_membership)
4✔
604

605
    @final
12✔
606
    @classmethod
12✔
607
    def class_has_fields(
12✔
608
        cls, fields: Iterable[type[Field]], union_membership: UnionMembership
609
    ) -> bool:
610
        """Behaves like `Target.has_fields()`, but works as a classmethod rather than an instance
611
        method."""
612
        return cls._has_fields(fields, registered_fields=cls.class_field_types(union_membership))
4✔
613

614
    @final
12✔
615
    @classmethod
12✔
616
    def class_get_field(cls, field: type[_F], union_membership: UnionMembership) -> type[_F]:
12✔
617
        """Get the requested Field type registered with this target type.
618

619
        This will error if the field is not registered, so you should call Target.class_has_field()
620
        first.
621
        """
622
        class_fields = cls.class_field_types(union_membership)
2✔
623
        result = next(
2✔
624
            (
625
                registered_field
626
                for registered_field in class_fields
627
                if issubclass(registered_field, field)
628
            ),
629
            None,
630
        )
631
        if result is None:
2✔
632
            raise KeyError(
1✔
633
                f"The target type `{cls.alias}` does not have a field `{field.__name__}`. Before "
634
                f"calling `TargetType.class_get_field({field.__name__})`, call "
635
                f"`TargetType.class_has_field({field.__name__})`."
636
            )
637
        return result
2✔
638

639
    @classmethod
12✔
640
    def register_plugin_field(cls, field: type[Field]) -> UnionRule:
12✔
641
        """Register a new field on the target type.
642

643
        In the `rules()` register.py entry-point, include
644
        `MyTarget.register_plugin_field(NewField)`. This will register `NewField` as a first-class
645
        citizen. Plugins can use this new field like any other.
646
        """
647
        return UnionRule(cls.PluginField, field)
12✔
648

649
    def validate(self) -> None:
12✔
650
        """Validate the target, such as checking for mutually exclusive fields.
651

652
        N.B.: The validation should only be of properties intrinsic to the associated files in any
653
        context. If the validation only makes sense for certain goals acting on targets; those
654
        validations should be done in the associated rules.
655
        """
656

657

658
def _validate_origin_sources_blocks(origin_sources_blocks: FrozenDict[str, SourceBlocks]) -> None:
12✔
659
    if not isinstance(origin_sources_blocks, FrozenDict):
2✔
660
        raise ValueError(
1✔
661
            f"Expected `origin_sources_blocks` to be of type `FrozenDict`, got {type(origin_sources_blocks)=} {origin_sources_blocks=}"
662
        )
663
    for blocks in origin_sources_blocks.values():
2✔
664
        if not isinstance(blocks, SourceBlocks):
2✔
665
            raise ValueError(
1✔
666
                f"Expected `origin_sources_blocks` to be a `FrozenDict` with values of type `SourceBlocks`, got values of {type(blocks)=} {blocks=}"
667
            )
668
        for block in blocks:
2✔
669
            if not isinstance(block, SourceBlock):
2✔
670
                raise ValueError(
×
671
                    f"Expected `origin_sources_blocks` to be a `FrozenDict` with values of type `SourceBlocks`, got values of {type(blocks)=} {blocks=}"
672
                )
673

674

675
@dataclass(frozen=True)
12✔
676
class WrappedTargetRequest:
12✔
677
    """Used with `WrappedTarget` to get the Target corresponding to an address.
678

679
    `description_of_origin` is used for error messages when the address does not actually exist. If
680
    you are confident this cannot happen, set the string to something like `<infallible>`.
681
    """
682

683
    address: Address
12✔
684
    description_of_origin: str = dataclasses.field(hash=False, compare=False)
12✔
685

686

687
@dataclass(frozen=True)
12✔
688
class WrappedTarget:
12✔
689
    """A light wrapper to encapsulate all the distinct `Target` subclasses into a single type.
690

691
    This is necessary when using a single target in a rule because the engine expects exact types
692
    and does not work with subtypes.
693
    """
694

695
    target: Target
12✔
696

697

698
class Targets(Collection[Target]):
12✔
699
    """A heterogeneous collection of instances of Target subclasses.
700

701
    While every element will be a subclass of `Target`, there may be many different `Target` types
702
    in this collection, e.g. some `FileTarget` and some `PythonTestTarget`.
703

704
    Often, you will want to filter out the relevant targets by looking at what fields they have
705
    registered, e.g.:
706

707
        valid_tgts = [tgt for tgt in tgts if tgt.has_fields([Compatibility, PythonSources])]
708

709
    You should not check the Target's actual type because this breaks custom target types;
710
    for example, prefer `tgt.has_field(PythonTestsSourcesField)` to
711
    `isinstance(tgt, PythonTestsTarget)`.
712
    """
713

714
    def expect_single(self) -> Target:
12✔
715
        assert_single_address([tgt.address for tgt in self])
10✔
716
        return self[0]
10✔
717

718

719
# This distinct type is necessary because of https://github.com/pantsbuild/pants/issues/14977.
720
#
721
# NB: We still proactively apply filtering inside `AddressSpecs` and `FilesystemSpecs`, which is
722
# earlier in the rule pipeline of `RawSpecs -> Addresses -> UnexpandedTargets -> Targets ->
723
# FilteredTargets`. That is necessary so that project-introspection goals like `list` which don't
724
# use `FilteredTargets` still have filtering applied.
725
class FilteredTargets(Collection[Target]):
12✔
726
    """A heterogeneous collection of Target instances that have been filtered with the global
727
    options `--tag` and `--exclude-target-regexp`.
728

729
    Outside of the extra filtering, this type is identical to `Targets`, including its handling of
730
    target generators.
731
    """
732

733
    def expect_single(self) -> Target:
12✔
734
        assert_single_address([tgt.address for tgt in self])
×
735
        return self[0]
×
736

737

738
class UnexpandedTargets(Collection[Target]):
12✔
739
    """Like `Targets`, but will not replace target generators with their generated targets (e.g.
740
    replace `python_sources` "BUILD targets" with generated `python_source` "file targets")."""
741

742
    def expect_single(self) -> Target:
12✔
743
        assert_single_address([tgt.address for tgt in self])
×
744
        return self[0]
×
745

746

747
class DepsTraversalBehavior(Enum):
12✔
748
    """The return value for ShouldTraverseDepsPredicate.
749

750
    NB: This only indicates whether to traverse the deps of a target;
751
    It does not control the inclusion of the target itself (though that
752
    might be added in the future). By the time the predicate is called,
753
    the target itself was already included.
754
    """
755

756
    INCLUDE = "include"
12✔
757
    EXCLUDE = "exclude"
12✔
758

759

760
@dataclass(frozen=True)
12✔
761
class ShouldTraverseDepsPredicate(metaclass=ABCMeta):
12✔
762
    """This callable determines whether to traverse through deps of a given Target + Field.
763

764
    This is a callable dataclass instead of a function to avoid issues with hashing closures.
765
    Only the id is used when hashing a function; any closure vars are NOT accounted for.
766

767
    NB: When subclassing a dataclass (like this one), you do not need to add the @dataclass
768
    decorator unless the subclass has additional fields. The @dataclass decorator only inspects
769
    the current class (not any parents from __mro__) to determine if any methods were explicitly
770
    defined. So, any typically-generated methods explicitly added on this parent class would NOT
771
    be inherited by @dataclass decorated subclasses. To avoid these issues, this parent class
772
    uses __post_init__ and relies on the generated __init__ and __hash__ methods.
773
    """
774

775
    # NB: This _callable field ensures that __call__ is included in the __hash__ method generated by @dataclass.
776
    # That is extremely important because two predicates with different implementations but the same data
777
    # (or no data) need to have different hashes and compare unequal.
778
    _callable: Callable[
12✔
779
        [Any, Target, Dependencies | SpecialCasedDependencies], DepsTraversalBehavior
780
    ] = dataclasses.field(init=False, repr=False)
781

782
    def __post_init__(self):
12✔
783
        object.__setattr__(self, "_callable", type(self).__call__)
12✔
784

785
    @abstractmethod
12✔
786
    def __call__(
12✔
787
        self, target: Target, field: Dependencies | SpecialCasedDependencies
788
    ) -> DepsTraversalBehavior:
789
        """This predicate decides when to INCLUDE or EXCLUDE the target's field's deps."""
790

791

792
class TraverseIfDependenciesField(ShouldTraverseDepsPredicate):
12✔
793
    """This is the default ShouldTraverseDepsPredicate implementation.
794

795
    This skips resolving dependencies for fields (like SpecialCasedDependencies) that are not
796
    subclasses of Dependencies.
797
    """
798

799
    def __call__(
12✔
800
        self, target: Target, field: Dependencies | SpecialCasedDependencies
801
    ) -> DepsTraversalBehavior:
802
        if isinstance(field, Dependencies):
12✔
803
            return DepsTraversalBehavior.INCLUDE
12✔
804
        return DepsTraversalBehavior.EXCLUDE
12✔
805

806

807
class AlwaysTraverseDeps(ShouldTraverseDepsPredicate):
12✔
808
    """A predicate to use when a request needs all deps.
809

810
    This includes deps from fields like SpecialCasedDependencies which are ignored in most cases.
811
    """
812

813
    def __call__(
12✔
814
        self, target: Target, field: Dependencies | SpecialCasedDependencies
815
    ) -> DepsTraversalBehavior:
816
        return DepsTraversalBehavior.INCLUDE
6✔
817

818

819
class CoarsenedTarget(EngineAwareParameter):
12✔
820
    def __init__(self, members: Iterable[Target], dependencies: Iterable[CoarsenedTarget]) -> None:
12✔
821
        """A set of Targets which cyclically reach one another, and are thus indivisible.
822

823
        Instances of this class form a structure-shared DAG, and so a hashcode is pre-computed for the
824
        recursive portion.
825

826
        :param members: The members of the cycle.
827
        :param dependencies: The deduped direct (not transitive) dependencies of all Targets in
828
            the cycle. Dependencies between members of the cycle are excluded.
829
        """
830
        self.members = FrozenOrderedSet(members)
12✔
831
        self.dependencies = FrozenOrderedSet(dependencies)
12✔
832
        self._hashcode = hash((self.members, self.dependencies))
12✔
833

834
    def debug_hint(self) -> str:
12✔
835
        return str(self)
×
836

837
    def metadata(self) -> dict[str, Any]:
12✔
838
        return {"addresses": [t.address.spec for t in self.members]}
×
839

840
    @property
12✔
841
    def representative(self) -> Target:
12✔
842
        """A stable "representative" target in the cycle."""
843
        return next(iter(self.members))
10✔
844

845
    def bullet_list(self) -> str:
12✔
846
        """The addresses and type aliases of all members of the cycle."""
847
        return bullet_list(sorted(f"{t.address.spec}\t({type(t).alias})" for t in self.members))
1✔
848

849
    def closure(self, visited: set[CoarsenedTarget] | None = None) -> Iterator[Target]:
12✔
850
        """All Targets reachable from this root."""
851
        return (t for ct in self.coarsened_closure(visited) for t in ct.members)
11✔
852

853
    def coarsened_closure(
12✔
854
        self, visited: set[CoarsenedTarget] | None = None
855
    ) -> Iterator[CoarsenedTarget]:
856
        """All CoarsenedTargets reachable from this root."""
857

858
        visited = set() if visited is None else visited
11✔
859
        queue = deque([self])
11✔
860
        while queue:
11✔
861
            ct = queue.popleft()
11✔
862
            if ct in visited:
11✔
863
                continue
6✔
864
            visited.add(ct)
11✔
865
            yield ct
11✔
866
            queue.extend(ct.dependencies)
11✔
867

868
    def __hash__(self) -> int:
12✔
869
        return self._hashcode
12✔
870

871
    def _eq_helper(self, other: CoarsenedTarget, equal_items: set[tuple[int, int]]) -> bool:
12✔
872
        key = (id(self), id(other))
11✔
873
        if key[0] == key[1] or key in equal_items:
11✔
874
            return True
7✔
875

876
        is_eq = (
11✔
877
            self._hashcode == other._hashcode
878
            and self.members == other.members
879
            and len(self.dependencies) == len(other.dependencies)
880
            and all(
881
                l._eq_helper(r, equal_items) for l, r in zip(self.dependencies, other.dependencies)
882
            )
883
        )
884

885
        # NB: We only track equal items because any non-equal item will cause the entire
886
        # operation to shortcircuit.
887
        if is_eq:
11✔
888
            equal_items.add(key)
11✔
889
        return is_eq
11✔
890

891
    def __eq__(self, other: Any) -> bool:
12✔
892
        if not isinstance(other, CoarsenedTarget):
8✔
893
            return NotImplemented
×
894
        return self._eq_helper(other, set())
8✔
895

896
    def __str__(self) -> str:
12✔
897
        if len(self.members) > 1:
10✔
898
            others = len(self.members) - 1
3✔
899
            return f"{self.representative.address.spec} (and {others} more)"
3✔
900
        return self.representative.address.spec
10✔
901

902
    def __repr__(self) -> str:
12✔
903
        return f"{self.__class__.__name__}({str(self)})"
×
904

905

906
class CoarsenedTargets(Collection[CoarsenedTarget]):
12✔
907
    """The CoarsenedTarget roots of a transitive graph walk for some addresses.
908

909
    To collect all reachable CoarsenedTarget members, use `def closure`.
910
    """
911

912
    def by_address(self) -> dict[Address, CoarsenedTarget]:
12✔
913
        """Compute a mapping from Address to containing CoarsenedTarget."""
914
        return {t.address: ct for ct in self for t in ct.members}
6✔
915

916
    def closure(self) -> Iterator[Target]:
12✔
917
        """All Targets reachable from these CoarsenedTarget roots."""
918
        visited: set[CoarsenedTarget] = set()
11✔
919
        return (t for root in self for t in root.closure(visited))
11✔
920

921
    def coarsened_closure(self) -> Iterator[CoarsenedTarget]:
12✔
922
        """All CoarsenedTargets reachable from these CoarsenedTarget roots."""
923
        visited: set[CoarsenedTarget] = set()
1✔
924
        return (ct for root in self for ct in root.coarsened_closure(visited))
1✔
925

926
    def __eq__(self, other: Any) -> bool:
12✔
927
        if not isinstance(other, CoarsenedTargets):
10✔
928
            return NotImplemented
×
929
        equal_items: set[tuple[int, int]] = set()
10✔
930
        return len(self) == len(other) and all(
10✔
931
            l._eq_helper(r, equal_items) for l, r in zip(self, other)
932
        )
933

934
    def __hash__(self):
12✔
935
        return super().__hash__()
11✔
936

937

938
@dataclass(frozen=True)
12✔
939
class CoarsenedTargetsRequest:
12✔
940
    """A request to get CoarsenedTargets for input roots."""
941

942
    roots: tuple[Address, ...]
12✔
943
    expanded_targets: bool
12✔
944
    should_traverse_deps_predicate: ShouldTraverseDepsPredicate
12✔
945

946
    def __init__(
12✔
947
        self,
948
        roots: Iterable[Address],
949
        *,
950
        expanded_targets: bool = False,
951
        should_traverse_deps_predicate: ShouldTraverseDepsPredicate = TraverseIfDependenciesField(),
952
    ) -> None:
953
        object.__setattr__(self, "roots", tuple(roots))
12✔
954
        object.__setattr__(self, "expanded_targets", expanded_targets)
12✔
955
        object.__setattr__(self, "should_traverse_deps_predicate", should_traverse_deps_predicate)
12✔
956

957

958
@dataclass(frozen=True)
12✔
959
class TransitiveTargets:
12✔
960
    """A set of Target roots, and their transitive, flattened, de-duped dependencies.
961

962
    If a target root is a dependency of another target root, then it will show up both in `roots`
963
    and in `dependencies`.
964
    """
965

966
    roots: tuple[Target, ...]
12✔
967
    dependencies: FrozenOrderedSet[Target]
12✔
968

969
    @memoized_property
12✔
970
    def closure(self) -> FrozenOrderedSet[Target]:
12✔
971
        """The roots and the dependencies combined."""
972
        return FrozenOrderedSet([*self.roots, *self.dependencies])
12✔
973

974

975
@dataclass(frozen=True)
12✔
976
class TransitiveTargetsRequest:
12✔
977
    """A request to get the transitive dependencies of the input roots."""
978

979
    roots: tuple[Address, ...]
12✔
980
    should_traverse_deps_predicate: ShouldTraverseDepsPredicate
12✔
981

982
    def __init__(
12✔
983
        self,
984
        roots: Iterable[Address],
985
        *,
986
        should_traverse_deps_predicate: ShouldTraverseDepsPredicate = TraverseIfDependenciesField(),
987
    ) -> None:
988
        object.__setattr__(self, "roots", tuple(roots))
12✔
989
        object.__setattr__(self, "should_traverse_deps_predicate", should_traverse_deps_predicate)
12✔
990

991

992
@dataclass(frozen=True)
12✔
993
class RegisteredTargetTypes:
12✔
994
    aliases_to_types: FrozenDict[str, type[Target]]
12✔
995

996
    def __init__(self, aliases_to_types: Mapping[str, type[Target]]) -> None:
12✔
997
        object.__setattr__(self, "aliases_to_types", FrozenDict(aliases_to_types))
12✔
998

999
    @classmethod
12✔
1000
    def create(cls, target_types: Iterable[type[Target]]) -> RegisteredTargetTypes:
12✔
1001
        result = {}
12✔
1002
        for target_type in sorted(target_types, key=lambda tt: tt.alias):
12✔
1003
            result[target_type.alias] = target_type
12✔
1004
            if target_type.deprecated_alias is not None:
12✔
1005
                result[target_type.deprecated_alias] = target_type
2✔
1006
        return cls(result)
12✔
1007

1008
    @property
12✔
1009
    def aliases(self) -> FrozenOrderedSet[str]:
12✔
1010
        return FrozenOrderedSet(self.aliases_to_types.keys())
12✔
1011

1012
    @property
12✔
1013
    def types(self) -> FrozenOrderedSet[type[Target]]:
12✔
1014
        return FrozenOrderedSet(self.aliases_to_types.values())
3✔
1015

1016

1017
class AllTargets(Collection[Target]):
12✔
1018
    """All targets in the project, but with target generators replaced by their generated targets,
1019
    unlike `AllUnexpandedTargets`."""
1020

1021

1022
class AllUnexpandedTargets(Collection[Target]):
12✔
1023
    """All targets in the project, including generated targets.
1024

1025
    This should generally be avoided because it is relatively expensive to compute and is frequently
1026
    invalidated, but it can be necessary for things like dependency inference to build a global
1027
    mapping of imports to targets.
1028
    """
1029

1030

1031
# -----------------------------------------------------------------------------------------------
1032
# Target generation
1033
# -----------------------------------------------------------------------------------------------
1034

1035

1036
class TargetGenerator(Target):
12✔
1037
    """A Target type which generates other Targets via installed `@rule` logic.
1038

1039
    To act as a generator, a Target type should subclass this base class and install generation
1040
    `@rule`s which consume a corresponding GenerateTargetsRequest subclass to produce
1041
    GeneratedTargets.
1042
    """
1043

1044
    # The generated Target class.
1045
    #
1046
    # If this is not provided, consider checking for the default values that applies to the target
1047
    # types being generated manually. The applicable defaults are available on the `AddressFamily`
1048
    # which you can get using:
1049
    #
1050
    #    family = await ensure_address_family(**implicitly(AddressFamilyDir(address.spec_path)))
1051
    #    target_defaults = family.defaults.get(MyTarget.alias, {})
1052
    generated_target_cls: ClassVar[type[Target]]
12✔
1053

1054
    # Fields which have their values copied from the generator Target to the generated Target.
1055
    #
1056
    # Must be a subset of `core_fields`.
1057
    #
1058
    # Fields should be copied from the generator to the generated when their semantic meaning is
1059
    # the same for both Target types, and when it is valuable for them to be introspected on
1060
    # either the generator or generated target (such as by `peek`, or in `filter`).
1061
    copied_fields: ClassVar[tuple[type[Field], ...]]
12✔
1062

1063
    # Fields which are specified to instances of the generator Target, but which are propagated
1064
    # to generated Targets rather than being stored on the generator Target.
1065
    #
1066
    # Must be disjoint from `core_fields`.
1067
    #
1068
    # Only Fields which are moved to the generated Target are allowed to be `parametrize`d. But
1069
    # it can also be the case that a Field only makes sense semantically when it is applied to
1070
    # the generated Target (for example, for an individual file), and the generator Target is just
1071
    # acting as a convenient place for them to be specified.
1072
    moved_fields: ClassVar[tuple[type[Field], ...]]
12✔
1073

1074
    @distinct_union_type_per_subclass
12✔
1075
    class MovedPluginField:
12✔
1076
        """A plugin field that should be moved into the generated targets."""
1077

1078
    def validate(self) -> None:
12✔
1079
        super().validate()
12✔
1080

1081
        copied_dependencies_field_types = [
12✔
1082
            field_type.__name__
1083
            for field_type in type(self).copied_fields
1084
            if issubclass(field_type, Dependencies)
1085
        ]
1086
        if copied_dependencies_field_types:
12✔
1087
            raise InvalidTargetException(
×
1088
                f"Using a `Dependencies` field subclass ({copied_dependencies_field_types}) as a "
1089
                "`TargetGenerator.copied_field`. `Dependencies` fields should be "
1090
                "`TargetGenerator.moved_field`s, to avoid redundant graph edges."
1091
            )
1092

1093
    @classmethod
12✔
1094
    def register_plugin_field(cls, field: type[Field], *, as_moved_field=False) -> UnionRule:
12✔
1095
        if as_moved_field:
12✔
1096
            return UnionRule(cls.MovedPluginField, field)
9✔
1097
        else:
1098
            return super().register_plugin_field(field)
12✔
1099

1100
    @classmethod
12✔
1101
    @memoized_method
12✔
1102
    def _find_plugin_fields(cls, union_membership: UnionMembership) -> tuple[type[Field], ...]:
12✔
1103
        return (
12✔
1104
            *cls._find_copied_plugin_fields(union_membership),
1105
            *cls._find_moved_plugin_fields(union_membership),
1106
        )
1107

1108
    @final
12✔
1109
    @classmethod
12✔
1110
    @memoized_method
12✔
1111
    def _find_moved_plugin_fields(
12✔
1112
        cls, union_membership: UnionMembership
1113
    ) -> tuple[type[Field], ...]:
1114
        result: set[type[Field]] = set()
12✔
1115
        classes = [cls]
12✔
1116
        while classes:
12✔
1117
            cls = classes.pop()
12✔
1118
            classes.extend(cls.__bases__)
12✔
1119
            if issubclass(cls, TargetGenerator):
12✔
1120
                result.update(cast("set[type[Field]]", union_membership.get(cls.MovedPluginField)))
12✔
1121

1122
        return tuple(result)
12✔
1123

1124
    @final
12✔
1125
    @classmethod
12✔
1126
    @memoized_method
12✔
1127
    def _find_copied_plugin_fields(
12✔
1128
        cls, union_membership: UnionMembership
1129
    ) -> tuple[type[Field], ...]:
1130
        return super()._find_plugin_fields(union_membership)
12✔
1131

1132

1133
class TargetFilesGenerator(TargetGenerator):
12✔
1134
    """A TargetGenerator which generates a Target per file matched by the generator.
1135

1136
    Unlike TargetGenerator, no additional `@rules` are required to be installed, because generation
1137
    is implemented declaratively. But an optional `settings_request_cls` can be declared to
1138
    dynamically control some settings of generation.
1139
    """
1140

1141
    settings_request_cls: ClassVar[type[TargetFilesGeneratorSettingsRequest] | None] = None
12✔
1142

1143
    def validate(self) -> None:
12✔
1144
        super().validate()
12✔
1145

1146
        if self.has_field(MultipleSourcesField) and not self[MultipleSourcesField].value:
12✔
1147
            raise InvalidTargetException(
×
1148
                f"The `{self.alias}` target generator at {self.address} has an empty "
1149
                f"`{self[MultipleSourcesField].alias}` field; so it will not generate any targets. "
1150
                "If its purpose is to act as an alias for its dependencies, then it should be "
1151
                "declared as a `target(..)` generic target instead. If it is unused, then it "
1152
                "should be removed."
1153
            )
1154

1155

1156
@union(in_scope_types=[EnvironmentName])
12✔
1157
class TargetFilesGeneratorSettingsRequest:
12✔
1158
    """An optional union to provide dynamic settings for a `TargetFilesGenerator`.
1159

1160
    See `TargetFilesGenerator`.
1161
    """
1162

1163

1164
@dataclass
12✔
1165
class TargetFilesGeneratorSettings:
12✔
1166
    # Set `add_dependencies_on_all_siblings` to True so that each file-level target depends on all
1167
    # other generated targets from the target generator. This is useful if both are true:
1168
    #
1169
    # a) file-level targets usually need their siblings to be present to work. Most target types
1170
    #   (Python, Java, Shell, etc) meet this, except for `files` and `resources` which have no
1171
    #   concept of "imports"
1172
    # b) dependency inference cannot infer dependencies on sibling files.
1173
    #
1174
    # Otherwise, set `add_dependencies_on_all_siblings` to `False` so that dependencies are
1175
    # finer-grained.
1176
    add_dependencies_on_all_siblings: bool = False
12✔
1177

1178

1179
_TargetGenerator = TypeVar("_TargetGenerator", bound=TargetGenerator)
12✔
1180

1181

1182
@union(in_scope_types=[EnvironmentName])
12✔
1183
@dataclass(frozen=True)
12✔
1184
class GenerateTargetsRequest(Generic[_TargetGenerator]):
12✔
1185
    generate_from: ClassVar[type[_TargetGenerator]]
12✔
1186

1187
    # The TargetGenerator instance to generate targets for.
1188
    generator: _TargetGenerator
12✔
1189
    # The base Address to generate for. Note that due to parametrization, this may not
1190
    # always be the Address of the underlying target.
1191
    template_address: Address
12✔
1192
    # The `TargetGenerator.moved_field/copied_field` Field values that the generator
1193
    # should generate targets with.
1194
    template: Mapping[str, Any] = dataclasses.field(hash=False)
12✔
1195
    # Per-generated-Target overrides, with an additional `template_address` to be applied. The
1196
    # per-instance Address might not match the base `template_address` if parametrization was
1197
    # applied within overrides.
1198
    overrides: Mapping[str, Mapping[Address, Mapping[str, Any]]] = dataclasses.field(hash=False)
12✔
1199

1200
    def require_unparametrized_overrides(self) -> dict[str, Mapping[str, Any]]:
12✔
1201
        """Flattens overrides for `GenerateTargetsRequest` impls which don't support `parametrize`.
1202

1203
        If `parametrize` has been used in overrides, this will raise an error indicating that that is
1204
        not yet supported for the generator target type.
1205

1206
        TODO: https://github.com/pantsbuild/pants/issues/14430 covers porting implementations and
1207
        removing this method.
1208
        """
1209
        if any(len(templates) != 1 for templates in self.overrides.values()):
9✔
1210
            raise ValueError(
×
1211
                f"Target generators of type `{self.generate_from.alias}` (defined at "
1212
                f"`{self.generator.address}`) do not (yet) support use of the `parametrize(..)` "
1213
                f"builtin in their `{OverridesField.alias}=` field."
1214
            )
1215
        return {name: next(iter(templates.values())) for name, templates in self.overrides.items()}
9✔
1216

1217

1218
class GeneratedTargets(FrozenDict[Address, Target]):
12✔
1219
    """A mapping of the address of generated targets to the targets themselves."""
1220

1221
    def __init__(self, generator: Target, generated_targets: Iterable[Target]) -> None:
12✔
1222
        expected_spec_path = generator.address.spec_path
12✔
1223
        expected_tgt_name = generator.address.target_name
12✔
1224
        mapping = {}
12✔
1225
        for tgt in sorted(generated_targets, key=lambda t: t.address):
12✔
1226
            if tgt.address.spec_path != expected_spec_path:
12✔
1227
                raise InvalidGeneratedTargetException(
1✔
1228
                    "All generated targets must have the same `Address.spec_path` as their "
1229
                    f"target generator. Expected {generator.address.spec_path}, but got "
1230
                    f"{tgt.address.spec_path} for target generated from {generator.address}: {tgt}"
1231
                    "\n\nConsider using `request.generator.address.create_generated()`."
1232
                )
1233
            if tgt.address.target_name != expected_tgt_name:
12✔
1234
                raise InvalidGeneratedTargetException(
1✔
1235
                    "All generated targets must have the same `Address.target_name` as their "
1236
                    f"target generator. Expected {generator.address.target_name}, but got "
1237
                    f"{tgt.address.target_name} for target generated from {generator.address}: "
1238
                    f"{tgt}\n\n"
1239
                    "Consider using `request.generator.address.create_generated()`."
1240
                )
1241
            if not tgt.address.is_generated_target:
12✔
1242
                raise InvalidGeneratedTargetException(
×
1243
                    "All generated targets must set `Address.generator_name` or "
1244
                    "`Address.relative_file_path`. Invalid for target generated from "
1245
                    f"{generator.address}: {tgt}\n\n"
1246
                    "Consider using `request.generator.address.create_generated()`."
1247
                )
1248
            mapping[tgt.address] = tgt
12✔
1249
        super().__init__(mapping)
12✔
1250

1251

1252
@rule(polymorphic=True)
12✔
1253
async def generate_targets(req: GenerateTargetsRequest) -> GeneratedTargets:
12✔
1254
    raise NotImplementedError()
×
1255

1256

1257
class TargetTypesToGenerateTargetsRequests(
12✔
1258
    FrozenDict[type[TargetGenerator], type[GenerateTargetsRequest]]
1259
):
1260
    def is_generator(self, tgt: Target) -> bool:
12✔
1261
        """Does this target type generate other targets?"""
1262
        return isinstance(tgt, TargetGenerator) and bool(self.request_for(type(tgt)))
12✔
1263

1264
    def request_for(self, tgt_cls: type[TargetGenerator]) -> type[GenerateTargetsRequest] | None:
12✔
1265
        """Return the request type for the given Target, or None."""
1266
        if issubclass(tgt_cls, TargetFilesGenerator):
12✔
1267
            return self.get(TargetFilesGenerator)
12✔
1268
        return self.get(tgt_cls)
12✔
1269

1270

1271
def _generate_file_level_targets(
12✔
1272
    generated_target_cls: type[Target],
1273
    generator: Target,
1274
    paths: Sequence[str],
1275
    template_address: Address,
1276
    template: Mapping[str, Any],
1277
    overrides: Mapping[str, Mapping[Address, Mapping[str, Any]]],
1278
    # NB: Should only ever be set to `None` in tests.
1279
    union_membership: UnionMembership | None,
1280
    *,
1281
    add_dependencies_on_all_siblings: bool,
1282
) -> GeneratedTargets:
1283
    """Generate one new target for each path, using the same fields as the generator target except
1284
    for the `sources` field only referring to the path and using a new address.
1285

1286
    Set `add_dependencies_on_all_siblings` to True so that each file-level target depends on all
1287
    other generated targets from the target generator. This is useful if both are true:
1288

1289
        a) file-level targets usually need their siblings to be present to work. Most target types
1290
          (Python, Java, Shell, etc) meet this, except for `files` and `resources` which have no
1291
          concept of "imports"
1292
        b) dependency inference cannot infer dependencies on sibling files.
1293

1294
    Otherwise, set `add_dependencies_on_all_siblings` to `False` so that dependencies are
1295
    finer-grained.
1296

1297
    `overrides` allows changing the fields for particular targets. It expects the full file path
1298
     as the key.
1299
    """
1300

1301
    # Paths will have already been globbed, so they should be escaped. See
1302
    # https://github.com/pantsbuild/pants/issues/15381.
1303
    paths = [glob_stdlib.escape(path) for path in paths]
12✔
1304

1305
    normalized_overrides = dict(overrides or {})
12✔
1306

1307
    all_generated_items: list[tuple[Address, str, dict[str, Any]]] = []
12✔
1308
    for fp in paths:
12✔
1309
        relativized_fp = fast_relpath(fp, template_address.spec_path)
12✔
1310

1311
        generated_overrides = normalized_overrides.pop(fp, None)
12✔
1312
        if generated_overrides is None:
12✔
1313
            # No overrides apply.
1314
            all_generated_items.append(
12✔
1315
                (template_address.create_file(relativized_fp), fp, dict(template))
1316
            )
1317
        else:
1318
            # At least one override applies. Generate a target per set of fields.
1319
            all_generated_items.extend(
4✔
1320
                (
1321
                    overridden_address.create_file(relativized_fp),
1322
                    fp,
1323
                    {**template, **override_fields},
1324
                )
1325
                for overridden_address, override_fields in generated_overrides.items()
1326
            )
1327

1328
    # TODO: Parametrization in overrides will result in some unusual internal dependencies when
1329
    # `add_dependencies_on_all_siblings`. Similar to inference, `add_dependencies_on_all_siblings`
1330
    # should probably be field value aware.
1331
    all_generated_address_specs = (
12✔
1332
        FrozenOrderedSet(addr.spec for addr, _, _ in all_generated_items)
1333
        if add_dependencies_on_all_siblings
1334
        else FrozenOrderedSet()
1335
    )
1336

1337
    def gen_tgt(address: Address, full_fp: str, generated_target_fields: dict[str, Any]) -> Target:
12✔
1338
        if add_dependencies_on_all_siblings:
12✔
1339
            if union_membership and not generated_target_cls.class_has_field(
1✔
1340
                Dependencies, union_membership
1341
            ):
1342
                raise AssertionError(
×
1343
                    f"The {type(generator).__name__} target class generates "
1344
                    f"{generated_target_cls.__name__} targets, which do not "
1345
                    f"have a `{Dependencies.alias}` field, and thus cannot "
1346
                    "`add_dependencies_on_all_siblings`."
1347
                )
1348
            original_deps = generated_target_fields.get(Dependencies.alias, ())
1✔
1349
            generated_target_fields[Dependencies.alias] = tuple(original_deps) + tuple(
1✔
1350
                all_generated_address_specs - {address.spec}
1351
            )
1352

1353
        generated_target_fields[SingleSourceField.alias] = fast_relpath(full_fp, address.spec_path)
12✔
1354
        return generated_target_cls(
12✔
1355
            generated_target_fields,
1356
            address,
1357
            union_membership=union_membership,
1358
            residence_dir=os.path.dirname(full_fp),
1359
        )
1360

1361
    result = tuple(
12✔
1362
        gen_tgt(address, full_fp, fields) for address, full_fp, fields in all_generated_items
1363
    )
1364

1365
    if normalized_overrides:
12✔
1366
        unused_relative_paths = sorted(
1✔
1367
            fast_relpath(fp, template_address.spec_path) for fp in normalized_overrides
1368
        )
1369
        all_valid_relative_paths = sorted(
1✔
1370
            cast(str, tgt.address.relative_file_path or tgt.address.generated_name)
1371
            for tgt in result
1372
        )
1373
        raise InvalidFieldException(
1✔
1374
            f"Unused file paths in the `overrides` field for {template_address}: "
1375
            f"{sorted(unused_relative_paths)}"
1376
            f"\n\nDid you mean one of these valid paths?\n\n"
1377
            f"{all_valid_relative_paths}"
1378
        )
1379

1380
    return GeneratedTargets(generator, result)
12✔
1381

1382

1383
# -----------------------------------------------------------------------------------------------
1384
# FieldSet
1385
# -----------------------------------------------------------------------------------------------
1386
def _get_field_set_fields_from_target(
12✔
1387
    field_set: type[FieldSet], target: Target
1388
) -> dict[str, Field]:
1389
    return {
12✔
1390
        dataclass_field_name: (
1391
            target[field_cls] if field_cls in field_set.required_fields else target.get(field_cls)
1392
        )
1393
        for dataclass_field_name, field_cls in field_set.fields.items()
1394
    }
1395

1396

1397
_FS = TypeVar("_FS", bound="FieldSet")
12✔
1398

1399

1400
@dataclass(frozen=True)
12✔
1401
class FieldSet(EngineAwareParameter, metaclass=ABCMeta):
12✔
1402
    """An ad hoc set of fields from a target which are used by rules.
1403

1404
    Subclasses should declare all the fields they consume as dataclass attributes. They should also
1405
    indicate which of these are required, rather than optional, through the class property
1406
    `required_fields`. When a field is optional, the default constructor for the field will be used
1407
    for any targets that do not have that field registered.
1408

1409
    Subclasses must set `@dataclass(frozen=True)` for their declared fields to be recognized.
1410

1411
    You can optionally implement the classmethod `opt_out` so that targets have a
1412
    mechanism to not match with the FieldSet even if they have the `required_fields` registered.
1413

1414
    For example:
1415

1416
        @dataclass(frozen=True)
1417
        class FortranTestFieldSet(FieldSet):
1418
            required_fields = (FortranSources,)
1419

1420
            sources: FortranSources
1421
            fortran_version: FortranVersion
1422

1423
            @classmethod
1424
            def opt_out(cls, tgt: Target) -> bool:
1425
                return tgt.get(MaybeSkipFortranTestsField).value
1426

1427
    This field set may then be created from a `Target` through the `is_applicable()` and `create()`
1428
    class methods:
1429

1430
        field_sets = [
1431
            FortranTestFieldSet.create(tgt) for tgt in targets
1432
            if FortranTestFieldSet.is_applicable(tgt)
1433
        ]
1434

1435
    FieldSets are consumed like any normal dataclass:
1436

1437
        print(field_set.address)
1438
        print(field_set.sources)
1439
    """
1440

1441
    required_fields: ClassVar[tuple[type[Field], ...]]
12✔
1442

1443
    address: Address
12✔
1444

1445
    @classmethod
12✔
1446
    def opt_out(cls, tgt: Target) -> bool:
12✔
1447
        """If `True`, the target will not match with the field set, even if it has the FieldSet's
1448
        `required_fields`.
1449

1450
        Note: this method is not intended to categorically opt out a target type from a
1451
        FieldSet, i.e. to always opt out based solely on the target type. While it is possible to
1452
        do, some error messages will incorrectly suggest that that target is compatible with the
1453
        FieldSet. Instead, if you need this feature, please ask us to implement it. See
1454
        https://github.com/pantsbuild/pants/pull/12002 for discussion.
1455
        """
1456
        return False
12✔
1457

1458
    @final
12✔
1459
    @classmethod
12✔
1460
    def is_applicable(cls, tgt: Target) -> bool:
12✔
1461
        return tgt.has_fields(cls.required_fields) and not cls.opt_out(tgt)
12✔
1462

1463
    @final
12✔
1464
    @classmethod
12✔
1465
    def applicable_target_types(
12✔
1466
        cls, target_types: Iterable[type[Target]], union_membership: UnionMembership
1467
    ) -> tuple[type[Target], ...]:
1468
        return tuple(
2✔
1469
            tgt_type
1470
            for tgt_type in target_types
1471
            if tgt_type.class_has_fields(cls.required_fields, union_membership)
1472
        )
1473

1474
    @final
12✔
1475
    @classmethod
12✔
1476
    def create(cls: type[_FS], tgt: Target) -> _FS:
12✔
1477
        return cls(address=tgt.address, **_get_field_set_fields_from_target(cls, tgt))
12✔
1478

1479
    @final
12✔
1480
    @memoized_classproperty
12✔
1481
    def fields(cls) -> FrozenDict[str, type[Field]]:
12✔
1482
        def field_type_from_annotation(annotation: Any) -> type[Field] | None:
12✔
1483
            if isinstance(annotation, type) and issubclass(annotation, Field):
12✔
1484
                return annotation
12✔
1485

1486
            origin = get_origin(annotation)
12✔
1487
            if origin not in (Union, UnionType):
12✔
1488
                return None
12✔
1489

1490
            field_types = [
12✔
1491
                arg
1492
                for arg in get_args(annotation)
1493
                if isinstance(arg, type) and issubclass(arg, Field)
1494
            ]
1495
            if len(field_types) != 1:
12✔
NEW
1496
                return None
×
1497
            return field_types[0]
12✔
1498

1499
        return FrozenDict(
12✔
1500
            (
1501
                (name, field_type)
1502
                for name, annotation in get_type_hints(cls).items()
1503
                if (field_type := field_type_from_annotation(annotation)) is not None
1504
            )
1505
        )
1506

1507
    def debug_hint(self) -> str:
12✔
1508
        return self.address.spec
11✔
1509

1510
    def metadata(self) -> dict[str, Any]:
12✔
1511
        return {"address": self.address.spec}
12✔
1512

1513
    def __repr__(self) -> str:
12✔
1514
        # We use a short repr() because this often shows up in stack traces. We don't need any of
1515
        # the field information because we can ask a user to send us their BUILD file.
1516
        return f"{self.__class__.__name__}(address={self.address})"
×
1517

1518

1519
@dataclass(frozen=True)
12✔
1520
class TargetRootsToFieldSets(Generic[_FS]):
12✔
1521
    mapping: FrozenDict[Target, tuple[_FS, ...]]
12✔
1522

1523
    def __init__(self, mapping: Mapping[Target, Iterable[_FS]]) -> None:
12✔
1524
        object.__setattr__(
6✔
1525
            self,
1526
            "mapping",
1527
            FrozenDict({tgt: tuple(field_sets) for tgt, field_sets in mapping.items()}),
1528
        )
1529

1530
    @memoized_property
12✔
1531
    def field_sets(self) -> tuple[_FS, ...]:
12✔
1532
        return tuple(
3✔
1533
            itertools.chain.from_iterable(
1534
                field_sets_per_target for field_sets_per_target in self.mapping.values()
1535
            )
1536
        )
1537

1538
    @memoized_property
12✔
1539
    def targets(self) -> tuple[Target, ...]:
12✔
1540
        return tuple(self.mapping.keys())
3✔
1541

1542

1543
class NoApplicableTargetsBehavior(Enum):
12✔
1544
    ignore = "ignore"
12✔
1545
    warn = "warn"
12✔
1546
    error = "error"
12✔
1547

1548

1549
def parse_shard_spec(shard_spec: str, origin: str = "") -> tuple[int, int]:
12✔
1550
    def invalid():
2✔
1551
        origin_str = f" from {origin}" if origin else ""
1✔
1552
        return ValueError(
1✔
1553
            f"Invalid shard specification {shard_spec}{origin_str}. Use a string of the form "
1554
            '"k/N" where k and N are integers, and 0 <= k < N .'
1555
        )
1556

1557
    if not shard_spec:
2✔
1558
        return 0, -1
1✔
1559
    shard_str, _, num_shards_str = shard_spec.partition("/")
1✔
1560
    try:
1✔
1561
        shard, num_shards = int(shard_str), int(num_shards_str)
1✔
1562
    except ValueError:
1✔
1563
        raise invalid()
1✔
1564
    if shard < 0 or shard >= num_shards:
1✔
1565
        raise invalid()
1✔
1566
    return shard, num_shards
1✔
1567

1568

1569
def get_shard(key: str, num_shards: int) -> int:
12✔
1570
    # Note: hash() is not guaranteed to be stable across processes, and adler32 is not
1571
    # well-distributed for small strings, so we use crc32. It's faster to compute than
1572
    # a cryptographic hash, which would be overkill.
1573
    return zlib.crc32(key.encode()) % num_shards
1✔
1574

1575

1576
@dataclass(frozen=True)
12✔
1577
class TargetRootsToFieldSetsRequest(Generic[_FS]):
12✔
1578
    field_set_superclass: type[_FS]
12✔
1579
    goal_description: str
12✔
1580
    no_applicable_targets_behavior: NoApplicableTargetsBehavior
12✔
1581
    shard: int
12✔
1582
    num_shards: int
12✔
1583

1584
    def __init__(
12✔
1585
        self,
1586
        field_set_superclass: type[_FS],
1587
        *,
1588
        goal_description: str,
1589
        no_applicable_targets_behavior: NoApplicableTargetsBehavior,
1590
        shard: int = 0,
1591
        num_shards: int = -1,
1592
    ) -> None:
1593
        object.__setattr__(self, "field_set_superclass", field_set_superclass)
6✔
1594
        object.__setattr__(self, "goal_description", goal_description)
6✔
1595
        object.__setattr__(self, "no_applicable_targets_behavior", no_applicable_targets_behavior)
6✔
1596
        object.__setattr__(self, "shard", shard)
6✔
1597
        object.__setattr__(self, "num_shards", num_shards)
6✔
1598

1599
    def is_in_shard(self, key: str) -> bool:
12✔
1600
        return get_shard(key, self.num_shards) == self.shard
×
1601

1602

1603
@dataclass(frozen=True)
12✔
1604
class FieldSetsPerTarget(Generic[_FS]):
12✔
1605
    # One tuple of FieldSet instances per input target.
1606
    collection: tuple[tuple[_FS, ...], ...]
12✔
1607

1608
    def __init__(self, collection: Iterable[Iterable[_FS]]):
12✔
1609
        object.__setattr__(self, "collection", tuple(tuple(iterable) for iterable in collection))
12✔
1610

1611
    @memoized_property
12✔
1612
    def field_sets(self) -> tuple[_FS, ...]:
12✔
1613
        return tuple(itertools.chain.from_iterable(self.collection))
11✔
1614

1615

1616
@dataclass(frozen=True)
12✔
1617
class FieldSetsPerTargetRequest(Generic[_FS]):
12✔
1618
    field_set_superclass: type[_FS]
12✔
1619
    targets: tuple[Target, ...]
12✔
1620

1621
    def __init__(self, field_set_superclass: type[_FS], targets: Iterable[Target]):
12✔
1622
        object.__setattr__(self, "field_set_superclass", field_set_superclass)
12✔
1623
        object.__setattr__(self, "targets", tuple(targets))
12✔
1624

1625

1626
# -----------------------------------------------------------------------------------------------
1627
# Exception messages
1628
# -----------------------------------------------------------------------------------------------
1629

1630

1631
class InvalidTargetException(Exception):
12✔
1632
    """Use when there's an issue with the target, e.g. mutually exclusive fields set.
1633

1634
    Suggested template:
1635

1636
         f"The `{alias!r}` target {address} ..."
1637
    """
1638

1639
    def __init__(self, message: Any, *, description_of_origin: str | None = None) -> None:
12✔
1640
        self.description_of_origin = description_of_origin
8✔
1641
        super().__init__(message)
8✔
1642

1643
    def __str__(self) -> str:
12✔
1644
        if not self.description_of_origin:
8✔
1645
            return super().__str__()
5✔
1646
        return f"{self.description_of_origin}: {super().__str__()}"
6✔
1647

1648
    def __repr__(self) -> str:
12✔
1649
        if not self.description_of_origin:
1✔
1650
            return super().__repr__()
1✔
1651
        return f"{self.description_of_origin}: {super().__repr__()}"
×
1652

1653

1654
class InvalidGeneratedTargetException(InvalidTargetException):
12✔
1655
    pass
12✔
1656

1657

1658
class InvalidFieldException(Exception):
12✔
1659
    """Use when there's an issue with a particular field.
1660

1661
    Suggested template:
1662

1663
         f"The {alias!r} field in target {address} must ..., but ..."
1664
    """
1665

1666
    def __init__(self, message: Any, *, description_of_origin: str | None = None) -> None:
12✔
1667
        self.description_of_origin = description_of_origin
12✔
1668
        super().__init__(message)
12✔
1669

1670
    def __str__(self) -> str:
12✔
1671
        if not self.description_of_origin:
8✔
1672
            return super().__str__()
8✔
1673
        return f"{self.description_of_origin}: {super().__str__()}"
×
1674

1675
    def __repr__(self) -> str:
12✔
1676
        if not self.description_of_origin:
×
1677
            return super().__repr__()
×
1678
        return f"{self.description_of_origin}: {super().__repr__()}"
×
1679

1680

1681
class InvalidFieldTypeException(InvalidFieldException):
12✔
1682
    """This is used to ensure that the field's value conforms with the expected type for the field,
1683
    e.g. `a boolean` or `a string` or `an iterable of strings and integers`."""
1684

1685
    def __init__(
12✔
1686
        self,
1687
        address: Address,
1688
        field_alias: str,
1689
        raw_value: Any | None,
1690
        *,
1691
        expected_type: str,
1692
        description_of_origin: str | None = None,
1693
    ) -> None:
1694
        raw_type = f"with type `{type(raw_value).__name__}`"
12✔
1695
        super().__init__(
12✔
1696
            f"The {repr(field_alias)} field in target {address} must be {expected_type}, but was "
1697
            f"`{repr(raw_value)}` {raw_type}.",
1698
            description_of_origin=description_of_origin,
1699
        )
1700

1701

1702
class InvalidFieldMemberTypeException(InvalidFieldException):
12✔
1703
    # based on InvalidFieldTypeException
1704
    def __init__(
12✔
1705
        self,
1706
        address: Address,
1707
        field_alias: str,
1708
        raw_value: Any | None,
1709
        *,
1710
        expected_type: str,
1711
        at_index: int,
1712
        wrong_element: Any,
1713
        description_of_origin: str | None = None,
1714
    ) -> None:
1715
        super().__init__(
1✔
1716
            softwrap(
1717
                f"""
1718
                The {repr(field_alias)} field in target {address} must be an iterable with
1719
                elements that have type {expected_type}. Encountered the element `{wrong_element}`
1720
                of type {type(wrong_element)} instead of {expected_type} at index {at_index}:
1721
                `{repr(raw_value)}`
1722
                """
1723
            ),
1724
            description_of_origin=description_of_origin,
1725
        )
1726

1727

1728
class RequiredFieldMissingException(InvalidFieldException):
12✔
1729
    def __init__(
12✔
1730
        self, address: Address, field_alias: str, *, description_of_origin: str | None = None
1731
    ) -> None:
1732
        super().__init__(
×
1733
            f"The {repr(field_alias)} field in target {address} must be defined.",
1734
            description_of_origin=description_of_origin,
1735
        )
1736

1737

1738
class InvalidFieldChoiceException(InvalidFieldException):
12✔
1739
    def __init__(
12✔
1740
        self,
1741
        address: Address,
1742
        field_alias: str,
1743
        raw_value: Any | None,
1744
        *,
1745
        valid_choices: Iterable[Any],
1746
        description_of_origin: str | None = None,
1747
    ) -> None:
1748
        super().__init__(
2✔
1749
            f"Values for the {repr(field_alias)} field in target {address} must be one of "
1750
            f"{sorted(valid_choices)}, but {repr(raw_value)} was provided.",
1751
            description_of_origin=description_of_origin,
1752
        )
1753

1754

1755
class UnrecognizedTargetTypeException(InvalidTargetException):
12✔
1756
    def __init__(
12✔
1757
        self,
1758
        target_type: str,
1759
        registered_target_types: RegisteredTargetTypes,
1760
        address: Address | None = None,
1761
        description_of_origin: str | None = None,
1762
    ) -> None:
1763
        for_address = f" for address {address}" if address else ""
1✔
1764
        super().__init__(
1✔
1765
            softwrap(
1766
                f"""
1767
                Target type {target_type!r} is not registered{for_address}.
1768

1769
                All valid target types: {sorted(registered_target_types.aliases)}
1770

1771
                (If {target_type!r} is a custom target type, refer to
1772
                {doc_url("docs/writing-plugins/the-target-api/concepts")} for getting it registered with Pants.)
1773

1774
                """
1775
            ),
1776
            description_of_origin=description_of_origin,
1777
        )
1778

1779

1780
# -----------------------------------------------------------------------------------------------
1781
# Field templates
1782
# -----------------------------------------------------------------------------------------------
1783

1784
T = TypeVar("T")
12✔
1785

1786

1787
class ScalarField(Generic[T], Field):
12✔
1788
    """A field with a scalar value (vs. a compound value like a sequence or dict).
1789

1790
    Subclasses must define the class properties `expected_type` and `expected_type_description`.
1791
    They should also override the type hints for the classmethod `compute_value` so that we use the
1792
    correct type annotation in generated documentation.
1793

1794
        class Example(ScalarField):
1795
            alias = "example"
1796
            expected_type = MyPluginObject
1797
            expected_type_description = "a `my_plugin` object"
1798

1799
            @classmethod
1800
            def compute_value(
1801
                cls, raw_value: Optional[MyPluginObject], address: Address
1802
            ) -> Optional[MyPluginObject]:
1803
                return super().compute_value(raw_value, address=address)
1804
    """
1805

1806
    expected_type: ClassVar[type[T]]
12✔
1807
    expected_type_description: ClassVar[str]
12✔
1808
    value: T | None
12✔
1809
    default: ClassVar[T | None] = None
12✔
1810

1811
    @classmethod
12✔
1812
    def compute_value(cls, raw_value: Any | None, address: Address) -> T | None:
12✔
1813
        value_or_default = super().compute_value(raw_value, address)
12✔
1814
        if value_or_default is not None and not isinstance(value_or_default, cls.expected_type):
12✔
1815
            raise InvalidFieldTypeException(
2✔
1816
                address,
1817
                cls.alias,
1818
                raw_value,
1819
                expected_type=cls.expected_type_description,
1820
            )
1821
        return value_or_default
12✔
1822

1823

1824
class BoolField(Field):
12✔
1825
    """A field whose value is a boolean.
1826

1827
    Subclasses must either set `default: bool` or `required = True` so that the value is always
1828
    defined.
1829
    """
1830

1831
    value: bool
12✔
1832
    default: ClassVar[bool]
12✔
1833

1834
    @classmethod
12✔
1835
    def compute_value(cls, raw_value: bool, address: Address) -> bool:  # type: ignore[override]
12✔
1836
        value_or_default = super().compute_value(raw_value, address)
12✔
1837
        if not isinstance(value_or_default, bool):
12✔
1838
            raise InvalidFieldTypeException(
×
1839
                address, cls.alias, raw_value, expected_type="a boolean"
1840
            )
1841
        return value_or_default
12✔
1842

1843

1844
class TriBoolField(ScalarField[bool]):
12✔
1845
    """A field whose value is a boolean or None, which is meant to represent a tri-state."""
1846

1847
    expected_type = bool
12✔
1848
    expected_type_description = "a boolean or None"
12✔
1849

1850
    @classmethod
12✔
1851
    def compute_value(cls, raw_value: bool | None, address: Address) -> bool | None:
12✔
1852
        return super().compute_value(raw_value, address)
12✔
1853

1854

1855
class ValidNumbers(Enum):
12✔
1856
    """What range of numbers are allowed for IntField and FloatField."""
1857

1858
    positive_only = enum.auto()
12✔
1859
    positive_and_zero = enum.auto()
12✔
1860
    all = enum.auto()
12✔
1861

1862
    def validate(self, num: float | int | None, alias: str, address: Address) -> None:
12✔
1863
        if num is None or self == self.all:
12✔
1864
            return
12✔
1865
        if self == self.positive_and_zero:
9✔
1866
            if num < 0:
6✔
1867
                raise InvalidFieldException(
1✔
1868
                    f"The {repr(alias)} field in target {address} must be greater than or equal to "
1869
                    f"zero, but was set to `{num}`."
1870
                )
1871
            return
6✔
1872
        if num <= 0:
8✔
1873
            raise InvalidFieldException(
1✔
1874
                f"The {repr(alias)} field in target {address} must be greater than zero, but was "
1875
                f"set to `{num}`."
1876
            )
1877

1878

1879
class IntField(ScalarField[int]):
12✔
1880
    expected_type = int
12✔
1881
    expected_type_description = "an integer"
12✔
1882
    valid_numbers: ClassVar[ValidNumbers] = ValidNumbers.all
12✔
1883

1884
    @classmethod
12✔
1885
    def compute_value(cls, raw_value: int | None, address: Address) -> int | None:
12✔
1886
        value_or_default = super().compute_value(raw_value, address)
12✔
1887
        cls.valid_numbers.validate(value_or_default, cls.alias, address)
12✔
1888
        return value_or_default
12✔
1889

1890

1891
class FloatField(ScalarField[float]):
12✔
1892
    expected_type = float
12✔
1893
    expected_type_description = "a float"
12✔
1894
    valid_numbers: ClassVar[ValidNumbers] = ValidNumbers.all
12✔
1895

1896
    @classmethod
12✔
1897
    def compute_value(cls, raw_value: float | None, address: Address) -> float | None:
12✔
1898
        value_or_default = super().compute_value(raw_value, address)
1✔
1899
        cls.valid_numbers.validate(value_or_default, cls.alias, address)
1✔
1900
        return value_or_default
1✔
1901

1902

1903
class StringField(ScalarField[str]):
12✔
1904
    """A field whose value is a string.
1905

1906
    If you expect the string to only be one of several values, set the class property
1907
    `valid_choices`.
1908
    """
1909

1910
    expected_type = str
12✔
1911
    expected_type_description = "a string"
12✔
1912
    valid_choices: ClassVar[type[Enum] | tuple[str, ...] | None] = None
12✔
1913

1914
    @classmethod
12✔
1915
    def compute_value(cls, raw_value: str | None, address: Address) -> str | None:
12✔
1916
        value_or_default = super().compute_value(raw_value, address)
12✔
1917
        if value_or_default is not None and cls.valid_choices is not None:
12✔
1918
            _validate_choices(
12✔
1919
                address, cls.alias, [value_or_default], valid_choices=cls.valid_choices
1920
            )
1921
        return value_or_default
12✔
1922

1923

1924
class SequenceField(Generic[T], Field):
12✔
1925
    """A field whose value is a homogeneous sequence.
1926

1927
    Subclasses must define the class properties `expected_element_type` and
1928
    `expected_type_description`. They should also override the type hints for the classmethod
1929
    `compute_value` so that we use the correct type annotation in generated documentation.
1930

1931
        class Example(SequenceField):
1932
            alias = "example"
1933
            expected_element_type = MyPluginObject
1934
            expected_type_description = "an iterable of `my_plugin` objects"
1935

1936
            @classmethod
1937
            def compute_value(
1938
                cls, raw_value: Optional[Iterable[MyPluginObject]], address: Address
1939
            ) -> Optional[Tuple[MyPluginObject, ...]]:
1940
                return super().compute_value(raw_value, address=address)
1941
    """
1942

1943
    expected_element_type: ClassVar[type]
12✔
1944
    expected_type_description: ClassVar[str]
12✔
1945
    value: tuple[T, ...] | None
12✔
1946
    default: ClassVar[tuple[T, ...] | None] = None
12✔
1947

1948
    @classmethod
12✔
1949
    def compute_value(
12✔
1950
        cls, raw_value: Iterable[Any] | None, address: Address
1951
    ) -> tuple[T, ...] | None:
1952
        value_or_default = super().compute_value(raw_value, address)
12✔
1953
        if value_or_default is None:
12✔
1954
            return None
12✔
1955
        try:
12✔
1956
            ensure_list(value_or_default, expected_type=cls.expected_element_type)
12✔
1957
        except ValueError:
1✔
1958
            raise InvalidFieldTypeException(
1✔
1959
                address,
1960
                cls.alias,
1961
                raw_value,
1962
                expected_type=cls.expected_type_description,
1963
            )
1964
        return tuple(value_or_default)
12✔
1965

1966

1967
class TupleSequenceField(Generic[T], Field):
12✔
1968
    # this cannot be a SequenceField as compute_value's use of ensure_list
1969
    # does not work with expected_element_type=tuple when the value itself
1970
    # is already a tuple.
1971
    expected_element_type: ClassVar[type]
12✔
1972
    expected_element_count: ClassVar[int]  # -1 for unlimited
12✔
1973
    expected_type_description: ClassVar[str]
12✔
1974
    expected_element_type_description: ClassVar[str]
12✔
1975

1976
    value: tuple[tuple[T, ...], ...] | None
12✔
1977
    default: ClassVar[tuple[tuple[T, ...], ...] | None] = None
12✔
1978

1979
    @classmethod
12✔
1980
    def compute_value(
12✔
1981
        cls, raw_value: Iterable[Iterable[T]] | None, address: Address
1982
    ) -> tuple[tuple[T, ...], ...] | None:
1983
        value_or_default = super().compute_value(raw_value, address)
7✔
1984
        if value_or_default is None:
7✔
1985
            return value_or_default
1✔
1986
        if isinstance(value_or_default, str) or not isinstance(
7✔
1987
            value_or_default, collections.abc.Iterable
1988
        ):
1989
            raise InvalidFieldTypeException(
1✔
1990
                address,
1991
                cls.alias,
1992
                raw_value,
1993
                expected_type=cls.expected_type_description,
1994
            )
1995

1996
        def invalid_member_exception(
7✔
1997
            at_index: int, wrong_element: Any
1998
        ) -> InvalidFieldMemberTypeException:
1999
            return InvalidFieldMemberTypeException(
1✔
2000
                address,
2001
                cls.alias,
2002
                raw_value,
2003
                expected_type=cls.expected_element_type_description,
2004
                wrong_element=wrong_element,
2005
                at_index=at_index,
2006
            )
2007

2008
        validated: list[tuple[T, ...]] = []
7✔
2009
        for i, x in enumerate(value_or_default):
7✔
2010
            if isinstance(x, str) or not isinstance(x, collections.abc.Iterable):
7✔
2011
                raise invalid_member_exception(i, x)
1✔
2012
            element = tuple(x)
7✔
2013
            if cls.expected_element_count >= 0 and cls.expected_element_count != len(element):
7✔
2014
                raise invalid_member_exception(i, x)
×
2015
            for s in element:
7✔
2016
                if not isinstance(s, cls.expected_element_type):
7✔
2017
                    raise invalid_member_exception(i, x)
1✔
2018
            validated.append(cast(tuple[T, ...], element))
7✔
2019

2020
        return tuple(validated)
7✔
2021

2022

2023
class StringSequenceField(SequenceField[str]):
12✔
2024
    expected_element_type = str
12✔
2025
    expected_type_description = "an iterable of strings (e.g. a list of strings)"
12✔
2026
    valid_choices: ClassVar[type[Enum] | tuple[str, ...] | None] = None
12✔
2027

2028
    @classmethod
12✔
2029
    def compute_value(
12✔
2030
        cls, raw_value: Iterable[str] | None, address: Address
2031
    ) -> tuple[str, ...] | None:
2032
        value_or_default = super().compute_value(raw_value, address)
12✔
2033
        if value_or_default and cls.valid_choices is not None:
12✔
2034
            _validate_choices(address, cls.alias, value_or_default, valid_choices=cls.valid_choices)
8✔
2035
        return value_or_default
12✔
2036

2037

2038
class DictStringToStringField(Field):
12✔
2039
    value: FrozenDict[str, str] | None
12✔
2040
    default: ClassVar[FrozenDict[str, str] | None] = None
12✔
2041

2042
    @classmethod
12✔
2043
    def compute_value(
12✔
2044
        cls, raw_value: dict[str, str] | None, address: Address
2045
    ) -> FrozenDict[str, str] | None:
2046
        value_or_default = super().compute_value(raw_value, address)
12✔
2047
        if value_or_default is None:
12✔
2048
            return None
12✔
2049
        invalid_type_exception = InvalidFieldTypeException(
12✔
2050
            address, cls.alias, raw_value, expected_type="a dictionary of string -> string"
2051
        )
2052
        if not isinstance(value_or_default, collections.abc.Mapping):
12✔
2053
            raise invalid_type_exception
1✔
2054
        if not all(isinstance(k, str) and isinstance(v, str) for k, v in value_or_default.items()):
12✔
2055
            raise invalid_type_exception
1✔
2056
        return FrozenDict(value_or_default)
12✔
2057

2058

2059
class ListOfDictStringToStringField(Field):
12✔
2060
    value: tuple[FrozenDict[str, str]] | None
12✔
2061
    default: ClassVar[list[FrozenDict[str, str]] | None] = None
12✔
2062

2063
    @classmethod
12✔
2064
    def compute_value(
12✔
2065
        cls, raw_value: list[dict[str, str]] | None, address: Address
2066
    ) -> tuple[FrozenDict[str, str], ...] | None:
2067
        value_or_default = super().compute_value(raw_value, address)
9✔
2068
        if value_or_default is None:
9✔
2069
            return None
9✔
2070
        invalid_type_exception = InvalidFieldTypeException(
1✔
2071
            address,
2072
            cls.alias,
2073
            raw_value,
2074
            expected_type="a list of dictionaries (or a single dictionary) of string -> string",
2075
        )
2076

2077
        # Also support passing in a single dictionary by wrapping it
2078
        if not isinstance(value_or_default, (list, tuple)):
1✔
2079
            value_or_default = [value_or_default]
1✔
2080

2081
        result_lst: list[FrozenDict[str, str]] = []
1✔
2082
        for item in value_or_default:
1✔
2083
            if not isinstance(item, collections.abc.Mapping):
1✔
2084
                raise invalid_type_exception
1✔
2085
            if not all(isinstance(k, str) and isinstance(v, str) for k, v in item.items()):
1✔
2086
                raise invalid_type_exception
1✔
2087
            result_lst.append(FrozenDict(item))
1✔
2088

2089
        return tuple(result_lst)
1✔
2090

2091

2092
class NestedDictStringToStringField(Field):
12✔
2093
    value: FrozenDict[str, FrozenDict[str, str]] | None
12✔
2094
    default: ClassVar[FrozenDict[str, FrozenDict[str, str]] | None] = None
12✔
2095

2096
    @classmethod
12✔
2097
    def compute_value(
12✔
2098
        cls, raw_value: dict[str, dict[str, str]] | None, address: Address
2099
    ) -> FrozenDict[str, FrozenDict[str, str]] | None:
2100
        value_or_default = super().compute_value(raw_value, address)
6✔
2101
        if value_or_default is None:
6✔
2102
            return None
5✔
2103
        invalid_type_exception = InvalidFieldTypeException(
5✔
2104
            address,
2105
            cls.alias,
2106
            raw_value,
2107
            expected_type="dict[str, dict[str, str]]",
2108
        )
2109
        if not isinstance(value_or_default, collections.abc.Mapping):
5✔
2110
            raise invalid_type_exception
1✔
2111
        for key, nested_value in value_or_default.items():
5✔
2112
            if not isinstance(key, str) or not isinstance(nested_value, collections.abc.Mapping):
5✔
2113
                raise invalid_type_exception
1✔
2114
            if not all(isinstance(k, str) and isinstance(v, str) for k, v in nested_value.items()):
5✔
2115
                raise invalid_type_exception
×
2116
        return FrozenDict(
5✔
2117
            {key: FrozenDict(nested_value) for key, nested_value in value_or_default.items()}
2118
        )
2119

2120

2121
class DictStringToStringSequenceField(Field):
12✔
2122
    value: FrozenDict[str, tuple[str, ...]] | None
12✔
2123
    default: ClassVar[FrozenDict[str, tuple[str, ...]] | None] = None
12✔
2124

2125
    @classmethod
12✔
2126
    def compute_value(
12✔
2127
        cls, raw_value: dict[str, Iterable[str]] | None, address: Address
2128
    ) -> FrozenDict[str, tuple[str, ...]] | None:
2129
        value_or_default = super().compute_value(raw_value, address)
11✔
2130
        if value_or_default is None:
11✔
2131
            return None
11✔
2132
        invalid_type_exception = InvalidFieldTypeException(
9✔
2133
            address,
2134
            cls.alias,
2135
            raw_value,
2136
            expected_type="a dictionary of string -> an iterable of strings",
2137
        )
2138
        if not isinstance(value_or_default, collections.abc.Mapping):
9✔
2139
            raise invalid_type_exception
1✔
2140
        result = {}
9✔
2141
        for k, v in value_or_default.items():
9✔
2142
            if not isinstance(k, str):
9✔
2143
                raise invalid_type_exception
1✔
2144
            try:
9✔
2145
                result[k] = tuple(ensure_str_list(v))
9✔
2146
            except ValueError:
1✔
2147
                raise invalid_type_exception
1✔
2148
        return FrozenDict(result)
9✔
2149

2150

2151
def _validate_choices(
12✔
2152
    address: Address,
2153
    field_alias: str,
2154
    values: Iterable[Any],
2155
    *,
2156
    valid_choices: type[Enum] | tuple[Any, ...],
2157
) -> None:
2158
    _valid_choices = set(
12✔
2159
        valid_choices
2160
        if isinstance(valid_choices, tuple)
2161
        else (choice.value for choice in valid_choices)
2162
    )
2163
    for choice in values:
12✔
2164
        if choice not in _valid_choices:
12✔
2165
            raise InvalidFieldChoiceException(
2✔
2166
                address, field_alias, choice, valid_choices=_valid_choices
2167
            )
2168

2169

2170
# -----------------------------------------------------------------------------------------------
2171
# Sources and codegen
2172
# -----------------------------------------------------------------------------------------------
2173

2174

2175
class SourcesField(AsyncFieldMixin, Field):
12✔
2176
    """A field for the sources that a target owns.
2177

2178
    When defining a new sources field, you should subclass `MultipleSourcesField` or
2179
    `SingleSourceField`, which set up the field's `alias` and data type / parsing. However, you
2180
    should use `tgt.get(SourcesField)` when you need to operate on all sources types, such as
2181
    with `HydrateSourcesRequest`, so that both subclasses work.
2182

2183
    Subclasses may set the following class properties:
2184

2185
    - `expected_file_extensions` -- A tuple of strings containing the expected file extensions for
2186
        source files. The default is no expected file extensions.
2187
    - `expected_num_files` -- An integer or range stating the expected total number of source
2188
        files. The default is no limit on the number of source files.
2189
    - `uses_source_roots` -- Whether the concept of "source root" pertains to the source files
2190
        referenced by this field.
2191
    - `default` -- A default value for this field.
2192
    - `default_glob_match_error_behavior` -- Advanced option, should very rarely be used. Override
2193
        glob match error behavior when using the default value. If setting this to
2194
        `GlobMatchErrorBehavior.ignore`, make sure you have other validation in place in case the
2195
        default glob doesn't match any files, if required, to alert the user appropriately.
2196
    """
2197

2198
    expected_file_extensions: ClassVar[tuple[str, ...] | None] = None
12✔
2199
    expected_num_files: ClassVar[int | range | None] = None
12✔
2200
    uses_source_roots: ClassVar[bool] = True
12✔
2201

2202
    default: ClassVar[ImmutableValue] = None
12✔
2203
    default_glob_match_error_behavior: ClassVar[GlobMatchErrorBehavior | None] = None
12✔
2204

2205
    @property
12✔
2206
    def globs(self) -> tuple[str, ...]:
12✔
2207
        """The raw globs, relative to the BUILD file."""
2208

2209
        # NB: We give a default implementation because it's common to use
2210
        # `tgt.get(SourcesField)`, and that must not error. But, subclasses need to
2211
        # implement this for the field to be useful (they should subclass `MultipleSourcesField`
2212
        # and `SingleSourceField`).
2213
        return ()
11✔
2214

2215
    def validate_resolved_files(self, files: Sequence[str]) -> None:
12✔
2216
        """Perform any additional validation on the resulting source files, e.g. ensuring that
2217
        certain banned files are not used.
2218

2219
        To enforce that the resulting files end in certain extensions, such as `.py` or `.java`, set
2220
        the class property `expected_file_extensions`.
2221

2222
        To enforce that there are only a certain number of resulting files, such as binary targets
2223
        checking for only 0-1 sources, set the class property `expected_num_files`.
2224
        """
2225
        if self.expected_file_extensions is not None:
12✔
2226
            bad_files = [
12✔
2227
                fp for fp in files if PurePath(fp).suffix not in self.expected_file_extensions
2228
            ]
2229
            if bad_files:
12✔
2230
                expected = (
1✔
2231
                    f"one of {sorted(self.expected_file_extensions)}"
2232
                    if len(self.expected_file_extensions) > 1
2233
                    else repr(self.expected_file_extensions[0])
2234
                )
2235
                raise InvalidFieldException(
1✔
2236
                    f"The {repr(self.alias)} field in target {self.address} can only contain "
2237
                    f"files that end in {expected}, but it had these files: {sorted(bad_files)}."
2238
                    "\n\nMaybe create a `resource`/`resources` or `file`/`files` target and "
2239
                    "include it in the `dependencies` field?"
2240
                )
2241
        if self.expected_num_files is not None:
12✔
2242
            num_files = len(files)
12✔
2243
            is_bad_num_files = (
12✔
2244
                num_files not in self.expected_num_files
2245
                if isinstance(self.expected_num_files, range)
2246
                else num_files != self.expected_num_files
2247
            )
2248
            if is_bad_num_files:
12✔
2249
                if isinstance(self.expected_num_files, range):
1✔
2250
                    if len(self.expected_num_files) == 2:
1✔
2251
                        expected_str = (
1✔
2252
                            " or ".join(str(n) for n in self.expected_num_files) + " files"
2253
                        )
2254
                    else:
2255
                        expected_str = f"a number of files in the range `{self.expected_num_files}`"
×
2256
                else:
2257
                    expected_str = pluralize(self.expected_num_files, "file")
1✔
2258
                raise InvalidFieldException(
1✔
2259
                    f"The {repr(self.alias)} field in target {self.address} must have "
2260
                    f"{expected_str}, but it had {pluralize(num_files, 'file')}."
2261
                )
2262

2263
    @staticmethod
12✔
2264
    def prefix_glob_with_dirpath(dirpath: str, glob: str) -> str:
12✔
2265
        if glob.startswith("!"):
12✔
2266
            return f"!{os.path.join(dirpath, glob[1:])}"
12✔
2267
        return os.path.join(dirpath, glob)
12✔
2268

2269
    @final
12✔
2270
    def _prefix_glob_with_address(self, glob: str) -> str:
12✔
2271
        return self.prefix_glob_with_dirpath(self.address.spec_path, glob)
12✔
2272

2273
    @final
12✔
2274
    @classmethod
12✔
2275
    def can_generate(
12✔
2276
        cls, output_type: type[SourcesField], union_membership: UnionMembership
2277
    ) -> bool:
2278
        """Can this field be used to generate the output_type?
2279

2280
        Generally, this method does not need to be used. Most call sites can simply use the below,
2281
        and the engine will generate the sources if possible or will return an instance of
2282
        HydratedSources with an empty snapshot if not possible:
2283

2284
            await hydrate_sources(
2285
                HydrateSourcesRequest(
2286
                    sources_field,
2287
                    for_sources_types=[FortranSources],
2288
                    enable_codegen=True,
2289
                ),
2290
                **implicitly(),
2291
            )
2292

2293
        This method is useful when you need to filter targets before hydrating them, such as how
2294
        you may filter targets via `tgt.has_field(MyField)`.
2295
        """
2296
        generate_request_types = union_membership.get(GenerateSourcesRequest)
10✔
2297
        return any(
10✔
2298
            issubclass(cls, generate_request_type.input)
2299
            and issubclass(generate_request_type.output, output_type)
2300
            for generate_request_type in generate_request_types
2301
        )
2302

2303
    @final
12✔
2304
    def path_globs(self, unmatched_build_file_globs: UnmatchedBuildFileGlobs) -> PathGlobs:
12✔
2305
        if not self.globs:
12✔
2306
            return PathGlobs([])
12✔
2307

2308
        # SingleSourceField has str as default type.
2309
        default_globs = (
12✔
2310
            [self.default] if self.default and isinstance(self.default, str) else self.default
2311
        )
2312

2313
        using_default_globs = default_globs and (set(self.globs) == set(default_globs)) or False
12✔
2314

2315
        # Use fields default error behavior if defined, if we use default globs else the provided
2316
        # error behavior.
2317
        error_behavior = (
12✔
2318
            unmatched_build_file_globs.error_behavior
2319
            if not using_default_globs or self.default_glob_match_error_behavior is None
2320
            else self.default_glob_match_error_behavior
2321
        )
2322

2323
        return PathGlobs(
12✔
2324
            (self._prefix_glob_with_address(glob) for glob in self.globs),
2325
            conjunction=GlobExpansionConjunction.any_match,
2326
            glob_match_error_behavior=error_behavior,
2327
            description_of_origin=(
2328
                f"{self.address}'s `{self.alias}` field"
2329
                if error_behavior != GlobMatchErrorBehavior.ignore
2330
                else None
2331
            ),
2332
        )
2333

2334
    @memoized_property
12✔
2335
    def filespec(self) -> Filespec:
12✔
2336
        """The original globs, returned in the Filespec dict format.
2337

2338
        The globs will be relativized to the build root.
2339
        """
2340
        includes = []
12✔
2341
        excludes = []
12✔
2342
        for glob in self.globs:
12✔
2343
            if glob.startswith("!"):
12✔
2344
                excludes.append(os.path.join(self.address.spec_path, glob[1:]))
11✔
2345
            else:
2346
                includes.append(os.path.join(self.address.spec_path, glob))
12✔
2347
        result: Filespec = {"includes": includes}
12✔
2348
        if excludes:
12✔
2349
            result["excludes"] = excludes
11✔
2350
        return result
12✔
2351

2352
    @memoized_property
12✔
2353
    def filespec_matcher(self) -> FilespecMatcher:
12✔
2354
        # Note: memoized because parsing the globs is expensive:
2355
        # https://github.com/pantsbuild/pants/issues/16122
2356
        return FilespecMatcher(self.filespec["includes"], self.filespec.get("excludes", []))
11✔
2357

2358

2359
class MultipleSourcesField(SourcesField, StringSequenceField):
12✔
2360
    """The `sources: list[str]` field.
2361

2362
    See the docstring for `SourcesField` for some class properties you can set, such as
2363
    `expected_file_extensions`.
2364

2365
    When you need to get the sources for all targets, use `tgt.get(SourcesField)` rather than
2366
    `tgt.get(MultipleSourcesField)`.
2367
    """
2368

2369
    alias = "sources"
12✔
2370

2371
    ban_subdirectories: ClassVar[bool] = False
12✔
2372

2373
    @property
12✔
2374
    def globs(self) -> tuple[str, ...]:
12✔
2375
        return self.value or ()
12✔
2376

2377
    @classmethod
12✔
2378
    def compute_value(
12✔
2379
        cls, raw_value: Iterable[str] | None, address: Address
2380
    ) -> tuple[str, ...] | None:
2381
        value = super().compute_value(raw_value, address)
12✔
2382
        invalid_globs = [glob for glob in (value or ()) if glob.startswith("../") or "/../" in glob]
12✔
2383
        if invalid_globs:
12✔
2384
            raise InvalidFieldException(
1✔
2385
                softwrap(
2386
                    f"""
2387
                    The {repr(cls.alias)} field in target {address} must not have globs with the
2388
                    pattern `../` because targets can only have sources in the current directory
2389
                    or subdirectories. It was set to: {sorted(value or ())}
2390
                    """
2391
                )
2392
            )
2393
        if cls.ban_subdirectories:
12✔
2394
            invalid_globs = [glob for glob in (value or ()) if "**" in glob or os.path.sep in glob]
12✔
2395
            if invalid_globs:
12✔
2396
                raise InvalidFieldException(
2✔
2397
                    softwrap(
2398
                        f"""
2399
                        The {repr(cls.alias)} field in target {address} must only have globs for
2400
                        the target's directory, i.e. it cannot include values with `**` or
2401
                        `{os.path.sep}`. It was set to: {sorted(value or ())}
2402
                        """
2403
                    )
2404
                )
2405
        return value
12✔
2406

2407

2408
class OptionalSingleSourceField(SourcesField, StringField):
12✔
2409
    """The `source: str` field.
2410

2411
    See the docstring for `SourcesField` for some class properties you can set, such as
2412
    `expected_file_extensions`.
2413

2414
    When you need to get the sources for all targets, use `tgt.get(SourcesField)` rather than
2415
    `tgt.get(OptionalSingleSourceField)`.
2416

2417
    Use `SingleSourceField` if the source must exist.
2418
    """
2419

2420
    alias = "source"
12✔
2421
    help = help_text(
12✔
2422
        """
2423
        A single file that belongs to this target.
2424

2425
        Path is relative to the BUILD file's directory, e.g. `source='example.ext'`.
2426
        """
2427
    )
2428
    required = False
12✔
2429
    default: ClassVar[str | None] = None
12✔
2430
    expected_num_files: ClassVar[int | range] = range(0, 2)
12✔
2431

2432
    @classmethod
12✔
2433
    def compute_value(cls, raw_value: str | None, address: Address) -> str | None:
12✔
2434
        value_or_default = super().compute_value(raw_value, address)
12✔
2435
        if value_or_default is None:
12✔
2436
            return None
12✔
2437
        if value_or_default.startswith("../") or "/../" in value_or_default:
12✔
2438
            raise InvalidFieldException(
1✔
2439
                softwrap(
2440
                    f"""\
2441
                    The {repr(cls.alias)} field in target {address} should not include `../`
2442
                    patterns because targets can only have sources in the current directory or
2443
                    subdirectories. It was set to {value_or_default}. Instead, use a normalized
2444
                    literal file path (relative to the BUILD file).
2445
                    """
2446
                )
2447
            )
2448
        if "*" in value_or_default:
12✔
2449
            raise InvalidFieldException(
1✔
2450
                softwrap(
2451
                    f"""\
2452
                    The {repr(cls.alias)} field in target {address} should not include `*` globs,
2453
                    but was set to {value_or_default}. Instead, use a literal file path (relative
2454
                    to the BUILD file).
2455
                    """
2456
                )
2457
            )
2458
        if value_or_default.startswith("!"):
12✔
2459
            raise InvalidFieldException(
1✔
2460
                softwrap(
2461
                    f"""\
2462
                    The {repr(cls.alias)} field in target {address} should not start with `!`,
2463
                    which is usually used in the `sources` field to exclude certain files. Instead,
2464
                    use a literal file path (relative to the BUILD file).
2465
                    """
2466
                )
2467
            )
2468
        return value_or_default
12✔
2469

2470
    @property
12✔
2471
    def file_path(self) -> str | None:
12✔
2472
        """The path to the file, relative to the build root.
2473

2474
        This works without hydration because we validate that `*` globs and `!` ignores are not
2475
        used. However, consider still hydrating so that you verify the source file actually exists.
2476

2477
        The return type is optional because it's possible to have 0-1 files.
2478
        """
2479
        if self.value is None:
12✔
2480
            return None
5✔
2481
        return os.path.join(self.address.spec_path, self.value)
12✔
2482

2483
    @property
12✔
2484
    def globs(self) -> tuple[str, ...]:
12✔
2485
        if self.value is None:
12✔
2486
            return ()
6✔
2487
        return (self.value,)
12✔
2488

2489

2490
class SingleSourceField(OptionalSingleSourceField):
12✔
2491
    """The `source: str` field.
2492

2493
    Unlike `OptionalSingleSourceField`, the `.value` must be defined, whether by setting the
2494
    `default` or making the field `required`.
2495

2496
    See the docstring for `SourcesField` for some class properties you can set, such as
2497
    `expected_file_extensions`.
2498

2499
    When you need to get the sources for all targets, use `tgt.get(SourcesField)` rather than
2500
    `tgt.get(SingleSourceField)`.
2501
    """
2502

2503
    required = True
12✔
2504
    expected_num_files = 1
12✔
2505
    value: str
12✔
2506

2507
    @property
12✔
2508
    def file_path(self) -> str:
12✔
2509
        result = super().file_path
12✔
2510
        assert result is not None
12✔
2511
        return result
12✔
2512

2513

2514
@dataclass(frozen=True)
12✔
2515
class HydrateSourcesRequest(EngineAwareParameter):
12✔
2516
    field: SourcesField
12✔
2517
    for_sources_types: tuple[type[SourcesField], ...]
12✔
2518
    enable_codegen: bool
12✔
2519

2520
    def __init__(
12✔
2521
        self,
2522
        field: SourcesField,
2523
        *,
2524
        for_sources_types: Iterable[type[SourcesField]] = (SourcesField,),
2525
        enable_codegen: bool = False,
2526
    ) -> None:
2527
        """Convert raw sources globs into an instance of HydratedSources.
2528

2529
        If you only want to handle certain SourcesFields, such as only PythonSources, set
2530
        `for_sources_types`. Any invalid sources will return a `HydratedSources` instance with an
2531
        empty snapshot and `sources_type = None`.
2532

2533
        If `enable_codegen` is set to `True`, any codegen sources will try to be converted to one
2534
        of the `for_sources_types`.
2535
        """
2536
        object.__setattr__(self, "field", field)
12✔
2537
        object.__setattr__(self, "for_sources_types", tuple(for_sources_types))
12✔
2538
        object.__setattr__(self, "enable_codegen", enable_codegen)
12✔
2539

2540
        self.__post_init__()
12✔
2541

2542
    def __post_init__(self) -> None:
12✔
2543
        if self.enable_codegen and self.for_sources_types == (SourcesField,):
12✔
2544
            raise ValueError(
×
2545
                "When setting `enable_codegen=True` on `HydrateSourcesRequest`, you must also "
2546
                "explicitly set `for_source_types`. Why? `for_source_types` is used to "
2547
                "determine which language(s) to try to generate. For example, "
2548
                "`for_source_types=(PythonSources,)` will hydrate `PythonSources` like normal, "
2549
                "and, if it encounters codegen sources that can be converted into Python, it will "
2550
                "generate Python files."
2551
            )
2552

2553
    def debug_hint(self) -> str:
12✔
2554
        return self.field.address.spec
11✔
2555

2556

2557
@dataclass(frozen=True)
12✔
2558
class HydratedSources:
12✔
2559
    """The result of hydrating a SourcesField.
2560

2561
    The `sources_type` will indicate which of the `HydrateSourcesRequest.for_sources_type` the
2562
    result corresponds to, e.g. if the result comes from `FilesSources` vs. `PythonSources`. If this
2563
    value is None, then the input `SourcesField` was not one of the expected types; or, when codegen
2564
    was enabled in the request, there was no valid code generator to generate the requested language
2565
    from the original input. This property allows for switching on the result, e.g. handling
2566
    hydrated files() sources differently than hydrated Python sources.
2567
    """
2568

2569
    snapshot: Snapshot
12✔
2570
    filespec: Filespec
12✔
2571
    sources_type: type[SourcesField] | None
12✔
2572

2573

2574
@union(in_scope_types=[EnvironmentName])
12✔
2575
@dataclass(frozen=True)
12✔
2576
class GenerateSourcesRequest:
12✔
2577
    """A request to go from protocol sources -> a particular language.
2578

2579
    This should be subclassed for each distinct codegen implementation. The subclasses must define
2580
    the class properties `input` and `output`. The subclass must also be registered via
2581
    `UnionRule(GenerateSourcesRequest, GenerateFortranFromAvroRequest)`, for example.
2582

2583
    The rule to actually implement the codegen should take the subclass as input, and it must
2584
    return `GeneratedSources`.
2585

2586
    The `exportable` attribute disables the use of this codegen by the `export-codegen` goal when
2587
    set to False.
2588

2589
    For example:
2590

2591
        class GenerateFortranFromAvroRequest:
2592
            input = AvroSources
2593
            output = FortranSources
2594

2595
        @rule
2596
        async def generate_fortran_from_avro(request: GenerateFortranFromAvroRequest) -> GeneratedSources:
2597
            ...
2598

2599
        def rules():
2600
            return [
2601
                generate_fortran_from_avro,
2602
                UnionRule(GenerateSourcesRequest, GenerateFortranFromAvroRequest),
2603
            ]
2604
    """
2605

2606
    protocol_sources: Snapshot
12✔
2607
    protocol_target: Target
12✔
2608

2609
    input: ClassVar[type[SourcesField]]
12✔
2610
    output: ClassVar[type[SourcesField]]
12✔
2611

2612
    exportable: ClassVar[bool] = True
12✔
2613

2614

2615
@dataclass(frozen=True)
12✔
2616
class GeneratedSources:
12✔
2617
    snapshot: Snapshot
12✔
2618

2619

2620
@rule(polymorphic=True)
12✔
2621
async def generate_sources(
12✔
2622
    req: GenerateSourcesRequest, env_name: EnvironmentName
2623
) -> GeneratedSources:
2624
    raise NotImplementedError()
×
2625

2626

2627
class SourcesPaths(Paths):
12✔
2628
    """The resolved file names of the `source`/`sources` field.
2629

2630
    This does not consider codegen, and only captures the files from the field.
2631
    """
2632

2633

2634
@dataclass(frozen=True)
12✔
2635
class SourcesPathsRequest(EngineAwareParameter):
12✔
2636
    """A request to resolve the file names of the `source`/`sources` field.
2637

2638
    Use via `await resolve_source_paths(SourcesPathRequest(tgt.get(SourcesField))`.
2639

2640
    This is faster than `await hydrate_sources(HydrateSourcesRequest)` because it does not snapshot
2641
    the files and it only resolves the file names.
2642

2643
    This does not consider codegen, and only captures the files from the field. Use
2644
    `HydrateSourcesRequest` to use codegen.
2645
    """
2646

2647
    field: SourcesField
12✔
2648

2649
    def debug_hint(self) -> str:
12✔
2650
        return self.field.address.spec
2✔
2651

2652

2653
def targets_with_sources_types(
12✔
2654
    sources_types: Iterable[type[SourcesField]],
2655
    targets: Iterable[Target],
2656
    union_membership: UnionMembership,
2657
) -> tuple[Target, ...]:
2658
    """Return all targets either with the specified sources subclass(es) or which can generate those
2659
    sources."""
2660
    return tuple(
8✔
2661
        tgt
2662
        for tgt in targets
2663
        if any(
2664
            tgt.has_field(sources_type)
2665
            or tgt.get(SourcesField).can_generate(sources_type, union_membership)
2666
            for sources_type in sources_types
2667
        )
2668
    )
2669

2670

2671
# -----------------------------------------------------------------------------------------------
2672
# `Dependencies` field
2673
# -----------------------------------------------------------------------------------------------
2674

2675

2676
class Dependencies(StringSequenceField, AsyncFieldMixin):
12✔
2677
    """The dependencies field.
2678

2679
    To resolve all dependencies—including the results of dependency inference—use either
2680
    `await resolve_dependencies(DependenciesRequest(tgt[Dependencies])` or
2681
    `await resolve_targets(**implicitly(DependenciesRequest(tgt[Dependencies]))`.
2682
    """
2683

2684
    alias = "dependencies"
12✔
2685
    help = help_text(
12✔
2686
        f"""
2687
        Addresses to other targets that this target depends on, e.g.
2688
        `['helloworld/subdir:lib', 'helloworld/main.py:lib', '3rdparty:reqs#django']`.
2689

2690
        This augments any dependencies inferred by Pants, such as by analyzing your imports. Use
2691
        `{bin_name()} dependencies` or `{bin_name()} peek` on this target to get the final
2692
        result.
2693

2694
        See {doc_url("docs/using-pants/key-concepts/targets-and-build-files")} for more about how addresses are formed, including for generated
2695
        targets. You can also run `{bin_name()} list ::` to find all addresses in your project, or
2696
        `{bin_name()} list dir` to find all addresses defined in that directory.
2697

2698
        If the target is in the same BUILD file, you can leave off the BUILD file path, e.g.
2699
        `:tgt` instead of `helloworld/subdir:tgt`. For generated first-party addresses, use
2700
        `./` for the file path, e.g. `./main.py:tgt`; for all other generated targets,
2701
        use `:tgt#generated_name`.
2702

2703
        You may exclude dependencies by prefixing with `!`, e.g.
2704
        `['!helloworld/subdir:lib', '!./sibling.txt']`. Ignores are intended for false positives
2705
        with dependency inference; otherwise, simply leave off the dependency from the BUILD file.
2706
        """
2707
    )
2708
    supports_transitive_excludes = False
12✔
2709

2710
    @memoized_property
12✔
2711
    def unevaluated_transitive_excludes(self) -> UnparsedAddressInputs:
12✔
2712
        val = (
12✔
2713
            (v[2:] for v in self.value if v.startswith("!!"))
2714
            if self.supports_transitive_excludes and self.value
2715
            else ()
2716
        )
2717
        return UnparsedAddressInputs(
12✔
2718
            val,
2719
            owning_address=self.address,
2720
            description_of_origin=f"the `{self.alias}` field from the target {self.address}",
2721
        )
2722

2723

2724
@dataclass(frozen=True)
12✔
2725
class DependenciesRequest(EngineAwareParameter):
12✔
2726
    field: Dependencies
12✔
2727
    should_traverse_deps_predicate: ShouldTraverseDepsPredicate = TraverseIfDependenciesField()
12✔
2728

2729
    def debug_hint(self) -> str:
12✔
2730
        return self.field.address.spec
12✔
2731

2732

2733
# NB: ExplicitlyProvidedDependenciesRequest does not have a predicate unlike DependenciesRequest.
2734
@dataclass(frozen=True)
12✔
2735
class ExplicitlyProvidedDependenciesRequest(EngineAwareParameter):
12✔
2736
    field: Dependencies
12✔
2737

2738
    def debug_hint(self) -> str:
12✔
2739
        return self.field.address.spec
×
2740

2741

2742
@dataclass(frozen=True)
12✔
2743
class ExplicitlyProvidedDependencies:
12✔
2744
    """The literal addresses from a BUILD file `dependencies` field.
2745

2746
    Almost always, you should use `await resolve_dependencies(DependenciesRequest, **implicitly())`
2747
    instead, which will consider dependency inference and apply ignores. However, this type can be
2748
    useful particularly within inference rules to see if a user already explicitly
2749
    provided a dependency.
2750

2751
    Resolve using
2752
    `await determine_explicitly_provided_dependencies(**implicitly(DependenciesRequest))`.
2753

2754
    Note that the `includes` are not filtered based on the `ignores`: this type preserves exactly
2755
    what was in the BUILD file.
2756
    """
2757

2758
    address: Address
12✔
2759
    includes: FrozenOrderedSet[Address]
12✔
2760
    ignores: FrozenOrderedSet[Address]
12✔
2761

2762
    @memoized_method
12✔
2763
    def any_are_covered_by_includes(self, addresses: Iterable[Address]) -> bool:
12✔
2764
        """Return True if every address is in the explicitly provided includes.
2765

2766
        Note that if the input addresses are generated targets, they will still be marked as covered
2767
        if their original target generator is in the explicitly provided includes.
2768
        """
2769
        return any(
10✔
2770
            addr in self.includes or addr.maybe_convert_to_target_generator() in self.includes
2771
            for addr in addresses
2772
        )
2773

2774
    @memoized_method
12✔
2775
    def remaining_after_disambiguation(
12✔
2776
        self, addresses: Iterable[Address], owners_must_be_ancestors: bool
2777
    ) -> frozenset[Address]:
2778
        """All addresses that remain after ineligible candidates are discarded.
2779

2780
        Candidates are removed if they appear as ignores (`!` and `!!)` in the `dependencies`
2781
        field. Note that if the input addresses are generated targets, they will still be marked as
2782
        covered if their original target generator is in the explicitly provided ignores.
2783

2784
        Candidates are also removed if `owners_must_be_ancestors` is True and the targets are not
2785
        ancestors, e.g. `root2:tgt` is not a valid candidate for something defined in `root1`.
2786
        """
2787
        original_addr_path = PurePath(self.address.spec_path)
10✔
2788

2789
        def is_valid(addr: Address) -> bool:
10✔
2790
            is_ignored = (
10✔
2791
                addr in self.ignores or addr.maybe_convert_to_target_generator() in self.ignores
2792
            )
2793
            if owners_must_be_ancestors is False:
10✔
2794
                return not is_ignored
10✔
2795
            # NB: `PurePath.is_relative_to()` was not added until Python 3.9. This emulates it.
2796
            try:
3✔
2797
                original_addr_path.relative_to(addr.spec_path)
3✔
2798
                return not is_ignored
3✔
2799
            except ValueError:
3✔
2800
                return False
3✔
2801

2802
        return frozenset(filter(is_valid, addresses))
10✔
2803

2804
    def maybe_warn_of_ambiguous_dependency_inference(
12✔
2805
        self,
2806
        ambiguous_addresses: Iterable[Address],
2807
        original_address: Address,
2808
        *,
2809
        context: str,
2810
        import_reference: str,
2811
        owners_must_be_ancestors: bool = False,
2812
    ) -> None:
2813
        """If the module is ambiguous and the user did not disambiguate, warn that dependency
2814
        inference will not be used.
2815

2816
        Disambiguation usually happens by using ignores in the `dependencies` field with `!` and
2817
        `!!`. If `owners_must_be_ancestors` is True, any addresses which are not ancestors of the
2818
        target in question will also be ignored.
2819
        """
2820
        if not ambiguous_addresses or self.any_are_covered_by_includes(ambiguous_addresses):
12✔
2821
            return
12✔
2822
        remaining = self.remaining_after_disambiguation(
10✔
2823
            ambiguous_addresses, owners_must_be_ancestors=owners_must_be_ancestors
2824
        )
2825
        if len(remaining) <= 1:
10✔
2826
            return
10✔
2827
        logger.warning(
8✔
2828
            f"{context}, but Pants cannot safely infer a dependency because more than one target "
2829
            f"owns this {import_reference}, so it is ambiguous which to use: "
2830
            f"{sorted(addr.spec for addr in remaining)}."
2831
            f"\n\nPlease explicitly include the dependency you want in the `dependencies` "
2832
            f"field of {original_address}, or ignore the ones you do not want by prefixing "
2833
            f"with `!` or `!!` so that one or no targets are left."
2834
            f"\n\nAlternatively, you can remove the ambiguity by deleting/changing some of the "
2835
            f"targets so that only 1 target owns this {import_reference}. Refer to "
2836
            f"{doc_url('docs/using-pants/troubleshooting-common-issues#import-errors-and-missing-dependencies')}."
2837
        )
2838

2839
    def disambiguated(
12✔
2840
        self, ambiguous_addresses: Iterable[Address], owners_must_be_ancestors: bool = False
2841
    ) -> Address | None:
2842
        """If exactly one of the input addresses remains after disambiguation, return it.
2843

2844
        Disambiguation usually happens by using ignores in the `dependencies` field with `!` and
2845
        `!!`. If `owners_must_be_ancestors` is True, any addresses which are not ancestors of the
2846
        target in question will also be ignored.
2847
        """
2848
        if not ambiguous_addresses or self.any_are_covered_by_includes(ambiguous_addresses):
12✔
2849
            return None
12✔
2850
        remaining_after_ignores = self.remaining_after_disambiguation(
10✔
2851
            ambiguous_addresses, owners_must_be_ancestors=owners_must_be_ancestors
2852
        )
2853
        return list(remaining_after_ignores)[0] if len(remaining_after_ignores) == 1 else None
10✔
2854

2855

2856
FS = TypeVar("FS", bound="FieldSet")
12✔
2857

2858

2859
@union(in_scope_types=[EnvironmentName])
12✔
2860
@dataclass(frozen=True)
12✔
2861
class InferDependenciesRequest(Generic[FS], EngineAwareParameter):
12✔
2862
    """A request to infer dependencies by analyzing source files.
2863

2864
    To set up a new inference implementation, subclass this class. Set the class property
2865
    `infer_from` to the FieldSet subclass you are able to infer from. This will cause the FieldSet
2866
    class, and any subclass, to use your inference implementation.
2867

2868
    Note that there cannot be more than one implementation for a particular `FieldSet` class.
2869

2870
    Register this subclass with `UnionRule(InferDependenciesRequest, InferFortranDependencies)`, for example.
2871

2872
    Then, create a rule that takes the subclass as a parameter and returns `InferredDependencies`.
2873

2874
    For example:
2875

2876
        class InferFortranDependencies(InferDependenciesRequest):
2877
            infer_from = FortranDependenciesInferenceFieldSet
2878

2879
        @rule
2880
        async def infer_fortran_dependencies(request: InferFortranDependencies) -> InferredDependencies:
2881
            hydrated_sources = await hydrate_sources(HydrateSources(request.field_set.sources))
2882
            ...
2883
            return InferredDependencies(...)
2884

2885
        def rules():
2886
            return [
2887
                infer_fortran_dependencies,
2888
                UnionRule(InferDependenciesRequest, InferFortranDependencies),
2889
            ]
2890
    """
2891

2892
    infer_from: ClassVar[type[FS]]
12✔
2893

2894
    field_set: FS
12✔
2895

2896

2897
@dataclass(frozen=True)
12✔
2898
class InferredDependencies:
12✔
2899
    include: FrozenOrderedSet[Address]
12✔
2900
    exclude: FrozenOrderedSet[Address]
12✔
2901

2902
    def __init__(
12✔
2903
        self,
2904
        include: Iterable[Address],
2905
        *,
2906
        exclude: Iterable[Address] = (),
2907
    ) -> None:
2908
        """The result of inferring dependencies."""
2909
        object.__setattr__(self, "include", FrozenOrderedSet(sorted(include)))
12✔
2910
        object.__setattr__(self, "exclude", FrozenOrderedSet(sorted(exclude)))
12✔
2911

2912

2913
@union(in_scope_types=[EnvironmentName])
12✔
2914
@dataclass(frozen=True)
12✔
2915
class TransitivelyExcludeDependenciesRequest(Generic[FS], EngineAwareParameter):
12✔
2916
    """A request to transitvely exclude dependencies of a "root" node.
2917

2918
    This is similar to `InferDependenciesRequest`, except the request is only made for "root" nodes
2919
    in the dependency graph.
2920

2921
    This mirrors the public facing "transitive exclude" dependency feature (i.e. `!!<address>`).
2922
    """
2923

2924
    infer_from: ClassVar[type[FS]]
12✔
2925

2926
    field_set: FS
12✔
2927

2928

2929
class TransitivelyExcludeDependencies(FrozenOrderedSet[Address]):
12✔
2930
    pass
12✔
2931

2932

2933
@union(in_scope_types=[EnvironmentName])
12✔
2934
@dataclass(frozen=True)
12✔
2935
class ValidateDependenciesRequest(Generic[FS], ABC):
12✔
2936
    """A request to validate dependencies after they have been computed.
2937

2938
    An implementing rule should raise an exception if dependencies are invalid.
2939
    """
2940

2941
    field_set_type: ClassVar[type[FS]]
12✔
2942

2943
    field_set: FS
12✔
2944
    dependencies: Addresses
12✔
2945

2946

2947
@dataclass(frozen=True)
12✔
2948
class ValidatedDependencies:
12✔
2949
    pass
12✔
2950

2951

2952
@dataclass(frozen=True)
12✔
2953
class DependenciesRuleApplicationRequest:
12✔
2954
    """A request to return the applicable dependency rule action for each dependency of a target."""
2955

2956
    address: Address
12✔
2957
    dependencies: Addresses
12✔
2958
    description_of_origin: str = dataclasses.field(hash=False, compare=False)
12✔
2959

2960

2961
@dataclass(frozen=True)
12✔
2962
class DependenciesRuleApplication:
12✔
2963
    """Maps all dependencies to their respective dependency rule application of an origin target
2964
    address.
2965

2966
    The `applications` will be empty and the `address` `None` if there is no dependency rule
2967
    implementation.
2968
    """
2969

2970
    address: Address | None = None
12✔
2971
    dependencies_rule: FrozenDict[Address, DependencyRuleApplication] = FrozenDict()
12✔
2972

2973
    def __post_init__(self):
12✔
2974
        if self.dependencies_rule and self.address is None:
2✔
2975
            raise ValueError(
×
2976
                "The `address` field must not be None when there are `dependencies_rule`s."
2977
            )
2978

2979
    @classmethod
12✔
2980
    @memoized_method
12✔
2981
    def allow_all(cls) -> DependenciesRuleApplication:
12✔
2982
        return cls()
×
2983

2984
    def execute_actions(self) -> None:
12✔
2985
        errors = [
2✔
2986
            action_error.replace("\n", "\n    ")
2987
            for action_error in (rule.execute() for rule in self.dependencies_rule.values())
2988
            if action_error is not None
2989
        ]
2990
        if errors:
2✔
2991
            err_count = len(errors)
1✔
2992
            raise DependencyRuleActionDeniedError(
1✔
2993
                softwrap(
2994
                    f"""
2995
                    {self.address} has {pluralize(err_count, "dependency violation")}:
2996

2997
                    {bullet_list(errors)}
2998
                    """
2999
                )
3000
            )
3001

3002

3003
class SpecialCasedDependencies(StringSequenceField, AsyncFieldMixin):
12✔
3004
    """Subclass this for fields that act similarly to the `dependencies` field, but are handled
3005
    differently than normal dependencies.
3006

3007
    For example, you might have a field for package/binary dependencies, which you will call
3008
    the equivalent of `./pants package` on. While you could put these in the normal
3009
    `dependencies` field, it is often clearer to the user to call out this magic through a
3010
    dedicated field.
3011

3012
    This type will ensure that the dependencies show up in project introspection,
3013
    like `dependencies` and `dependents`, but not show up when you
3014
    `await transitive_targets(TransitiveTargetsRequest(...), **implicitly())` and
3015
    `await resolve_dependencies(DependenciesRequest(...), **implicitly())`.
3016

3017
    To hydrate this field's dependencies, use
3018
    `await resolve_unparsed_address_inputs(tgt.get(MyField).to_unparsed_address_inputs(), **implicitly())`.
3019
    """
3020

3021
    def to_unparsed_address_inputs(self) -> UnparsedAddressInputs:
12✔
3022
        return UnparsedAddressInputs(
12✔
3023
            self.value or (),
3024
            owning_address=self.address,
3025
            description_of_origin=f"the `{self.alias}` from the target {self.address}",
3026
        )
3027

3028

3029
# -----------------------------------------------------------------------------------------------
3030
# Other common Fields used across most targets
3031
# -----------------------------------------------------------------------------------------------
3032

3033

3034
class Tags(StringSequenceField):
12✔
3035
    alias = "tags"
12✔
3036
    help = help_text(
12✔
3037
        f"""
3038
        Arbitrary strings to describe a target.
3039

3040
        For example, you may tag some test targets with 'integration_test' so that you could run
3041
        `{bin_name()} --tag='integration_test' test ::`  to only run on targets with that tag.
3042
        """
3043
    )
3044

3045

3046
class DescriptionField(StringField):
12✔
3047
    alias = "description"
12✔
3048
    help = help_text(
12✔
3049
        f"""
3050
        A human-readable description of the target.
3051

3052
        Use `{bin_name()} list --documented ::` to see all targets with descriptions.
3053
        """
3054
    )
3055

3056

3057
COMMON_TARGET_FIELDS = (Tags, DescriptionField)
12✔
3058

3059

3060
class OverridesField(AsyncFieldMixin, Field):
12✔
3061
    """A mapping of keys (e.g. target names, source globs) to field names with their overridden
3062
    values.
3063

3064
    This is meant for target generators to reduce boilerplate. It's up to the corresponding target
3065
    generator rule to determine how to implement the field, such as how users specify the key. For
3066
    example, `{"f.ext": {"tags": ['my_tag']}}`.
3067
    """
3068

3069
    alias = "overrides"
12✔
3070
    value: dict[tuple[str, ...], dict[str, Any]] | None
12✔
3071
    default: ClassVar[None] = None  # A default does not make sense for this field.
12✔
3072

3073
    @classmethod
12✔
3074
    def compute_value(
12✔
3075
        cls,
3076
        raw_value: dict[str | tuple[str, ...], dict[str, Any]] | None,
3077
        address: Address,
3078
    ) -> FrozenDict[tuple[str, ...], FrozenDict[str, ImmutableValue]] | None:
3079
        value_or_default = super().compute_value(raw_value, address)
12✔
3080
        if value_or_default is None:
12✔
3081
            return None
12✔
3082

3083
        def invalid_type_exception() -> InvalidFieldException:
10✔
3084
            return InvalidFieldTypeException(
1✔
3085
                address,
3086
                cls.alias,
3087
                raw_value,
3088
                expected_type="dict[str | tuple[str, ...], dict[str, Any]]",
3089
            )
3090

3091
        if not isinstance(value_or_default, collections.abc.Mapping):
10✔
3092
            raise invalid_type_exception()
1✔
3093

3094
        result: dict[tuple[str, ...], FrozenDict[str, ImmutableValue]] = {}
10✔
3095
        for outer_key, nested_value in value_or_default.items():
10✔
3096
            if isinstance(outer_key, str):
10✔
3097
                outer_key = (outer_key,)
10✔
3098
            if not isinstance(outer_key, collections.abc.Sequence) or not all(
10✔
3099
                isinstance(elem, str) for elem in outer_key
3100
            ):
3101
                raise invalid_type_exception()
1✔
3102
            if not isinstance(nested_value, collections.abc.Mapping):
10✔
3103
                raise invalid_type_exception()
1✔
3104
            if not all(isinstance(inner_key, str) for inner_key in nested_value):
10✔
3105
                raise invalid_type_exception()
1✔
3106
            result[tuple(outer_key)] = FrozenDict.deep_freeze(cast(Mapping[str, Any], nested_value))
10✔
3107

3108
        return FrozenDict(result)
10✔
3109

3110
    @classmethod
12✔
3111
    def to_path_globs(
12✔
3112
        cls,
3113
        address: Address,
3114
        overrides_keys: Iterable[str],
3115
        unmatched_build_file_globs: UnmatchedBuildFileGlobs,
3116
    ) -> tuple[PathGlobs, ...]:
3117
        """Create a `PathGlobs` for each key.
3118

3119
        This should only be used if the keys are file globs.
3120
        """
3121

3122
        def relativize_glob(glob: str) -> str:
12✔
3123
            return (
4✔
3124
                f"!{os.path.join(address.spec_path, glob[1:])}"
3125
                if glob.startswith("!")
3126
                else os.path.join(address.spec_path, glob)
3127
            )
3128

3129
        return tuple(
12✔
3130
            PathGlobs(
3131
                [relativize_glob(glob)],
3132
                glob_match_error_behavior=unmatched_build_file_globs.error_behavior,
3133
                description_of_origin=f"the `overrides` field for {address}",
3134
            )
3135
            for glob in overrides_keys
3136
        )
3137

3138
    def flatten(self) -> dict[str, dict[str, Any]]:
12✔
3139
        """Combine all overrides for every key into a single dictionary."""
3140
        result: dict[str, dict[str, Any]] = {}
12✔
3141
        for keys, override in (self.value or {}).items():
12✔
3142
            for key in keys:
10✔
3143
                for field, value in override.items():
10✔
3144
                    if key not in result:
10✔
3145
                        result[key] = {field: value}
10✔
3146
                        continue
10✔
3147
                    if field not in result[key]:
5✔
3148
                        result[key][field] = value
5✔
3149
                        continue
5✔
3150
                    raise InvalidFieldException(
×
3151
                        f"Conflicting overrides in the `{self.alias}` field of "
3152
                        f"`{self.address}` for the key `{key}` for "
3153
                        f"the field `{field}`. You cannot specify the same field name "
3154
                        "multiple times for the same key.\n\n"
3155
                        f"(One override sets the field to `{repr(result[key][field])}` "
3156
                        f"but another sets to `{repr(value)}`.)"
3157
                    )
3158
        return result
12✔
3159

3160
    @classmethod
12✔
3161
    def flatten_paths(
12✔
3162
        cls,
3163
        address: Address,
3164
        paths_and_overrides: Iterable[tuple[Paths, PathGlobs, dict[str, Any]]],
3165
    ) -> dict[str, dict[str, Any]]:
3166
        """Combine all overrides for each file into a single dictionary."""
3167
        result: dict[str, dict[str, Any]] = {}
12✔
3168
        for paths, globs, override in paths_and_overrides:
12✔
3169
            # NB: If some globs did not result in any Paths, we preserve them to ensure that
3170
            # unconsumed overrides trigger errors during generation.
3171
            for path in paths.files or globs.globs:
4✔
3172
                for field, value in override.items():
4✔
3173
                    if path not in result:
4✔
3174
                        result[path] = {field: value}
4✔
3175
                        continue
4✔
3176
                    if field not in result[path]:
1✔
3177
                        result[path][field] = value
1✔
3178
                        continue
1✔
3179
                    relpath = fast_relpath(path, address.spec_path)
1✔
3180
                    raise InvalidFieldException(
1✔
3181
                        f"Conflicting overrides for `{address}` for the relative path "
3182
                        f"`{relpath}` for the field `{field}`. You cannot specify the same field "
3183
                        f"name multiple times for the same path.\n\n"
3184
                        f"(One override sets the field to `{repr(result[path][field])}` "
3185
                        f"but another sets to `{repr(value)}`.)"
3186
                    )
3187
        return result
12✔
3188

3189

3190
def generate_multiple_sources_field_help_message(files_example: str) -> str:
12✔
3191
    return softwrap(
12✔
3192
        """
3193
        A list of files and globs that belong to this target.
3194

3195
        Paths are relative to the BUILD file's directory. You can ignore files/globs by
3196
        prefixing them with `!`.
3197

3198
        """
3199
        + files_example
3200
    )
3201

3202

3203
def generate_file_based_overrides_field_help_message(
12✔
3204
    generated_target_name: str, example: str
3205
) -> str:
3206
    example = textwrap.dedent(example.lstrip("\n"))  # noqa: PNT20
12✔
3207
    example = textwrap.indent(example, " " * 4)
12✔
3208
    return "\n".join(
12✔
3209
        [
3210
            softwrap(
3211
                f"""
3212
                Override the field values for generated `{generated_target_name}` targets.
3213

3214
                Expects a dictionary of relative file paths and globs to a dictionary for the
3215
                overrides. You may either use a string for a single path / glob,
3216
                or a string tuple for multiple paths / globs. Each override is a dictionary of
3217
                field names to the overridden value.
3218

3219
                For example:
3220

3221
                {example}
3222
                """
3223
            ),
3224
            "",
3225
            softwrap(
3226
                f"""
3227
                File paths and globs are relative to the BUILD file's directory. Every overridden file is
3228
                validated to belong to this target's `sources` field.
3229

3230
                If you'd like to override a field's value for every `{generated_target_name}` target
3231
                generated by this target, change the field directly on this target rather than using the
3232
                `overrides` field.
3233

3234
                You can specify the same file name in multiple keys, so long as you don't override the
3235
                same field more than one time for the file.
3236
                """
3237
            ),
3238
        ],
3239
    )
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