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

pantsbuild / pants / 20632486505

01 Jan 2026 04:21AM UTC coverage: 43.231% (-37.1%) from 80.281%
20632486505

Pull #22962

github

web-flow
Merge 08d5c63b0 into f52ab6675
Pull Request #22962: Bump the gha-deps group across 1 directory with 6 updates

26122 of 60424 relevant lines covered (43.23%)

0.86 hits per line

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

38.23
/src/python/pants/jvm/resolve/coursier_fetch.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
2✔
5

6
import dataclasses
2✔
7
import importlib.resources
2✔
8
import itertools
2✔
9
import json
2✔
10
import logging
2✔
11
import os
2✔
12
from collections import defaultdict
2✔
13
from collections.abc import Iterable, Iterator
2✔
14
from dataclasses import dataclass
2✔
15
from itertools import chain
2✔
16
from typing import TYPE_CHECKING, Any
2✔
17

18
import toml
2✔
19

20
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
2✔
21
from pants.core.goals.generate_lockfiles import DEFAULT_TOOL_LOCKFILE, GenerateLockfilesSubsystem
2✔
22
from pants.core.util_rules.source_files import SourceFilesRequest, determine_source_files
2✔
23
from pants.engine.addresses import UnparsedAddressInputs
2✔
24
from pants.engine.collection import Collection
2✔
25
from pants.engine.fs import (
2✔
26
    AddPrefix,
27
    CreateDigest,
28
    Digest,
29
    DigestSubset,
30
    FileContent,
31
    FileDigest,
32
    MergeDigests,
33
    PathGlobs,
34
    RemovePrefix,
35
    Snapshot,
36
)
37
from pants.engine.internals.graph import resolve_targets
2✔
38
from pants.engine.internals.native_engine import EMPTY_DIGEST
2✔
39
from pants.engine.intrinsics import (
2✔
40
    create_digest,
41
    digest_subset_to_digest,
42
    digest_to_snapshot,
43
    get_digest_contents,
44
    merge_digests,
45
    path_globs_to_digest,
46
    remove_prefix,
47
)
48
from pants.engine.process import fallible_to_exec_result_or_raise
2✔
49
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
2✔
50
from pants.engine.target import CoarsenedTargets, Target
2✔
51
from pants.engine.unions import UnionRule
2✔
52
from pants.jvm.compile import (
2✔
53
    ClasspathEntry,
54
    ClasspathEntryRequest,
55
    CompileResult,
56
    FallibleClasspathEntry,
57
)
58
from pants.jvm.resolve import coursier_setup
2✔
59
from pants.jvm.resolve.common import (
2✔
60
    ArtifactRequirement,
61
    ArtifactRequirements,
62
    GatherJvmCoordinatesRequest,
63
)
64
from pants.jvm.resolve.coordinate import Coordinate, Coordinates
2✔
65
from pants.jvm.resolve.coursier_setup import Coursier, CoursierFetchProcess
2✔
66
from pants.jvm.resolve.jvm_tool import gather_coordinates_for_jvm_lockfile
2✔
67
from pants.jvm.resolve.key import CoursierResolveKey
2✔
68
from pants.jvm.resolve.lockfile_metadata import JVMLockfileMetadata, LockfileContext
2✔
69
from pants.jvm.subsystems import JvmSubsystem
2✔
70
from pants.jvm.target_types import (
2✔
71
    JvmArtifactFieldSet,
72
    JvmArtifactJarSourceField,
73
    JvmArtifactTarget,
74
    JvmResolveField,
75
)
76
from pants.jvm.util_rules import ExtractFileDigest, digest_to_file_digest
2✔
77
from pants.util.docutil import bin_name, doc_url
2✔
78
from pants.util.logging import LogLevel
2✔
79
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
2✔
80
from pants.util.strutil import bullet_list, pluralize
2✔
81

82
if TYPE_CHECKING:
83
    from pants.jvm.resolve.jvm_tool import GenerateJvmLockfileFromTool
84

85
logger = logging.getLogger(__name__)
2✔
86

87

88
class CoursierFetchRequest(ClasspathEntryRequest):
2✔
89
    field_sets = (JvmArtifactFieldSet,)
2✔
90

91

92
class CoursierError(Exception):
2✔
93
    """An exception relating to invoking Coursier or processing its output."""
94

95

96
class NoCompatibleResolve(Exception):
2✔
97
    """No compatible resolve could be found for a set of targets."""
98

99
    def __init__(self, jvm: JvmSubsystem, msg_prefix: str, relevant_targets: Iterable[Target]):
2✔
100
        resolves_to_addresses = defaultdict(list)
×
101
        for tgt in relevant_targets:
×
102
            if tgt.has_field(JvmResolveField):
×
103
                resolve = tgt[JvmResolveField].normalized_value(jvm)
×
104
                resolves_to_addresses[resolve].append(tgt.address.spec)
