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

pantsbuild / pants / 20520347691

26 Dec 2025 09:50AM UTC coverage: 80.283% (-0.01%) from 80.296%
20520347691

Pull #21918

github

web-flow
Merge 8d95a9530 into 165706096
Pull Request #21918: [WIP] partition protobuf dependency inference by any "resolve-like" fields from plugins

225 of 297 new or added lines in 11 files covered. (75.76%)

42 existing lines in 3 files now uncovered.

78750 of 98090 relevant lines covered (80.28%)

3.36 hits per line

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

76.1
/src/python/pants/core/target_types.py
1
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
12✔
4

5
import builtins
12✔
6
import dataclasses
12✔
7
import os
12✔
8
import urllib.parse
12✔
9
from collections import defaultdict
12✔
10
from collections.abc import Sequence
12✔
11
from dataclasses import dataclass
12✔
12
from pathlib import PurePath
12✔
13
from typing import Generic, TypeVar, cast
12✔
14

15
from pants.core.goals import package
12✔
16
from pants.core.goals.package import (
12✔
17
    BuiltPackage,
18
    BuiltPackageArtifact,
19
    EnvironmentAwarePackageRequest,
20
    OutputPathField,
21
    PackageFieldSet,
22
    environment_aware_package,
23
)
24
from pants.core.util_rules.archive import ArchiveFormat, CreateArchive, create_archive
12✔
25
from pants.core.util_rules.archive import rules as archive_rules
12✔
26
from pants.engine.addresses import Address, UnparsedAddressInputs
12✔
27
from pants.engine.download_file import download_file
12✔
28
from pants.engine.environment import EnvironmentName
12✔
29
from pants.engine.fs import (
12✔
30
    AddPrefix,
31
    CreateDigest,
32
    DownloadFile,
33
    FileDigest,
34
    FileEntry,
35
    MergeDigests,
36
    PathGlobs,
37
    RemovePrefix,
38
)
39
from pants.engine.internals.graph import find_valid_field_sets, hydrate_sources, resolve_targets
12✔
40
from pants.engine.intrinsics import digest_to_snapshot
12✔
41
from pants.engine.platform import Platform
12✔
42
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
12✔
43
from pants.engine.target import (
12✔
44
    COMMON_TARGET_FIELDS,
45
    AllTargets,
46
    Dependencies,
47
    FieldSet,
48
    FieldSetsPerTargetRequest,
49
    GeneratedSources,
50
    GenerateSourcesRequest,
51
    HydrateSourcesRequest,
52
    InvalidFieldTypeException,
53
    MultipleSourcesField,
54
    OptionalSingleSourceField,
55
    OverridesField,
56
    SingleSourceField,
57
    SourcesField,
58
    SpecialCasedDependencies,
59
    StringField,
60
    Target,
61
    TargetFilesGenerator,
62
    generate_file_based_overrides_field_help_message,
63
    generate_multiple_sources_field_help_message,
64
)
65
from pants.engine.unions import UnionRule, union
12✔
66
from pants.option.bootstrap_options import UnmatchedBuildFileGlobs
12✔
67
from pants.util.docutil import bin_name
12✔
68
from pants.util.frozendict import FrozenDict
12✔
69
from pants.util.logging import LogLevel
12✔
70
from pants.util.strutil import help_text, softwrap
12✔
71

72
# -----------------------------------------------------------------------------------------------
73
# `per_platform` object
74
# -----------------------------------------------------------------------------------------------
75
_T = TypeVar("_T")
12✔
76

77

78
@dataclass(frozen=True)
12✔
79
class per_platform(Generic[_T]):
12✔
80
    """An object containing differing homogeneous platform-dependent values.
81

82
    The values should be evaluated for the execution environment, and not the host environment
83
    (I.e. it should be evaluated in a `rule` which requests `Platform`).
84

85
    Expected usage is roughly:
86

87
    ```python
88
    class MyFieldType(...):
89
        value = str | per_platform[str]
90

91
        @classmethod
92
        def compute_value(  # type: ignore[override]
93
            cls,
94
            raw_value: Optional[Union[str, per_platform[str]]],
95
            address: Address,
96
        ) -> Optional[Union[str, per_platform[str]]]:
97
            if isinstance(raw_value, per_platform):
98
                # NOTE: Ensure the values are homogeneous
99
                raw_value.check_types(str)
100

101
            return raw_value
102

103
    ...
104

105
    @rule
106
    async def my_rule(..., platform: Platform) -> ...:
107
        field_value = target[MyFieldType].value
108

109
        if isinstance(field_value, per_platform):
110
            field_value = field_value.get_value_for_platform(platform)
111

112
        ...
113
    ```
114

115
    NOTE: Support for this object should be heavily weighed, as it would be inappropriate to use in
116
    certain contexts (such as the `source` field in a `foo_source` target, where the intent is to
117
    support differing source files based on platform. The result would be that dependency inference
118
    (and therefore the dependencies field) wouldn't be knowable on the host, which is not something
119
    the engine can support yet).
120
    """
