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

pantsbuild / pants / 18252174847

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

push

github

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

Just doing the minimal to pull forward the x86_64 pattern.

ref #20993

25776 of 59416 relevant lines covered (43.38%)

1.3 hits per line

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

70.07
/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
3✔
4

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

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

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

76

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

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

84
    Expected usage is roughly:
85

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

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

100
            return raw_value
101

102
    ...
103

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

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

111
        ...
112
    ```
113

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

121
    linux_arm64: _T | None = None
3✔
122
    linux_x86_64: _T | None = None
3✔
123
    macos_arm64: _T | None = None
3✔
124
    macos_x86_64: _T | None = None
3✔
125

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

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

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

156

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

161

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

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

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

183
        self.__post_init__()
×
184

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

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

201

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

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

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

216
        `http_source` has the following signature:
217

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

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

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

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

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

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

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

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

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

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

292

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

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

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

322
    return GeneratedSources(snapshot)
×
323

324

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

331

332
class FileDependenciesField(Dependencies):
3✔
333
    pass
3✔
334

335

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

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

349

350
class GenerateFileSourceRequest(GenerateSourcesRequest):
3✔
351
    input = FileSourceField
3✔
352
    output = FileSourceField
3✔
353

354

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

361

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

369

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

382

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

395

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

400

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

406

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

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

420

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

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

433

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

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

446

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

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

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

467
        To remove a prefix:
468

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

476
        To add a prefix:
477

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

485
        To replace a prefix:
486

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

496

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

502

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

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

543

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

548

549
class ResourceDependenciesField(Dependencies):
3✔
550
    pass
3✔
551

552

553
class ResourceSourceField(AssetSourceField):
3✔
554
    uses_source_roots = True
3✔
555

556

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

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

571

572
class GenerateResourceSourceRequest(GenerateSourcesRequest):
3✔
573
    input = ResourceSourceField
3✔
574
    output = ResourceSourceField
3✔
575

576

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

583

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

590

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

603

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

616

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

621
    sources: ResourceSourceField
3✔
622

623

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

628
    sources: ResourcesGeneratingSourcesField
3✔
629

630

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

635

636
class GenericTargetDependenciesField(Dependencies):
3✔
637
    pass
3✔
638

639

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

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

652

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

657

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

663

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

675

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

681

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

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

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

699

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

704

705
class TargetGeneratorSourcesHelperSourcesField(SingleSourceField):
3✔
706
    uses_source_roots = False
3✔
707
    required = True
3✔
708

709

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

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

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

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

730

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

735

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

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

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

752

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

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

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

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

772

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

780

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

792

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

797
    packages: ArchivePackagesField
3✔
798
    files: ArchiveFilesField
3✔
799
    format_field: ArchiveFormatField
3✔
800
    output_path: OutputPathField
3✔
801

802

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

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

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

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

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

858

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

863

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

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

871
    uses_source_roots = False
3✔
872
    required = True
3✔
873
    value: str
3✔
874

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

878

879
class LockfileDependenciesField(Dependencies):
3✔
880
    pass
3✔
881

882

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

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

895

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

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

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

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

908

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

920

921
def rules():
3✔
922
    return (
3✔
923
        *collect_rules(),
924
        *archive_rules(),
925
        *package.rules(),
926
        UnionRule(GenerateSourcesRequest, GenerateResourceSourceRequest),
927
        UnionRule(GenerateSourcesRequest, GenerateFileSourceRequest),
928
        UnionRule(GenerateSourcesRequest, RelocateFilesViaCodegenRequest),
929
        UnionRule(PackageFieldSet, ArchiveFieldSet),
930
    )
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