×
105

106
        formatted_resolve_lists = "\n\n".join(
×
107
            f"{resolve}:\n{bullet_list(sorted(addresses))}"
108
            for resolve, addresses in sorted(resolves_to_addresses.items())
109
        )
110
        super().__init__(
×
111
            f"{msg_prefix}:\n\n"
112
            f"{formatted_resolve_lists}\n\n"
113
            "Targets which will be merged onto the same classpath must share a resolve (from the "
114
            f"[resolve]({doc_url('reference/targets/deploy_jar#resolve')}) field)."
115
        )
116

117

118
@dataclass(frozen=True)
2✔
119
class CoursierLockfileEntry:
2✔
120
    """A single artifact entry from a Coursier-resolved lockfile.
121

122
    These fields are nearly identical to the JSON objects from the
123
    "dependencies" entries in Coursier's --json-output-file format.
124
    But unlike Coursier's JSON report, a CoursierLockfileEntry
125
    includes the content-address of the artifact fetched by Coursier
126
    and ingested by Pants.
127

128
    For example, a Coursier JSON report dependency entry might look like this:
129

130
    ```
131
    {
132
      "coord": "com.chuusai:shapeless_2.13:2.3.3",
133
      "file": "/home/USER/.cache/coursier/v1/https/repo1.maven.org/maven2/com/chuusai/shapeless_2.13/2.3.3/shapeless_2.13-2.3.3.jar",
134
      "directDependencies": [
135
        "org.scala-lang:scala-library:2.13.0"
136
      ],
137
      "dependencies": [
138
        "org.scala-lang:scala-library:2.13.0"
139
      ]
140
    }
141
    ```
142

143
    The equivalent CoursierLockfileEntry would look like this:
144

145
    ```
146
    CoursierLockfileEntry(
147
        coord="com.chuusai:shapeless_2.13:2.3.3", # identical
148
        file_name="shapeless_2.13-2.3.3.jar" # PurePath(entry["file"].name)
149
        direct_dependencies=(Coordinate.from_coord_str("org.scala-lang:scala-library:2.13.0"),),
150
        dependencies=(Coordinate.from_coord_str("org.scala-lang:scala-library:2.13.0"),),
151
        file_digest=FileDigest(fingerprint=<sha256 of the jar>, ...),
152
    )
153
    ```
154

155
    The fields `remote_url` and `pants_address` are set by Pants if the `coord` field matches a
156
    `jvm_artifact` that had either the `url` or `jar` fields set.
157
    """
158

159
    coord: Coordinate
2✔
160
    file_name: str
2✔
161
    direct_dependencies: Coordinates
2✔
162
    dependencies: Coordinates
2✔
163
    file_digest: FileDigest
2✔
164
    remote_url: str | None = None
2✔
165
    pants_address: str | None = None
2✔
166

167
    @classmethod
2✔
168
    def from_json_dict(cls, entry) -> CoursierLockfileEntry:
2✔
169
        """Construct a CoursierLockfileEntry from its JSON dictionary representation."""
170

171
        return cls(
×
172
            coord=Coordinate.from_json_dict(entry["coord"]),
173
            file_name=entry["file_name"],
174
            direct_dependencies=Coordinates(
175
                Coordinate.from_json_dict(d) for d in entry["directDependencies"]
176
            ),
177
            dependencies=Coordinates(Coordinate.from_json_dict(d) for d in entry["dependencies"]),
178
            file_digest=FileDigest(
179
                fingerprint=entry["file_digest"]["fingerprint"],
180
                serialized_bytes_length=entry["file_digest"]["serialized_bytes_length"],
181
            ),
182
            remote_url=entry.get("remote_url"),
183
            pants_address=entry.get("pants_address"),
184
        )
185

186
    def to_json_dict(self) -> dict[str, Any]:
2✔
187
        """Export this CoursierLockfileEntry to a JSON object."""
188

189
        return dict(
×
190
            coord=self.coord.to_json_dict(),
191
            directDependencies=[coord.to_json_dict() for coord in self.direct_dependencies],
192
            dependencies=[coord.to_json_dict() for coord in self.dependencies],
193
            file_name=self.file_name,
194
            file_digest=dict(
195
                fingerprint=self.file_digest.fingerprint,
196
                serialized_bytes_length=self.file_digest.serialized_bytes_length,
197
            ),
198
            remote_url=self.remote_url,
199
            pants_address=self.pants_address,
200
        )
201

202

203
@dataclass(frozen=True)
2✔
204
class CoursierResolvedLockfile:
2✔
205
    """An in-memory representation of Pants' Coursier lockfile format.
206

207
    All coordinates in the resolved lockfile will be compatible, so we do not need to do version
208
    testing when looking up coordinates.
209
    """
210

211
    entries: tuple[CoursierLockfileEntry, ...]