121

122
    linux_arm64: _T | None = None
12✔
123
    linux_x86_64: _T | None = None
12✔
124
    macos_arm64: _T | None = None
12✔
125
    macos_x86_64: _T | None = None
12✔
126

127
    def check_types(self, type_: type) -> None:
12✔
128
        fields_and_values = [
×
129
            (field.name, getattr(self, field.name)) for field in dataclasses.fields(self)
130
        ]
131
        fields_with_values = {name: value for name, value in fields_and_values if value is not None}
×
132
        if not fields_with_values:
×
133
            raise ValueError("`per_platform` must be given at least one platform value.")
×
134

135
        bad_typed_fields = [
×
136
            (name, type(value).__name__)
137
            for name, value in fields_with_values.items()
138
            if not isinstance(value, type_)
139
        ]
140
        if bad_typed_fields:
×
141
            raise TypeError(
×
142
                f"The following fields of a `per_platform` object were expected to be of type `{type_.__name__}`:"
143
                + ' "'
144
                + ", ".join(f"{name} of type '{typename}'" for name, typename in bad_typed_fields)
145
                + '".'
146
            )
147

148
    def get_value_for_platform(self, platform: Platform) -> _T:
12✔
149
        value = getattr(self, platform.value)
×
150
        if value is None:
×
151
            raise ValueError(
×
152
                f"A request was made to resolve a `per_platform` on `{platform.value}`"
153
                + " but the value was `None`. Please specify a value."
154
            )
155
        return cast("_T", value)
×
156

157

158
# -----------------------------------------------------------------------------------------------
159
# Asset target helpers
160
# -----------------------------------------------------------------------------------------------
161

162

163
@dataclass(frozen=True)
12✔
164
class http_source:
12✔
165
    url: str
12✔
166
    len: int
12✔
167
    sha256: str
12✔
168
    # Defaults to last part of the URL path (E.g. `index.html`)
169
    filename: str
12✔
170

171
    def __init__(self, url: str, *, len: int, sha256: str, filename: str = ""):
12✔
172
        for field in dataclasses.fields(self):
1✔
173
            value = locals()[field.name]
1✔
174
            if not isinstance(value, getattr(builtins, cast(str, field.type))):
1✔
175
                raise TypeError(f"`{field.name}` must be a `{field.type}`, got `{type(value)!r}`.")
1✔
176

177
        object.__setattr__(self, "url", url)
1✔
178
        object.__setattr__(self, "len", len)
1✔
179
        object.__setattr__(self, "sha256", sha256)
1✔
180
        object.__setattr__(
1✔
181
            self, "filename", filename or urllib.parse.urlparse(url).path.rsplit("/", 1)[-1]
182
        )
183

184
        self.__post_init__()
1✔
185

186
    def __post_init__(self):
12✔
187
        if not self.filename:
1✔
188
            raise ValueError(
1✔
189
                softwrap(
190
                    f"""
191
                    Couldn't deduce filename from `url`: '{self.url}'.
192

193
                    Please specify the `filename` argument.
194
                    """
195
                )
196
            )
197
        if "\\" in self.filename or "/" in self.filename:
1✔
198
            raise ValueError(
1✔
199
                f"`filename` cannot contain a path separator, but was set to '{self.filename}'"
200
            )
201

202

203
class AssetSourceField(SingleSourceField):
12✔
204
    value: str | http_source | per_platform[http_source]  # type: ignore[assignment]
12✔
205
    # @TODO: Don't document http_source, link to it once https://github.com/pantsbuild/pants/issues/14832
206
    # is implemented.
207
    help = help_text(
12✔
208
        """
209
        The source of this target.
210

211
        If a string is provided, represents a path that is relative to the BUILD file's directory,
212
        e.g. `source='example.ext'`.
213

214
        If an `http_source` is provided, represents the network location to download the source from.
215
        The downloaded file will exist in the sandbox in the same directory as the target.
216

217
        `http_source` has the following signature:
218

219
            http_source(url: str, *, len: int, sha256: str, filename: str = "")
220

221
        The filename defaults to the last part of the URL path (e.g. `example.ext`), but can also be
222
        specified if you wish to have control over the file name. You cannot, however, specify a
223
        path separator to download the file into a subdirectory (you must declare a target in desired
224
        subdirectory).
225

226
        You can easily get the len and checksum with the following command:
227

228
            curl -L $URL | tee >(wc -c) >(shasum -a 256) >/dev/null
229

230
        If a `per_platform` is provided, represents a mapping from platform to `http_source`, where
231
        the platform is one of (`linux_arm64`, `linux_x86_64`, `macos_arm64`, `macos_x86_64`) and is
232
        resolved in the execution target. Each `http_source` value MUST have the same filename provided.
233
        """
234
    )
