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

pantsbuild / pants / 19015773527

02 Nov 2025 05:33PM UTC coverage: 17.872% (-62.4%) from 80.3%
19015773527

Pull #22816

github

web-flow
Merge a12d75757 into 6c024e162
Pull Request #22816: Update Pants internal Python to 3.14

4 of 5 new or added lines in 3 files covered. (80.0%)

28452 existing lines in 683 files now uncovered.

9831 of 55007 relevant lines covered (17.87%)

0.18 hits per line

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

0.0
/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

UNCOV
4
from __future__ import annotations
×
5

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

UNCOV
18
import toml
×
19

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

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

UNCOV
85
logger = logging.getLogger(__name__)
×
86

87

UNCOV
88
class CoursierFetchRequest(ClasspathEntryRequest):
×
UNCOV
89
    field_sets = (JvmArtifactFieldSet,)
×
90

91

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

95

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

UNCOV
99
    def __init__(self, jvm: JvmSubsystem, msg_prefix: str, relevant_targets: Iterable[Target]):
×
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

UNCOV
118
@dataclass(frozen=True)
×
UNCOV
119
class CoursierLockfileEntry:
×
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

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

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

UNCOV
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

UNCOV
186
    def to_json_dict(self) -> dict[str, Any]:
×
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

UNCOV
203
@dataclass(frozen=True)
×
UNCOV
204
class CoursierResolvedLockfile:
×
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

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

UNCOV
214
    @classmethod
×
UNCOV
215
    def _coordinate_not_found(cls, key: CoursierResolveKey, coord: Coordinate) -> CoursierError:
×
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

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

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

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

UNCOV
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

UNCOV
261
    @classmethod
×
UNCOV
262
    def from_toml(cls, lockfile: str | bytes) -> CoursierResolvedLockfile:
×
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
UNCOV
270
        if isinstance(lockfile, str):
×
271
            lockfile_str = lockfile
×
272
            lockfile_bytes = lockfile.encode("utf-8")
×
273
        else:
UNCOV
274
            lockfile_str = lockfile.decode("utf-8")
×
UNCOV
275
            lockfile_bytes = lockfile
×
276

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

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

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

UNCOV
293
        return cls.from_toml(lockfile)
×
294

UNCOV
295
    def to_serialized(self) -> bytes:
×
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

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

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

308

UNCOV
309
def classpath_dest_filename(coord: str, src_filename: str) -> str:
×
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

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

UNCOV
326
    @property
×
UNCOV
327
    def argv(self) -> Iterable[str]:
×
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

UNCOV
341
@rule
×
UNCOV
342
async def prepare_coursier_resolve_info(
×
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

UNCOV
429
@rule(level=LogLevel.DEBUG)
×
UNCOV
430
async def coursier_resolve_lockfile(
×
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

UNCOV
540
@rule
×
UNCOV
541
async def get_coursier_lockfile_for_resolve(
×
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

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

552

UNCOV
553
@rule
×
UNCOV
554
async def coursier_fetch_one_coord(
×
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

UNCOV
647
@rule(desc="Fetch with coursier")
×
UNCOV
648
async def fetch_with_coursier(request: CoursierFetchRequest) -> FallibleClasspathEntry:
×
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

UNCOV
685
@rule(level=LogLevel.DEBUG)
×
UNCOV
686
async def coursier_fetch_lockfile(lockfile: CoursierResolvedLockfile) -> ResolvedClasspathEntries:
×
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

UNCOV
694
@rule
×
UNCOV
695
async def select_coursier_resolve_for_targets(
×
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

UNCOV
736
@dataclass(frozen=True)
×
UNCOV
737
class ToolClasspathRequest:
×
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

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

UNCOV
753
    def __post_init__(self) -> None:
×
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

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

UNCOV
764
    content: Snapshot
×
765

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

UNCOV
770
    def classpath_entries(self, root: str | None = None) -> Iterator[str]:
×
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

UNCOV
785
@rule(level=LogLevel.DEBUG)
×
UNCOV
786
async def materialize_classpath_for_tool(request: ToolClasspathRequest) -> ToolClasspath:
×
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

UNCOV
846
def rules():
×
UNCOV
847
    return [
×
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