2✔
212
    metadata: JVMLockfileMetadata | None = None
2✔
213

214
    @classmethod
2✔
215
    def _coordinate_not_found(cls, key: CoursierResolveKey, coord: Coordinate) -> CoursierError:
2✔
216
        # TODO: After fixing https://github.com/pantsbuild/pants/issues/13496, coordinate matches
217
        # should become exact, and this error message will capture all cases of stale lockfiles.
218
        return CoursierError(
×
219
            f"{coord} was not present in resolve `{key.name}` at `{key.path}`.\n"
220
            f"If you have recently added new `{JvmArtifactTarget.alias}` targets, you might "
221
            f"need to update your lockfile by running `coursier-resolve --names={key.name}`."
222
        )
223

224
    def direct_dependencies(
2✔
225
        self, key: CoursierResolveKey, coord: Coordinate
226
    ) -> tuple[CoursierLockfileEntry, tuple[CoursierLockfileEntry, ...]]:
227
        """Return the entry for the given Coordinate, and for its direct dependencies."""
228
        entries = {(i.coord.group, i.coord.artifact, i.coord.classifier): i for i in self.entries}
×
229
        entry = entries.get((coord.group, coord.artifact, coord.classifier))
×
230
        if entry is None:
×
231
            raise self._coordinate_not_found(key, coord)
×
232

233
        return (
×
234
            entry,
235
            tuple(entries[(i.group, i.artifact, i.classifier)] for i in entry.direct_dependencies),
236
        )
237

238
    def dependencies(
2✔
239
        self, key: CoursierResolveKey, coord: Coordinate
240
    ) -> tuple[CoursierLockfileEntry, tuple[CoursierLockfileEntry, ...]]:
241
        """Return the entry for the given Coordinate, and for its transitive dependencies."""
242
        entries = {(i.coord.group, i.coord.artifact, i.coord.classifier): i for i in self.entries}
×
243
        entry = entries.get((coord.group, coord.artifact, coord.classifier))
×
244
        if entry is None:
×
245
            raise self._coordinate_not_found(key, coord)
×
246

247
        return (
×
248
            entry,
249
            tuple(
250
                entries[(d.group, d.artifact, d.classifier)]
251
                for d in entry.dependencies
252
                # Coursier will pass "pom" coords through to us. These coords don't have
253
                # a coords entry, but all of their relevant dependencies have already been taken into account
254
                # and will appear in the dependencies list
255
                if d.classifier != "pom"
256
            ),
257
        )
258

259
    @classmethod
2✔
260
    def from_toml(cls, lockfile: str | bytes) -> CoursierResolvedLockfile:
2✔
261
        """Constructs a CoursierResolvedLockfile from it's TOML + metadata comment representation.
262

263
        The toml file should consist of an `[entries]` block, followed by several entries.
264
        """
265

266
        lockfile_str: str
267
        lockfile_bytes: bytes
268
        if isinstance(lockfile, str):
×
269
            lockfile_str = lockfile
×
270
            lockfile_bytes = lockfile.encode("utf-8")
×
271
        else:
272
            lockfile_str = lockfile.decode("utf-8")
×
273
            lockfile_bytes = lockfile
×
274

275
        contents = toml.loads(lockfile_str)
×
276
        entries = tuple(
×
277
            CoursierLockfileEntry.from_json_dict(entry) for entry in (contents["entries"])
278
        )
279
        metadata = JVMLockfileMetadata.from_lockfile(lockfile_bytes, delimeter="#")
×
280

281
        return cls(
×
282
            entries=entries,
283
            metadata=metadata,
284
        )
285

286
    @classmethod
2✔
287
    def from_serialized(cls, lockfile: str | bytes) -> CoursierResolvedLockfile:
2✔
288
        """Construct a CoursierResolvedLockfile from its serialized representation (either TOML with
289
        attached metadata, or old-style JSON.)."""
290

291
        return cls.from_toml(lockfile)
×
292

293
    def to_serialized(self) -> bytes:
2✔
294
        """Export this CoursierResolvedLockfile to a human-readable serialized form.
295

296
        This serialized form is intended to be checked in to the user's repo as a hermetic snapshot
297
        of a Coursier resolved JVM classpath.
298
        """
299

300
        lockfile = {
×
301
            "entries": [entry.to_json_dict() for entry in self.entries],
302
        }
303

304
        return toml.dumps(lockfile).encode("utf-8")
×
305

306

307
def classpath_dest_filename(coord: str, src_filename: str) -> str:
2✔
308
    """Calculates the destination filename on the classpath for the given source filename and coord.
309

310
    TODO: This is duplicated in `COURSIER_POST_PROCESSING_SCRIPT`.
311
    """
312
    dest_name = coord.replace(":", "_")
×
313
    _, ext = os.path.splitext(src_filename)
×
314
    return f"{dest_name}{ext}"
×
315

316