235

236
    @classmethod
12✔
237
    def compute_value(  # type: ignore[override]
12✔
238
        cls,
239
        raw_value: str | http_source | per_platform[http_source] | None,
240
        address: Address,
241
    ) -> str | http_source | per_platform[http_source] | None:
242
        if raw_value is None or isinstance(raw_value, str):
4✔
243
            return super().compute_value(raw_value, address)
4✔
244
        elif isinstance(raw_value, per_platform):
×
245
            raw_value.check_types(http_source)
×
246
            value_as_dict = dataclasses.asdict(raw_value)
×
247
            filenames = {
×
248
                source["filename"] for source in value_as_dict.values() if source is not None
249
            }
250
            if len(filenames) > 1:
×
251
                raise ValueError(
×
252
                    "Every `http_source` in the `per_platform` must have the same `filename`,"
253
                    + f" but found: {', '.join(sorted(filenames))}"
254
                )
255

256
        elif not isinstance(raw_value, http_source):
×
257
            raise InvalidFieldTypeException(
×
258
                address,
259
                cls.alias,
260
                raw_value,
261
                expected_type="a string, an `http_source` object, or a `per_platform[http_source]` object.",
262
            )
263
        return raw_value
×
264

265
    def validate_resolved_files(self, files: Sequence[str]) -> None:
12✔
266
        if isinstance(self.value, str):
×
267
            super().validate_resolved_files(files)
×
268

269
    @property
12✔
270
    def globs(self) -> tuple[str, ...]:
12✔
271
        if isinstance(self.value, str):
×
272
            return (self.value,)
×
273
        return ()
×
274

275
    @property
12✔
276
    def file_path(self) -> str:
12✔
277
        assert self.value
×
278
        filename = (
×
279
            self.value
280
            if isinstance(self.value, str)
281
            else (
282
                self.value.filename
283
                if isinstance(self.value, http_source)
284
                else next(
285
                    source["filename"]
286
                    for source in dataclasses.asdict(self.value).values()
287
                    if source is not None
288
                )
289
            )
290
        )
291
        return os.path.join(self.address.spec_path, filename)
×
292

293

294
async def _hydrate_asset_source(
12✔
295
    request: GenerateSourcesRequest, platform: Platform
296
) -> GeneratedSources:
297
    target = request.protocol_target
×
298
    source_field = target[AssetSourceField]
×
299
    if isinstance(source_field.value, str):
×
300
        return GeneratedSources(request.protocol_sources)
×
301

302
    source = source_field.value
×
303
    if isinstance(source, per_platform):
×
304
        source = source.get_value_for_platform(platform)
×
305

306
    file_digest = FileDigest(source.sha256, source.len)
×
307
    # NB: This just has to run, we don't actually need the result because we know the Digest's
308
    # FileEntry metadata.
309
    await download_file(DownloadFile(source.url, file_digest), **implicitly())
×
310
    snapshot = await digest_to_snapshot(
×
311
        **implicitly(
312
            CreateDigest(
313
                [
314
                    FileEntry(
315
                        path=source_field.file_path,
316
                        file_digest=file_digest,
317
                    )
318
                ]
319
            )
320
        )
321
    )
322

323
    return GeneratedSources(snapshot)
×
324

325

326
# -----------------------------------------------------------------------------------------------
327
# `file` and `files` targets
328
# -----------------------------------------------------------------------------------------------
329
class FileSourceField(AssetSourceField):
12✔
330
    uses_source_roots = False
12✔
331

332

333
class FileDependenciesField(Dependencies):
12✔
334
    pass
12✔
335

336

337
class FileTarget(Target):
12✔
338
    alias = "file"
12✔
339
    core_fields = (*COMMON_TARGET_FIELDS, FileDependenciesField, FileSourceField)
12✔
340
    help = help_text(
12✔
341
        """
342
        A single loose file that lives outside of code packages.
343

344
        Files are placed directly in archives, outside of code artifacts such as Python wheels
345
        or JVM JARs. The sources of a `file` target are accessed via filesystem APIs, such as
346
        Python's `open()`, via paths relative to the repository root.
347
        """
348
    )
349

350

351
class GenerateFileSourceRequest(GenerateSourcesRequest):
12✔
352
    input = FileSourceField
12✔
353
    output = FileSourceField
12✔
354

355

