• 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

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
3✔
5

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

18
import toml
3✔
19

20
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
3✔
21
from pants.core.goals.generate_lockfiles import DEFAULT_TOOL_LOCKFILE, GenerateLockfilesSubsystem
3✔
22
from pants.core.util_rules.source_files import SourceFilesRequest, determine_source_files
3✔
23
from pants.engine.addresses import UnparsedAddressInputs
3✔
24
from pants.engine.collection import Collection
3✔
25
from pants.engine.fs import (
3✔
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
3✔
38
from pants.engine.internals.native_engine import EMPTY_DIGEST
3✔
39
from pants.engine.intrinsics import (
3✔
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
3✔
49
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
3✔
50
from pants.engine.target import CoarsenedTargets, Target
3✔
51
from pants.engine.unions import UnionRule
3✔
52
from pants.jvm.compile import (
3✔
53
    ClasspathEntry,
54
    ClasspathEntryRequest,
55
    CompileResult,
56
    FallibleClasspathEntry,
57
)
58
from pants.jvm.resolve import coursier_setup
3✔
59
from pants.jvm.resolve.common import (
3✔
60
    ArtifactRequirement,
61
    ArtifactRequirements,
62
    GatherJvmCoordinatesRequest,
63
)
64
from pants.jvm.resolve.coordinate import Coordinate, Coordinates
3✔
65
from pants.jvm.resolve.coursier_setup import Coursier, CoursierFetchProcess
3✔
66
from pants.jvm.resolve.jvm_tool import gather_coordinates_for_jvm_lockfile
3✔
67
from pants.jvm.resolve.key import CoursierResolveKey
3✔
68
from pants.jvm.resolve.lockfile_metadata import JVMLockfileMetadata, LockfileContext
3✔
69
from pants.jvm.subsystems import JvmSubsystem
3✔
70
from pants.jvm.target_types import (
3✔
71
    JvmArtifactFieldSet,
72
    JvmArtifactJarSourceField,
73
    JvmArtifactTarget,
74
    JvmResolveField,
75
)
76
from pants.jvm.util_rules import ExtractFileDigest, digest_to_file_digest
3✔
77
from pants.util.docutil import bin_name, doc_url
3✔
78
from pants.util.logging import LogLevel
3✔
79
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
3✔
80
from pants.util.strutil import bullet_list, pluralize
3✔
81

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

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

87

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

91

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

95

96
class NoCompatibleResolve(Exception):
3✔
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]):
3✔
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)
3✔
119
class CoursierLockfileEntry:
3✔
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
3✔
160
    file_name: str
3✔
161
    direct_dependencies: Coordinates
3✔
162
    dependencies: Coordinates
3✔
163
    file_digest: FileDigest
3✔
164
    remote_url: str | None = None
3✔
165
    pants_address: str | None = None
3✔
166

167
    @classmethod
3✔
168
    def from_json_dict(cls, entry) -> CoursierLockfileEntry:
3✔
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]:
3✔
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)
3✔
204
class CoursierResolvedLockfile:
3✔
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, ...]
3✔
212
    metadata: JVMLockfileMetadata | None = None
3✔
213

214
    @classmethod
3✔
215
    def _coordinate_not_found(cls, key: CoursierResolveKey, coord: Coordinate) -> CoursierError:
3✔
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(
3✔
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(
3✔
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
                dependency_entry
251
                for d in entry.dependencies
252
                # The dependency might not be present in the entries due to coursier bug:
253
                # https://github.com/coursier/coursier/issues/2884
254
                # As a workaround, if this happens, we want to skip the dependency.
255
                # TODO Drop the check once the bug is fixed.
256
                if (dependency_entry := entries.get((d.group, d.artifact, d.classifier)))
257
                is not None
258
            ),
259
        )
260

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

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

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

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

283
        return cls(
×
284
            entries=entries,
285
            metadata=metadata,
286
        )
287

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

293
        return cls.from_toml(lockfile)
×
294

295
    def to_serialized(self) -> bytes:
3✔
296
        """Export this CoursierResolvedLockfile to a human-readable serialized form.
297

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

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

306
        return toml.dumps(lockfile).encode("utf-8")
×
307

308

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

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

318

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

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

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

340

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

351
    LOCAL_EXCLUDE_FILE = "PANTS_RESOLVE_EXCLUDES"
×
352

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

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

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

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

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

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

402
    to_resolve = chain(no_jars, resolvable_jar_requirements)
×
403

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

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

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

428

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

435
    This rule does two things in a single Process invocation:
436

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

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

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

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

457
    if len(artifact_requirements) == 0:
×
458
        return CoursierResolvedLockfile(entries=())
×
459

460
    coursier_resolve_info = await prepare_coursier_resolve_info(artifact_requirements)
×
461

462
    coursier_report_file_name = "coursier_report.json"
×
463

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

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

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

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

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

537
    return CoursierResolvedLockfile(entries=tuple(new_entries))
×
538

539

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

548

549
class ResolvedClasspathEntries(Collection[ClasspathEntry]):
3✔
550
    """A collection of resolved classpath entries."""
551

552

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

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

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

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

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

590
    coursier_resolve_info = await prepare_coursier_resolve_info(ArtifactRequirements([req]))
×
591

592
    coursier_report_file_name = "coursier_report.json"
×
593

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

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

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

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

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

646

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

652
    requirement = ArtifactRequirement.from_jvm_artifact_target(request.component.representative)
×
653

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

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

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

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

684

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

693

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

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

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

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

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

735

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

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

749
    prefix: str | None = None
3✔
750
    lockfile: GenerateJvmLockfileFromTool | None = None
3✔
751
    artifact_requirements: ArtifactRequirements = ArtifactRequirements()
3✔
752

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

759

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

764
    content: Snapshot
3✔
765

766
    @property
3✔
767
    def digest(self) -> Digest:
3✔
768
        return self.content.digest
×
769

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

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

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

784

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

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

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

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

845

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