317
@dataclass(frozen=True)
2✔
318
class CoursierResolveInfo:
2✔
319
    coord_arg_strings: FrozenOrderedSet[str]
2✔
320
    force_version_coord_arg_strings: FrozenOrderedSet[str]
2✔
321
    extra_args: tuple[str, ...]
2✔
322
    digest: Digest
2✔
323

324
    @property
2✔
325
    def argv(self) -> Iterable[str]:
2✔
326
        """Return coursier arguments that can be used to compute or fetch this resolve.
327

328
        Must be used in concert with `digest`.
329
        """
330
        return itertools.chain(
×
331
            self.coord_arg_strings,
332
            itertools.chain.from_iterable(
333
                zip(itertools.repeat("--force-version"), self.force_version_coord_arg_strings)
334
            ),
335
            self.extra_args,
336
        )
337

338

339
@rule
2✔
340
async def prepare_coursier_resolve_info(
2✔
341
    artifact_requirements: ArtifactRequirements,
342
) -> CoursierResolveInfo:
343
    # Transform requirements that correspond to local JAR files into coordinates with `file:/`
344
    # URLs, and put the files in the place specified by the URLs.
345
    no_jars: list[ArtifactRequirement] = []
×
346
    jars: list[tuple[ArtifactRequirement, JvmArtifactJarSourceField]] = []
×
347
    extra_args: list[str] = []
×
348

349
    LOCAL_EXCLUDE_FILE = "PANTS_RESOLVE_EXCLUDES"
×
350

351
    for req in artifact_requirements:
×
352
        jar = req.jar
×
353
        if not jar:
×
354
            no_jars.append(req)
×
355
        else:
356
            jars.append((req, jar))
×
357

358
    excludes = [
×
359
        (req.coordinate, exclude)
360
        for req in artifact_requirements
361
        for exclude in (req.excludes or [])
362
    ]
363

364
    excludes_digest = EMPTY_DIGEST
×
365
    if excludes:
×
366
        excludes_file_content = FileContent(
×
367
            LOCAL_EXCLUDE_FILE,
368
            "\n".join(
369
                f"{coord.group}:{coord.artifact}--{exclude}" for (coord, exclude) in excludes
370
            ).encode("utf-8"),
371
        )
372
        excludes_digest = await create_digest(CreateDigest([excludes_file_content]))
×
373
        extra_args += ["--local-exclude-file", LOCAL_EXCLUDE_FILE]
×
374

375
    jar_file_sources = await concurrently(
×
376
        determine_source_files(SourceFilesRequest([jar_source_field]))
377
        for _, jar_source_field in jars
378
    )
379
    jar_file_paths = [jar_file_source.snapshot.files[0] for jar_file_source in jar_file_sources]
×
380

381
    resolvable_jar_requirements = [
×
382
        dataclasses.replace(
383
            req, jar=None, url=f"file:{Coursier.working_directory_placeholder}/{path}"
384
        )
385
        for (req, _), path in zip(jars, jar_file_paths)
386
    ]
387

388
    # Coursier only fetches non-jar artifact types ("packaging" in Pants parlance) if passed an `-A` option
389
    # explicitly requesting that the non-jar artifact(s) be fetched. This is an addition to passing the coordinate
390
    # with the desired type (packaging) value.
391
    extra_types: set[str] = set()
×
392
    for no_jar in no_jars:
×
393
        if no_jar.coordinate.packaging != "jar":
×
394
            extra_types.add(no_jar.coordinate.packaging)
×
395
    if extra_types:
×
396
        # Note: `-A` defaults to `jar,bundle` and any value set replaces (and does not supplement) those defaults,
397
        # so the defaults must be included here for them to remain usable.
398
        extra_args.extend(["-A", ",".join(sorted(["jar", "bundle", *extra_types]))])
×
399

400
    to_resolve = chain(no_jars, resolvable_jar_requirements)
×
401

402
    digest = await merge_digests(
×
403
        MergeDigests(
404
            [
405
                *(jar_file_source.snapshot.digest for jar_file_source in jar_file_sources),
406
                excludes_digest,
407
            ]
408
        )
409
    )
410

411
    coord_arg_strings: OrderedSet[str] = OrderedSet()
×
412
    force_version_coord_arg_strings: OrderedSet[str] = OrderedSet()
×
413
    for req in sorted(to_resolve, key=lambda ar: ar.coordinate):
×
414
        coord_arg_str = req.to_coord_arg_str()
×
415
        coord_arg_strings.add(coord_arg_str)
×
416
        if req.force_version:
×
417
            force_version_coord_arg_strings.add(coord_arg_str)
×
418

419
    return CoursierResolveInfo(
×
420
        coord_arg_strings=FrozenOrderedSet(coord_arg_strings),
421
        force_version_coord_arg_strings=FrozenOrderedSet(force_version_coord_arg_strings),
422
        digest=digest,
423
        extra_args=tuple(extra_args),
424
    )