356
@rule
12✔
357
async def hydrate_file_source(
12✔
358
    request: GenerateFileSourceRequest, platform: Platform
359
) -> GeneratedSources:
360
    return await _hydrate_asset_source(request, platform)
×
361

362

363
class FilesGeneratingSourcesField(MultipleSourcesField):
12✔
364
    required = True
12✔
365
    uses_source_roots = False
12✔
366
    help = generate_multiple_sources_field_help_message(
12✔
367
        "Example: `sources=['example.txt', 'new_*.md', '!old_ignore.csv']`"
368
    )
369

370

371
class FilesOverridesField(OverridesField):
12✔
372
    help = generate_file_based_overrides_field_help_message(
12✔
373
        FileTarget.alias,
374
        """
375
        overrides={
376
            "foo.json": {"description": "our customer model"]},
377
            "bar.json": {"description": "our product model"]},
378
            ("foo.json", "bar.json"): {"tags": ["overridden"]},
379
        }
380
        """,
381
    )
382

383

384
class FilesGeneratorTarget(TargetFilesGenerator):
12✔
385
    alias = "files"
12✔
386
    core_fields = (
12✔
387
        *COMMON_TARGET_FIELDS,
388
        FilesGeneratingSourcesField,
389
        FilesOverridesField,
390
    )
391
    generated_target_cls = FileTarget
12✔
392
    copied_fields = COMMON_TARGET_FIELDS
12✔
393
    moved_fields = (FileDependenciesField,)
12✔
394
    help = "Generate a `file` target for each file in the `sources` field."
12✔
395

396

397
# -----------------------------------------------------------------------------------------------
398
# `relocated_files` target
399
# -----------------------------------------------------------------------------------------------
400

401

402
class RelocatedFilesSourcesField(MultipleSourcesField):
12✔
403
    # We solely register this field for codegen to work.
404
    alias = "_sources"
12✔
405
    expected_num_files = 0
12✔
406

407

408
class RelocatedFilesOriginalTargetsField(SpecialCasedDependencies):
12✔
409
    alias = "files_targets"
12✔
410
    required = True
12✔
411
    help = help_text(
12✔
412
        """
413
        Addresses to the original `file` and `files` targets that you want to relocate, such as
414
        `['//:json_files']`.
415

416
        Every target will be relocated using the same mapping. This means
417
        that every target must include the value from the `src` field in their original path.
418
        """
419
    )
420

421

422
class RelocatedFilesSrcField(StringField):
12✔
423
    alias = "src"
12✔
424
    required = True
12✔
425
    help = help_text(
12✔
426
        """
427
        The original prefix that you want to replace, such as `src/resources`.
428

429
        You can set this field to the empty string to preserve the original path; the value in the `dest`
430
        field will then be added to the beginning of this original path.
431
        """
432
    )
433

434

435
class RelocatedFilesDestField(StringField):
12✔
436
    alias = "dest"
12✔
437
    required = True
12✔
438
    help = help_text(
12✔
439
        """
440
        The new prefix that you want to add to the beginning of the path, such as `data`.
441

442
        You can set this field to the empty string to avoid adding any new values to the path; the
443
        value in the `src` field will then be stripped, rather than replaced.
444
        """
445
    )
446

447

448
class RelocatedFiles(Target):
12✔
449
    alias = "relocated_files"
12✔
450
    core_fields = (
12✔
451
        *COMMON_TARGET_FIELDS,
452
        RelocatedFilesSourcesField,
453
        RelocatedFilesOriginalTargetsField,
454
        RelocatedFilesSrcField,
455
        RelocatedFilesDestField,
456
    )
457
    help = help_text(
12✔
458
        """
459
        Loose files with path manipulation applied.
460

461
        Allows you to relocate the files at runtime to something more convenient than their actual
462
        paths in your project.
463

464
        For example, you can relocate `src/resources/project1/data.json` to instead be
465
        `resources/data.json`. Your other target types can then add this target to their
466
        `dependencies` field, rather than using the original `files` target.
467

468
        To remove a prefix:
469

470
            # Results in `data.json`.
471
            relocated_files(
472
                files_targets=["src/resources/project1:target"],
473
                src="src/resources/project1",
474
                dest="",
475
            )
476

477
        To add a prefix:
478

479
            # Results in `images/logo.svg`.
480
            relocated_files(
481
                files_targets=["//:logo"],
482
                src="",
483
                dest="images",
484
            )
485

486
        To replace a prefix:
487

488
            # Results in `new_prefix/project1/data.json`.
489
            relocated_files(
490
                files_targets=["src/resources/project1:target"],
491
                src="src/resources",
492
                dest="new_prefix",
493
            )
494
        """
495
    )
496

497

498
class RelocateFilesViaCodegenRequest(GenerateSourcesRequest):
12✔
499
    input = RelocatedFilesSourcesField
12✔
500
    output = FileSourceField
12✔
501
    exportable = False
12✔
502

503

504
@rule(desc="Relocating loose files for `relocated_files` targets", level=LogLevel.DEBUG)
12✔
505
async def relocate_files(request: RelocateFilesViaCodegenRequest) -> GeneratedSources:
12✔
506
    # Unlike normal codegen, we operate the on the sources of the `files_targets` field, not the
507
    # `sources` of the original `relocated_sources` target.
508
    # TODO(#13086): Because we're using `Targets` instead of `UnexpandedTargets`, the
509
    #  `files` target generator gets replaced by its generated `file` targets. That replacement is
510
    #  necessary because we only hydrate sources for `FileSourcesField`, which is only for the
511
    #  `file` target.  That's really subtle!
512
    original_file_targets = await resolve_targets(
×
513
        **implicitly(
514
            {
515
                request.protocol_target.get(
516
                    RelocatedFilesOriginalTargetsField
517
                ).to_unparsed_address_inputs(): UnparsedAddressInputs
518
            }
519
        )
520
    )
521
    original_files_sources = await concurrently(
×
522
        hydrate_sources(
523
            HydrateSourcesRequest(
524
                tgt.get(SourcesField),
525
                for_sources_types=(FileSourceField,),
526
                enable_codegen=True,
527
            ),
528
            **implicitly(),
529
        )
530
        for tgt in original_file_targets
531
    )
532
    snapshot = await digest_to_snapshot(
×
533
        **implicitly(MergeDigests(sources.snapshot.digest for sources in original_files_sources))
534
    )
535

536
    src_val = request.protocol_target.get(RelocatedFilesSrcField).value
×
537
    dest_val = request.protocol_target.get(RelocatedFilesDestField).value
×
538
    if src_val:
×
539
        snapshot = await digest_to_snapshot(**implicitly(RemovePrefix(snapshot.digest, src_val)))
×
540
    if dest_val:
×
541
        snapshot = await digest_to_snapshot(**implicitly(AddPrefix(snapshot.digest, dest_val)))
×
542
    return GeneratedSources(snapshot)
×
543

544

545
# -----------------------------------------------------------------------------------------------
546
# `resource` and `resources` target
547
# -----------------------------------------------------------------------------------------------
548

549

550
class ResourceDependenciesField(Dependencies):
12✔
551
    pass
12✔
552

553

554
class ResourceSourceField(AssetSourceField):
12✔
555
    uses_source_roots = True
12✔
556

557

558
class ResourceTarget(Target):
12✔
559
    alias = "resource"
12✔
560
    core_fields = (*COMMON_TARGET_FIELDS, ResourceDependenciesField, ResourceSourceField)
12✔
561
    help = help_text(
12✔
562
        """
563
        A single resource file embedded in a code package and accessed in a
564
        location-independent manner.
565

566
        Resources are embedded in code artifacts such as Python wheels or JVM JARs. The sources
567
        of a `resources` target are accessed via language-specific resource APIs, such as
568
        Python's `pkgutil` or JVM's ClassLoader, via paths relative to the target's source root.
569
        """
570
    )
571

572

573
class GenerateResourceSourceRequest(GenerateSourcesRequest):
12✔
574
    input = ResourceSourceField
12✔
575
    output = ResourceSourceField
12✔
576

577

578
@rule
12✔
579
async def hydrate_resource_source(
12✔
580
    request: GenerateResourceSourceRequest, platform: Platform
581
) -> GeneratedSources:
582
    return await _hydrate_asset_source(request, platform)
×
583

584

585
class ResourcesGeneratingSourcesField(MultipleSourcesField):
12✔
586
    required = True
12✔
587
    help = generate_multiple_sources_field_help_message(
12✔
588
        "Example: `sources=['example.txt', 'new_*.md', '!old_ignore.csv']`"
589
    )
590

591

592
class ResourcesOverridesField(OverridesField):
12✔
593
    help = generate_file_based_overrides_field_help_message(
12✔
594
        ResourceTarget.alias,
595
        """
596
        overrides={
597
            "foo.json": {"description": "our customer model"]},
598
            "bar.json": {"description": "our product model"]},
599
            ("foo.json", "bar.json"): {"tags": ["overridden"]},
600
        }
601
        """,
602
    )
603

604

605
class ResourcesGeneratorTarget(TargetFilesGenerator):
12✔
606
    alias = "resources"
12✔
607
    core_fields = (
12✔
608
        *COMMON_TARGET_FIELDS,
609
        ResourcesGeneratingSourcesField,
610
        ResourcesOverridesField,
611
    )
612
    generated_target_cls = ResourceTarget