425

426

427
@rule(level=LogLevel.DEBUG)
2✔
428
async def coursier_resolve_lockfile(
2✔
429
    artifact_requirements: ArtifactRequirements,
430
) -> CoursierResolvedLockfile:
431
    """Run `coursier fetch ...` against a list of Maven coordinates and capture the result.
432

433
    This rule does two things in a single Process invocation:
434

435
        * Runs `coursier fetch` to let Coursier do the heavy lifting of resolving
436
          dependencies and downloading resolved artifacts (jars, etc).
437
        * Copies the resolved artifacts into the Process output directory, capturing
438
          the artifacts as content-addressed `Digest`s.
439

440
    It's important that this happens in the same process, since the process isn't
441
    guaranteed to run on the same machine as the rule, nor is a subsequent process
442
    invocation.  This guarantees that whatever Coursier resolved, it was fully
443
    captured into Pants' content addressed artifact storage.
444

445
    Note however that we still get the benefit of Coursier's "global" cache if it
446
    had already been run on the machine where the `coursier fetch` runs, so rerunning
447
    `coursier fetch` tends to be fast in practice.
448

449
    Finally, this rule bundles up the result into a `CoursierResolvedLockfile`.  This
450
    data structure encapsulates everything necessary to either materialize the
451
    resolved dependencies to a classpath for Java invocations, or to write the
452
    lockfile out to the workspace to hermetically freeze the result of the resolve.
453
    """
454

455
    if len(artifact_requirements) == 0:
×
456
        return CoursierResolvedLockfile(entries=())
×
457

458
    coursier_resolve_info = await prepare_coursier_resolve_info(artifact_requirements)
×
459

460
    coursier_report_file_name = "coursier_report.json"
×
461

462
    process_result = await fallible_to_exec_result_or_raise(
×
463
        **implicitly(
464
            CoursierFetchProcess(
465
                args=(
466
                    coursier_report_file_name,
467
                    *coursier_resolve_info.argv,
468
                ),
469
                input_digest=coursier_resolve_info.digest,
470
                output_directories=("classpath",),
471
                output_files=(coursier_report_file_name,),
472
                description=(
473
                    "Running `coursier fetch` against "
474
                    f"{pluralize(len(artifact_requirements), 'requirement')}: "
475
                    f"{', '.join(req.to_coord_arg_str() for req in artifact_requirements)}"
476
                ),
477
            )
478
        )
479
    )
480

481
    report_digest = await digest_subset_to_digest(
×
482
        DigestSubset(process_result.output_digest, PathGlobs([coursier_report_file_name]))
483
    )
484
    report_contents = await get_digest_contents(report_digest)
×
485
    report = json.loads(report_contents[0].content)
×
486

487
    artifact_file_names = tuple(
×
488
        classpath_dest_filename(dep["coord"], dep["file"]) for dep in report["dependencies"]
489
    )
490
    artifact_output_paths = tuple(f"classpath/{file_name}" for file_name in artifact_file_names)
×
491
    artifact_digests = await concurrently(
×
492
        digest_subset_to_digest(
493
            DigestSubset(process_result.output_digest, PathGlobs([output_path]))
494
        )
495
        for output_path in artifact_output_paths
496
    )
497
    stripped_artifact_digests = await concurrently(
×
498
        remove_prefix(RemovePrefix(artifact_digest, "classpath"))
499
        for artifact_digest in artifact_digests
500
    )
501
    artifact_file_digests = await concurrently(
×
502
        digest_to_file_digest(ExtractFileDigest(stripped_artifact_digest, file_name))
503
        for stripped_artifact_digest, file_name in zip(
504
            stripped_artifact_digests, artifact_file_names
505
        )
506
    )
507

508
    first_pass_lockfile = CoursierResolvedLockfile(
×
509
        entries=tuple(
510
            CoursierLockfileEntry(
511
                coord=Coordinate.from_coord_str(dep["coord"]),
512
                direct_dependencies=Coordinates(
513
                    Coordinate.from_coord_str(dd) for dd in dep["directDependencies"]
514
                ),
515
                dependencies=Coordinates(Coordinate.from_coord_str(d) for d in dep["dependencies"]),
516
                file_name=file_name,
517
                file_digest=artifact_file_digest,
518
            )
519
            for dep, file_name, artifact_file_digest in zip(
520
                report["dependencies"], artifact_file_names, artifact_file_digests
521
            )
522
        )
523
    )
524

525
    inverted_artifacts = {req.coordinate: req for req in artifact_requirements}
×
526
    new_entries = []
×
527
    for entry in first_pass_lockfile.entries:
×
528
        req = inverted_artifacts.get(entry.coord)
×
529
        if req:
×
530
            address = req.jar.address if req.jar else None
×
531
            address_spec = address.spec if address else None