12✔
613
    copied_fields = COMMON_TARGET_FIELDS
12✔
614
    moved_fields = (ResourceDependenciesField,)
12✔
615
    help = "Generate a `resource` target for each file in the `sources` field."
12✔
616

617

618
@dataclass(frozen=True)
12✔
619
class ResourcesFieldSet(FieldSet):
12✔
620
    required_fields = (ResourceSourceField,)
12✔
621

622
    sources: ResourceSourceField
12✔
623

624

625
@dataclass(frozen=True)
12✔
626
class ResourcesGeneratorFieldSet(FieldSet):
12✔
627
    required_fields = (ResourcesGeneratingSourcesField,)
12✔
628

629
    sources: ResourcesGeneratingSourcesField
12✔
630

631

632
# -----------------------------------------------------------------------------------------------
633
# `target` generic target
634
# -----------------------------------------------------------------------------------------------
635

636

637
class GenericTargetDependenciesField(Dependencies):
12✔
638
    pass
12✔
639

640

641
class GenericTarget(Target):
12✔
642
    alias = "target"
12✔
643
    core_fields = (*COMMON_TARGET_FIELDS, GenericTargetDependenciesField)
12✔
644
    help = help_text(
12✔
645
        """
646
        A generic target with no specific type.
647

648
        This can be used as a generic "bag of dependencies", i.e. you can group several different
649
        targets into one single target so that your other targets only need to depend on one thing.
650
        """
651
    )
652

653

654
# -----------------------------------------------------------------------------------------------
655
# `Asset` targets (resources and files)
656
# -----------------------------------------------------------------------------------------------
657

658

659
@dataclass(frozen=True)
12✔
660
class AllAssetTargets:
12✔
661
    resources: tuple[Target, ...]
12✔
662
    files: tuple[Target, ...]
12✔
663

664

665
@rule(desc="Find all assets in project")
12✔
666
async def find_all_assets(all_targets: AllTargets) -> AllAssetTargets:
12✔
667
    resources = []
×
668
    files = []
×
669
    for tgt in all_targets:
×
670
        if tgt.has_field(ResourceSourceField):
×
671
            resources.append(tgt)
×
672
        if tgt.has_field(FileSourceField):
×
673
            files.append(tgt)
×
674
    return AllAssetTargets(tuple(resources), tuple(files))
×
675

676

677
@dataclass(frozen=True)
12✔
678
class AllAssetTargetsByPath:
12✔
679
    resources: FrozenDict[PurePath, frozenset[Target]]
12✔
680
    files: FrozenDict[PurePath, frozenset[Target]]
12✔
681

682

683
@rule(desc="Mapping assets by path")
12✔
684
async def map_assets_by_path(
12✔
685
    all_asset_targets: AllAssetTargets,
686
) -> AllAssetTargetsByPath:
687
    resources_by_path: defaultdict[PurePath, set[Target]] = defaultdict(set)
×
688
    for resource_tgt in all_asset_targets.resources:
×
689
        resources_by_path[PurePath(resource_tgt[ResourceSourceField].file_path)].add(resource_tgt)
×
690

691
    files_by_path: defaultdict[PurePath, set[Target]] = defaultdict(set)
×
692
    for file_tgt in all_asset_targets.files:
×
693
        files_by_path[PurePath(file_tgt[FileSourceField].file_path)].add(file_tgt)
×
694

695
    return AllAssetTargetsByPath(
×
696
        FrozenDict((key, frozenset(values)) for key, values in resources_by_path.items()),
697
        FrozenDict((key, frozenset(values)) for key, values in files_by_path.items()),
698
    )
699

700

701
# -----------------------------------------------------------------------------------------------
702
# `_target_generator_sources_helper` target
703
# -----------------------------------------------------------------------------------------------
704

705

706
class TargetGeneratorSourcesHelperSourcesField(SingleSourceField):
12✔
707
    uses_source_roots = False
12✔
708
    required = True
12✔
709

710

711
class TargetGeneratorSourcesHelperTarget(Target):
12✔
712
    """Target generators that work by reading in some source file(s) should also generate this
713
    target once per file, and add it as a dependency to every generated target so that `--changed-
714
    since` works properly.
715

716
    See https://github.com/pantsbuild/pants/issues/13118 for discussion of why this is necessary and
717
    alternatives considered.
718
    """
719

720
    alias = "_generator_sources_helper"
12✔
721
    core_fields = (*COMMON_TARGET_FIELDS, TargetGeneratorSourcesHelperSourcesField)
12✔
722
    help = help_text(
12✔
723
        """
724
        A private helper target type used by some target generators.
725

726
        This tracks their `source` / `sources` field so that `--changed-since --changed-dependents`
727
        works properly for generated targets.
728
        """
729
    )
730

731

732
# -----------------------------------------------------------------------------------------------
733
# `archive` target
734
# -----------------------------------------------------------------------------------------------
735

736

737
class ArchivePackagesField(SpecialCasedDependencies):
12✔
738
    alias = "packages"
12✔
739
    help = help_text(
12✔
740
        f"""
741
        Addresses to any targets that can be built with `{bin_name()} package`,
742
        e.g. `["project:app"]`.
743

744
        Pants will build the assets as if you had run `{bin_name()} package`.
745
        It will include the results in your archive using the same name they
746
        would normally have, but without the `--distdir` prefix (e.g. `dist/`).
747

748
        You can include anything that can be built by `{bin_name()} package`,
749
        e.g. a `pex_binary`, `python_awslambda`, or even another `archive`.
750
        """
751
    )
752

753

754
class ArchiveFilesField(SpecialCasedDependencies):
12✔
755
    alias = "files"
12✔
756
    help = help_text(
12✔
757
        """
758
        Addresses to any `file`, `files`, or `relocated_files` targets to include in the
759
        archive, e.g. `["resources:logo"]`.
760

761
        This is useful to include any loose files, like data files,
762
        image assets, or config files.
763

764
        This will ignore any targets that are not `file`, `files`, or
765
        `relocated_files` targets.
766

767
        If you instead want those files included in any packages specified in the `packages`
768
        field for this target, then use a `resource` or `resources` target and have the original
769
        package depend on the resources.
770
        """
771
    )
772

773

774
class ArchiveFormatField(StringField):
12✔
775
    alias = "format"
12✔
776
    valid_choices = ArchiveFormat
12✔
777
    required = True
12✔
778
    value: str
12✔
779
    help = "The type of archive file to be generated."
12✔
780

781

782
class ArchiveTarget(Target):
12✔
783
    alias = "archive"
12✔
784
    core_fields = (
12✔
785
        *COMMON_TARGET_FIELDS,
786
        OutputPathField,
787
        ArchivePackagesField,
788
        ArchiveFilesField,
789
        ArchiveFormatField,
790
    )
791
    help = "A ZIP or TAR file containing loose files and code packages."
12✔
792

793

794
@dataclass(frozen=True)
12✔
795
class ArchiveFieldSet(PackageFieldSet):
12✔
796
    required_fields = (ArchiveFormatField,)
12✔
797

798
    packages: ArchivePackagesField
12✔
799
    files: ArchiveFilesField
12✔
800
    format_field: ArchiveFormatField
12✔
801
    output_path: OutputPathField
12✔
802

803

804
@rule(level=LogLevel.DEBUG)
12✔
805
async def package_archive_target(field_set: ArchiveFieldSet) -> BuiltPackage:
12✔
806
    # TODO(#13086): Because we're using `Targets` instead of `UnexpandedTargets`, the
807
    #  `files` target generator gets replaced by its generated `file` targets. That replacement is
808
    #  necessary because we only hydrate sources for `FileSourcesField`, which is only for the
809
    #  `file` target.  That's really subtle!
810
    package_targets, file_targets = await concurrently(
×
811
        resolve_targets(**implicitly(field_set.packages.to_unparsed_address_inputs())),
812
        resolve_targets(**implicitly(field_set.files.to_unparsed_address_inputs())),
813
    )
814

815
    package_field_sets_per_target = await find_valid_field_sets(
×
816
        FieldSetsPerTargetRequest(PackageFieldSet, package_targets), **implicitly()
817
    )
818
    packages = await concurrently(
×
819
        environment_aware_package(EnvironmentAwarePackageRequest(field_set))
820
        for field_set in package_field_sets_per_target.field_sets
821
    )
822

823
    file_sources = await concurrently(
×
824
        hydrate_sources(
825
            HydrateSourcesRequest(
826
                tgt.get(SourcesField),
827
                for_sources_types=(FileSourceField,),
828
                enable_codegen=True,
829
            ),
830
            **implicitly(),
831
        )
832
        for tgt in file_targets
833
    )
834

835
    input_snapshot = await digest_to_snapshot(
×
836
        **implicitly(
837
            MergeDigests(
838
                (
839
                    *(package.digest for package in packages),
840
                    *(sources.snapshot.digest for sources in file_sources),
841
                )
842
            )
843
        )
844
    )
845

846
    output_filename = field_set.output_path.value_or_default(
×
847
        file_ending=field_set.format_field.value
848
    )
849
    archive = await create_archive(
×
850
        CreateArchive(
851
            input_snapshot,
852
            output_filename=output_filename,
853
            format=ArchiveFormat(field_set.format_field.value),
854
        ),
855
        **implicitly(),
856
    )