×
532
            entry = dataclasses.replace(entry, remote_url=req.url, pants_address=address_spec)
×
533
        new_entries.append(entry)
×
534

535
    return CoursierResolvedLockfile(entries=tuple(new_entries))
×
536

537

538
@rule
2✔
539
async def get_coursier_lockfile_for_resolve(
2✔
540
    coursier_resolve: CoursierResolveKey,
541
) -> CoursierResolvedLockfile:
542
    lockfile_digest_contents = await get_digest_contents(coursier_resolve.digest)
×
543
    lockfile_contents = lockfile_digest_contents[0].content
×
544
    return CoursierResolvedLockfile.from_serialized(lockfile_contents)
×
545

546

547
class ResolvedClasspathEntries(Collection[ClasspathEntry]):
2✔
548
    """A collection of resolved classpath entries."""
549

550

551
@rule
2✔
552
async def coursier_fetch_one_coord(
2✔
553
    request: CoursierLockfileEntry,
554
) -> ClasspathEntry:
555
    """Run `coursier fetch --intransitive` to fetch a single artifact.
556

557
    This rule exists to permit efficient subsetting of a "global" classpath
558
    in the form of a lockfile.  Callers can determine what subset of dependencies
559
    from the lockfile are needed for a given target, then request those
560
    lockfile entries individually.
561

562
    By fetching only one entry at a time, we maximize our cache efficiency.  If instead
563
    we fetched the entire subset that the caller wanted, there would be a different cache
564
    key for every possible subset.
565

566
    This rule also guarantees exact reproducibility.  If all caches have been
567
    removed, `coursier fetch` will re-download the artifact, and this rule will
568
    confirm that what was downloaded matches exactly (by content digest) what
569
    was specified in the lockfile (what Coursier originally downloaded).
570
    """
571

572
    # Prepare any URL- or JAR-specifying entries for use with Coursier
573
    req: ArtifactRequirement
574
    if request.pants_address:
×
575
        targets = await resolve_targets(
×
576
            **implicitly(
577
                UnparsedAddressInputs(
578
                    [request.pants_address],
579
                    owning_address=None,
580
                    description_of_origin="<infallible - coursier fetch>",
581
                )
582
            )
583
        )
584
        req = ArtifactRequirement(request.coord, jar=targets[0][JvmArtifactJarSourceField])
×
585
    else:
586
        req = ArtifactRequirement(request.coord, url=request.remote_url)
×
587

588
    coursier_resolve_info = await prepare_coursier_resolve_info(ArtifactRequirements([req]))
×
589

590
    coursier_report_file_name = "coursier_report.json"
×
591

592
    process_result = await fallible_to_exec_result_or_raise(
×
593
        **implicitly(
594
            CoursierFetchProcess(
595
                args=(
596
                    coursier_report_file_name,
597
                    "--intransitive",
598
                    *coursier_resolve_info.argv,
599
                ),
600
                input_digest=coursier_resolve_info.digest,
601
                output_directories=("classpath",),
602
                output_files=(coursier_report_file_name,),
603
                description=f"Fetching with coursier: {request.coord.to_coord_str()}",
604
            )
605
        )
606
    )
607
    report_digest = await digest_subset_to_digest(
×
608
        DigestSubset(process_result.output_digest, PathGlobs([coursier_report_file_name]))
609
    )
610
    report_contents = await get_digest_contents(report_digest)
×
611
    report = json.loads(report_contents[0].content)
×
612

613
    report_deps = report["dependencies"]
×
614
    if len(report_deps) == 0:
×
615
        raise CoursierError("Coursier fetch report has no dependencies (i.e. nothing was fetched).")
×
616
    elif len(report_deps) > 1:
×
617
        raise CoursierError(
×
618
            "Coursier fetch report has multiple dependencies, but exactly 1 was expected."
619
        )
620

621
    dep = report_deps[0]
×
622
    resolved_coord = Coordinate.from_coord_str(dep["coord"])
×
623
    if resolved_coord != request.coord:
×
624
        raise CoursierError(
×
625
            f'Coursier resolved coord "{resolved_coord.to_coord_str()}" does not match requested coord "{request.coord.to_coord_str()}".'
626
        )
627

628
    classpath_dest_name = classpath_dest_filename(dep["coord"], dep["file"])
×
629
    classpath_dest = f"classpath/{classpath_dest_name}"
×
630

631
    resolved_file_digest = await digest_subset_to_digest(
×
632
        DigestSubset(process_result.output_digest, PathGlobs([classpath_dest]))
633
    )
634
    stripped_digest = await remove_prefix(RemovePrefix(resolved_file_digest, "classpath"))
×
635
    file_digest = await digest_to_file_digest(
×
636
        ExtractFileDigest(stripped_digest, classpath_dest_name)
637
    )
638
    if file_digest != request.file_digest:
×
639
        raise CoursierError(
×
640
            f"Coursier fetch for '{resolved_coord}' succeeded, but fetched artifact {file_digest} did not match the expected artifact: {request.file_digest}."
641
        )
642
    return ClasspathEntry(digest=stripped_digest, filenames=(classpath_dest_name,))
×
643

644

645
@rule(desc="Fetch with coursier")
2✔
646
async def fetch_with_coursier(request: CoursierFetchRequest) -> FallibleClasspathEntry:
2✔
647
    # TODO: Loading this per JvmArtifact.
648
    lockfile = await get_coursier_lockfile_for_resolve(request.resolve)
×
649

650
    requirement = ArtifactRequirement.from_jvm_artifact_target(request.component.representative)
×
651

652
    if lockfile.metadata and not lockfile.metadata.is_valid_for(
×
653
        [requirement], LockfileContext.USER
654
    ):
655
        raise ValueError(
×
656
            f"Requirement `{requirement.to_coord_arg_str()}` has changed since the lockfile "
657
            f"for {request.resolve.path} was generated. Run `{bin_name()} generate-lockfiles` to update your "
658
            "lockfile based on the new requirements."
659
        )
660

661
    # All of the transitive dependencies are exported.
662
    # TODO: Expose an option to control whether this exports only the root, direct dependencies,
663
    # transitive dependencies, etc.
664
    assert len(request.component.members) == 1, "JvmArtifact does not have dependencies."
×
665
    root_entry, transitive_entries = lockfile.dependencies(
×
666
        request.resolve,
667
        requirement.coordinate,
668
    )
669

670
    classpath_entries = await concurrently(
×
671
        coursier_fetch_one_coord(entry) for entry in (root_entry, *transitive_entries)
672
    )
673
    exported_digest = await merge_digests(MergeDigests(cpe.digest for cpe in classpath_entries))
×
674

675
    return FallibleClasspathEntry(
×
676
        description=str(request.component),
677
        result=CompileResult.SUCCEEDED,
678
        output=ClasspathEntry.merge(exported_digest, classpath_entries),
679
        exit_code=0,
680
    )
681

682

683
@rule(level=LogLevel.DEBUG)
2✔
684
async def coursier_fetch_lockfile(lockfile: CoursierResolvedLockfile) -> ResolvedClasspathEntries:
2✔
685
    """Fetch every artifact in a lockfile."""
686
    classpath_entries = await concurrently(
×
687
        coursier_fetch_one_coord(entry) for entry in lockfile.entries
688
    )
689
    return ResolvedClasspathEntries(classpath_entries)
×
690

691

692
@rule
2✔
693
async def select_coursier_resolve_for_targets(
2✔
694
    coarsened_targets: CoarsenedTargets, jvm: JvmSubsystem
695
) -> CoursierResolveKey:
696
    """Selects and validates (transitively) a single resolve for a set of roots in a compile graph.
697

698
    In most cases, a `CoursierResolveKey` should be requested for a single `CoarsenedTarget` root,
699
    which avoids coupling un-related roots unnecessarily. But in other cases, a single compatible
700
    resolve is required for multiple roots (such as when running a `repl` over unrelated code), and
701
    in that case there might be multiple CoarsenedTargets.
702
    """
703
    targets = list(coarsened_targets.closure())
×
704

705
    # Find a single resolve that is compatible with all targets in the closure.
706
    compatible_resolve: str | None = None
×
707
    all_compatible = True
×
708
    for tgt in targets:
×
709
        if not tgt.has_field(JvmResolveField):
×
710
            continue
×
711
        resolve = tgt[JvmResolveField].normalized_value(jvm)
×
712
        if compatible_resolve is None:
×
713
            compatible_resolve = resolve
×
714
        elif resolve != compatible_resolve:
×
715
            all_compatible = False
×
716

717
    if not all_compatible:
×
718
        raise NoCompatibleResolve(
×
719
            jvm, "The selected targets did not have a resolve in common", targets
720
        )
721
    resolve = compatible_resolve or jvm.default_resolve
×
722

723
    # Load the resolve.
724
    resolve_path = jvm.resolves[resolve]
×
725
    lockfile_source = PathGlobs(
×
726
        [resolve_path],
727
        glob_match_error_behavior=GlobMatchErrorBehavior.error,
728
        description_of_origin=f"The resolve `{resolve}` from `[jvm].resolves`",
729
    )
730
    resolve_digest = await path_globs_to_digest(lockfile_source)
×
731
    return CoursierResolveKey(resolve, resolve_path, resolve_digest)
×
732

733

734
@dataclass(frozen=True)
2✔
735
class ToolClasspathRequest:
2✔
736
    """A request to set up the classpath for a JVM tool by fetching artifacts and merging the
737
    classpath.
738

739
    :param prefix: if set, should be a relative directory that will
740
        be prepended to every classpath element.  This is useful for
741
        keeping all classpath elements isolated under a single directory
742
        in a process invocation, where other inputs on the process's
743
        root directory might interfere with un-prefixed classpath
744
        entries (or vice versa).
745
    """
746

747
    prefix: str | None = None
2✔
748
    lockfile: GenerateJvmLockfileFromTool | None = None
2✔
749
    artifact_requirements: ArtifactRequirements = ArtifactRequirements()
2✔
750

751
    def __post_init__(self) -> None:
2✔
752
        if not bool(self.lockfile) ^ bool(self.artifact_requirements):
×
753
            raise AssertionError(
×
754
                f"Exactly one of `lockfile` or `artifact_requirements` must be provided: {self}"
755
            )
756

757

758
@dataclass(frozen=True)
2✔
759
class ToolClasspath:
2✔
760
    """A fully fetched and merged classpath for running a JVM tool."""
761

762
    content: Snapshot
2✔
763

764
    @property
2✔
765
    def digest(self) -> Digest:
2✔
766
        return self.content.digest
×
767

768
    def classpath_entries(self, root: str | None = None) -> Iterator[str]:
2✔
769
        """Returns optionally prefixed classpath entry filenames.
770

771
        :param prefix: if set, will be prepended to all entries.  This is useful
772
            if the process working directory is not the same as the root
773
            directory for the process input `Digest`.
774
        """
775
        if root is None:
×
776
            yield from self.content.files
×
777
            return
×
778

779
        for file_name in self.content.files:
×
780
            yield os.path.join(root, file_name)
×
781

782

783
@rule(level=LogLevel.DEBUG)
2✔
784
async def materialize_classpath_for_tool(request: ToolClasspathRequest) -> ToolClasspath:
2✔
785
    if request.artifact_requirements:
×
786
        resolution = await coursier_resolve_lockfile(request.artifact_requirements)
×
787
    else:
788
        lockfile_req = request.lockfile
×
789
        assert lockfile_req is not None
×
790
        regen_command = f"`{GenerateLockfilesSubsystem.name} --resolve={lockfile_req.resolve_name}`"
×
791
        if lockfile_req.lockfile == DEFAULT_TOOL_LOCKFILE:
×
792
            lockfile_bytes = (
×
793
                importlib.resources.files(lockfile_req.default_lockfile_resource[0])
794
                .joinpath(lockfile_req.default_lockfile_resource[1])
795
                .read_bytes()
796
            )
797
            resolution = CoursierResolvedLockfile.from_serialized(lockfile_bytes)
×
798
        else:
799
            lockfile_snapshot = await digest_to_snapshot(
×
800
                **implicitly(PathGlobs([lockfile_req.lockfile]))
801
            )
802
            if not lockfile_snapshot.files:
×
803
                raise ValueError(
×
804
                    f"No lockfile found at {lockfile_req.lockfile}, which is configured "
805
                    f"by the option {lockfile_req.lockfile_option_name}."
806
                    f"Run {regen_command} to generate it."
807
                )
808

809
            resolution = await get_coursier_lockfile_for_resolve(
×
810
                CoursierResolveKey(
811
                    name=lockfile_req.resolve_name,
812
                    path=lockfile_req.lockfile,
813
                    digest=lockfile_snapshot.digest,
814
                )
815
            )
816

817
        # Validate that the lockfile is correct.
818
        lockfile_inputs = await gather_coordinates_for_jvm_lockfile(
×
819
            GatherJvmCoordinatesRequest(
820
                lockfile_req.artifact_inputs, lockfile_req.artifact_option_name
821
            )
822
        )
823
        if resolution.metadata and not resolution.metadata.is_valid_for(
×
824
            lockfile_inputs, LockfileContext.TOOL
825
        ):
826
            raise ValueError(
×
827
                f"The lockfile {lockfile_req.lockfile} (configured by the option "
828
                f"{lockfile_req.lockfile_option_name}) was generated with different requirements "
829
                f"than are currently set via {lockfile_req.artifact_option_name}. Run "
830
                f"{regen_command} to regenerate the lockfile."
831
            )
832

833
    classpath_entries = await coursier_fetch_lockfile(resolution)
×
834
    merged_snapshot = await digest_to_snapshot(
×
835
        **implicitly(MergeDigests(classpath_entry.digest for classpath_entry in classpath_entries))
836
    )
837
    if request.prefix is not None:
×
838
        merged_snapshot = await digest_to_snapshot(
×
839
            **implicitly(AddPrefix(merged_snapshot.digest, request.prefix))
840
        )
841
    return ToolClasspath(merged_snapshot)
×
842

843

844
def rules():
2✔
845
    return [
2✔
846
        *collect_rules(),
847
        *coursier_setup.rules(),
848
        UnionRule(ClasspathEntryRequest, CoursierFetchRequest),
849
    ]
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