857
    return BuiltPackage(archive, (BuiltPackageArtifact(output_filename),))
×
858

859

860
# -----------------------------------------------------------------------------------------------
861
# `_lockfile` and `_lockfiles` targets
862
# -----------------------------------------------------------------------------------------------
863

864

865
class LockfileSourceField(OptionalSingleSourceField):
12✔
866
    """Source field for synthesized `_lockfile` targets.
867

868
    It is special in that it always ignores any missing files, regardless of the global
869
    `--unmatched-build-file-globs` option.
870
    """
871

872
    uses_source_roots = False
12✔
873
    required = True
12✔
874
    value: str
12✔
875

876
    def path_globs(self, unmatched_build_file_globs: UnmatchedBuildFileGlobs) -> PathGlobs:  # type: ignore[misc]
12✔
877
        return super().path_globs(UnmatchedBuildFileGlobs.ignore())
1✔
878

879

880
class LockfileDependenciesField(Dependencies):
12✔
881
    pass
12✔
882

883

884
class LockfileTarget(Target):
12✔
885
    alias = "_lockfile"
12✔
886
    core_fields = (*COMMON_TARGET_FIELDS, LockfileSourceField, LockfileDependenciesField)
12✔
887
    help = help_text(
12✔
888
        """
889
        A target for lockfiles in order to include them in the dependency graph of other targets.
890

891
        This tracks them so that `--changed-since --changed-dependents` works properly for targets
892
        relying on a particular lockfile.
893
        """
894
    )
895

896

897
class LockfilesGeneratorSourcesField(MultipleSourcesField):
12✔
898
    """Sources field for synthesized `_lockfiles` targets.
899

900
    It is special in that it always ignores any missing files, regardless of the global
901
    `--unmatched-build-file-globs` option.
902
    """
903

904
    help = generate_multiple_sources_field_help_message("Example: `sources=['example.lock']`")
12✔
905

906
    def path_globs(self, unmatched_build_file_globs: UnmatchedBuildFileGlobs) -> PathGlobs:  # type: ignore[misc]
12✔
907
        return super().path_globs(UnmatchedBuildFileGlobs.ignore())
1✔
908

909

910
class LockfilesGeneratorTarget(TargetFilesGenerator):
12✔
911
    alias = "_lockfiles"
12✔
912
    core_fields = (
12✔
913
        *COMMON_TARGET_FIELDS,
914
        LockfilesGeneratorSourcesField,
915
    )
916
    generated_target_cls = LockfileTarget
12✔
917
    copied_fields = COMMON_TARGET_FIELDS
12✔
918
    moved_fields = (LockfileDependenciesField,)
12✔
919
    help = "Generate a `_lockfile` target for each file in the `sources` field."
12✔
920

921

922
# -----------------------------------------------------------------------------------------------
923
#  Resolve-like fields
924
# -----------------------------------------------------------------------------------------------
925

926

927
@union(in_scope_types=[EnvironmentName])
12✔
928
@dataclass(frozen=True)
12✔
929
class ResolveLikeFieldToValueRequest:
12✔
930
    target: Target
12✔
931

932

933
@dataclass(frozen=True)
12✔
934
class ResolveLikeFieldToValueResult:
12✔
935
    """Result of resolving a resolve-like field to the resolve name as a string.
936

937
    The value will be the actual resolve name (e.g., "python-default", "jvm-default"), or None if
938
    the language backend has disabled resolves (in which case all targets should be treated as
939
    belonging to a single implicit resolve).
940
    """
941

942
    value: str | None
12✔
943

944

945
@rule(polymorphic=True)
12✔
946
async def get_resolve_from_resolve_like_field_request(
12✔
947
    request: ResolveLikeFieldToValueRequest,
948
) -> ResolveLikeFieldToValueResult:
NEW
949
    raise NotImplementedError()
×
950

951

952
class ResolveLikeField:
12✔
953
    """Mix-in for any field which behaves like a `resolve` field."""
954

955
    def get_resolve_like_field_to_value_request(self) -> type[ResolveLikeFieldToValueRequest]:
12✔
956
        """Return a `ResolveLikeFieldToValueRequest` subclass which can be used to obtain a string
957
        field value."""
NEW
958
        raise NotImplementedError()
×
959

960

961
def rules():
12✔
962
    return (
12✔
963
        *collect_rules(),
964
        *archive_rules(),
965
        *package.rules(),
966
        UnionRule(GenerateSourcesRequest, GenerateResourceSourceRequest),
967
        UnionRule(GenerateSourcesRequest, GenerateFileSourceRequest),
968
        UnionRule(GenerateSourcesRequest, RelocateFilesViaCodegenRequest),
969
        UnionRule(PackageFieldSet, ArchiveFieldSet),
970
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc