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

pantsbuild / pants / 25600929628

09 May 2026 12:18PM UTC coverage: 91.154% (-1.6%) from 92.787%
25600929628

Pull #23341

github

web-flow
Merge 0787d1df4 into 60371862f
Pull Request #23341: Restore missing-entry guard in CoursierResolvedLockfile.dependencies() (regression from #22906)

87247 of 95714 relevant lines covered (91.15%)

3.87 hits per line

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

92.15
/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
11✔
5

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

18
import toml
11✔
19

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

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

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

87

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

91

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

95

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

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

171
        return cls(
11✔
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]:
11✔
187
        """Export this CoursierLockfileEntry to a JSON object."""
188

189
        return dict(
1✔
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)
11✔
204
class CoursierResolvedLockfile:
11✔
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, ...]
11✔
212
    metadata: JVMLockfileMetadata | None = None
11✔
213

214
    @classmethod
11✔
215
    def _coordinate_not_found(cls, key: CoursierResolveKey, coord: Coordinate) -> CoursierError:
11✔
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(
11✔
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}
1✔
229
        entry = entries.get((coord.group, coord.artifact, coord.classifier))
1✔
230
        if entry is None:
1✔
231
            raise self._coordinate_not_found(key, coord)
×
232

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

238
    def dependencies(
11✔
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}
9✔
243
        entry = entries.get((coord.group, coord.artifact, coord.classifier))
9✔
244
        if entry is None:
9✔
245
            raise self._coordinate_not_found(key, coord)
×
246

247
        return (
9✔
248
            entry,
249
            tuple(
250
                dependency_entry
251
                for d in entry.dependencies
252
                # Coursier sometimes reports transitive dependencies that don't have a
253
                # coord entry of their own — parent POMs with classifier "pom", and
254
                # other entries that are dropped by coursier bugs such as
255
                # https://github.com/coursier/coursier/issues/2884 (which is still
256
                # observable as of v2.1.25-M19, e.g. ("org.apache.curator",
257
                # "apache-curator", None) when resolving hive-exec). Skip any such
258
                # missing entry rather than raising KeyError.
259
                if (dependency_entry := entries.get((d.group, d.artifact, d.classifier)))
260
                is not None
261
            ),
262
        )
263

264
    @classmethod
11✔
265
    def from_toml(cls, lockfile: str | bytes) -> CoursierResolvedLockfile:
11✔
266
        """Constructs a CoursierResolvedLockfile from it's TOML + metadata comment representation.
267

268
        The toml file should consist of an `[entries]` block, followed by several entries.
269
        """
270

271
        lockfile_str: str
272
        lockfile_bytes: bytes
273
        if isinstance(lockfile, str):
11✔
274
            lockfile_str = lockfile
×
275
            lockfile_bytes = lockfile.encode("utf-8")
×
276
        else:
277
            lockfile_str = lockfile.decode("utf-8")
11✔
278
            lockfile_bytes = lockfile
11✔
279

280
        contents = toml.loads(lockfile_str)
11✔
281
        entries = tuple(
11✔
282
            CoursierLockfileEntry.from_json_dict(entry) for entry in (contents["entries"])
283
        )
284
        metadata = JVMLockfileMetadata.from_lockfile(lockfile_bytes, delimeter="#")
11✔
285

286
        return cls(
11✔
287
            entries=entries,
288
            metadata=metadata,
289
        )
290

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

296
        return cls.from_toml(lockfile)
11✔
297

298
    def to_serialized(self) -> bytes:
11✔
299
        """Export this CoursierResolvedLockfile to a human-readable serialized form.
300

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

305
        lockfile = {
4✔
306
            "entries": [entry.to_json_dict() for entry in self.entries],
307
        }
308

309
        return toml.dumps(lockfile).encode("utf-8")
4✔
310

311

312
def classpath_dest_filename(coord: str, src_filename: str) -> str:
11✔
313
    """Calculates the destination filename on the classpath for the given source filename and coord.
314

315
    TODO: This is duplicated in `COURSIER_POST_PROCESSING_SCRIPT`.
316
    """
317
    dest_name = coord.replace(":", "_")
11✔
318
    _, ext = os.path.splitext(src_filename)
11✔
319
    return f"{dest_name}{ext}"
11✔
320

321

322
@dataclass(frozen=True)
11✔
323
class CoursierResolveInfo:
11✔
324
    coord_arg_strings: FrozenOrderedSet[str]
11✔
325
    force_version_coord_arg_strings: FrozenOrderedSet[str]
11✔
326
    extra_args: tuple[str, ...]
11✔
327
    digest: Digest
11✔
328

329
    @property
11✔
330
    def argv(self) -> Iterable[str]:
11✔
331
        """Return coursier arguments that can be used to compute or fetch this resolve.
332

333
        Must be used in concert with `digest`.
334
        """
335
        return itertools.chain(
11✔
336
            self.coord_arg_strings,
337
            itertools.chain.from_iterable(
338
                zip(itertools.repeat("--force-version"), self.force_version_coord_arg_strings)
339
            ),
340
            self.extra_args,
341
        )
342

343

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

354
    LOCAL_EXCLUDE_FILE = "PANTS_RESOLVE_EXCLUDES"
11✔
355

356
    for req in artifact_requirements:
11✔
357
        jar = req.jar
11✔
358
        if not jar:
11✔
359
            no_jars.append(req)
11✔
360
        else:
361
            jars.append((req, jar))
1✔
362

363
    excludes = [
11✔
364
        (req.coordinate, exclude)
365
        for req in artifact_requirements
366
        for exclude in (req.excludes or [])
367
    ]
368

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

380
    jar_file_sources = await concurrently(
11✔
381
        determine_source_files(SourceFilesRequest([jar_source_field]))
382
        for _, jar_source_field in jars
383
    )
384
    jar_file_paths = [jar_file_source.snapshot.files[0] for jar_file_source in jar_file_sources]
11✔
385

386
    resolvable_jar_requirements = [
11✔
387
        dataclasses.replace(
388
            req, jar=None, url=f"file:{Coursier.working_directory_placeholder}/{path}"
389
        )
390
        for (req, _), path in zip(jars, jar_file_paths)
391
    ]
392

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

405
    to_resolve = chain(no_jars, resolvable_jar_requirements)
11✔
406

407
    digest = await merge_digests(
11✔
408
        MergeDigests(
409
            [
410
                *(jar_file_source.snapshot.digest for jar_file_source in jar_file_sources),
411
                excludes_digest,
412
            ]
413
        )
414
    )
415

416
    coord_arg_strings: OrderedSet[str] = OrderedSet()
11✔
417
    force_version_coord_arg_strings: OrderedSet[str] = OrderedSet()
11✔
418
    for req in sorted(to_resolve, key=lambda ar: ar.coordinate):
11✔
419
        coord_arg_str = req.to_coord_arg_str()
11✔
420
        coord_arg_strings.add(coord_arg_str)
11✔
421
        if req.force_version:
11✔
422
            force_version_coord_arg_strings.add(coord_arg_str)
1✔
423

424
    return CoursierResolveInfo(
11✔
425
        coord_arg_strings=FrozenOrderedSet(coord_arg_strings),
426
        force_version_coord_arg_strings=FrozenOrderedSet(force_version_coord_arg_strings),
427
        digest=digest,
428
        extra_args=tuple(extra_args),
429
    )
430

431

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

438
    This rule does two things in a single Process invocation:
439

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

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

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

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

460
    if len(artifact_requirements) == 0:
11✔
461
        return CoursierResolvedLockfile(entries=())
1✔
462

463
    coursier_resolve_info = await prepare_coursier_resolve_info(artifact_requirements)
11✔
464

465
    coursier_report_file_name = "coursier_report.json"
11✔
466

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

486
    report_digest = await digest_subset_to_digest(
11✔
487
        DigestSubset(process_result.output_digest, PathGlobs([coursier_report_file_name]))
488
    )
489
    report_contents = await get_digest_contents(report_digest)
11✔
490
    report = json.loads(report_contents[0].content)
11✔
491

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

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

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

540
    return CoursierResolvedLockfile(entries=tuple(new_entries))
11✔
541

542

543
@rule
11✔
544
async def get_coursier_lockfile_for_resolve(
11✔
545
    coursier_resolve: CoursierResolveKey,
546
) -> CoursierResolvedLockfile:
547
    lockfile_digest_contents = await get_digest_contents(coursier_resolve.digest)
9✔
548
    lockfile_contents = lockfile_digest_contents[0].content
9✔
549
    return CoursierResolvedLockfile.from_serialized(lockfile_contents)
9✔
550

551

552
class ResolvedClasspathEntries(Collection[ClasspathEntry]):
11✔
553
    """A collection of resolved classpath entries."""
554

555

556
@rule
11✔
557
async def coursier_fetch_one_coord(
11✔
558
    request: CoursierLockfileEntry,
559
) -> ClasspathEntry:
560
    """Run `coursier fetch --intransitive` to fetch a single artifact.
561

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

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

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

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

593
    coursier_resolve_info = await prepare_coursier_resolve_info(ArtifactRequirements([req]))
11✔
594

595
    coursier_report_file_name = "coursier_report.json"
11✔
596

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

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

626
    dep = report_deps[0]
11✔
627
    resolved_coord = Coordinate.from_coord_str(dep["coord"])
11✔
628
    if resolved_coord != request.coord:
11✔
629
        raise CoursierError(
1✔
630
            f'Coursier resolved coord "{resolved_coord.to_coord_str()}" does not match requested coord "{request.coord.to_coord_str()}".'
631
        )
632

633
    classpath_dest_name = classpath_dest_filename(dep["coord"], dep["file"])
11✔
634
    classpath_dest = f"classpath/{classpath_dest_name}"
11✔
635

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

649

650
@rule(desc="Fetch with coursier")
11✔
651
async def fetch_with_coursier(request: CoursierFetchRequest) -> FallibleClasspathEntry:
11✔
652
    # TODO: Loading this per JvmArtifact.
653
    lockfile = await get_coursier_lockfile_for_resolve(request.resolve)
9✔
654

655
    requirement = ArtifactRequirement.from_jvm_artifact_target(request.component.representative)
9✔
656

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

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

675
    classpath_entries = await concurrently(
9✔
676
        coursier_fetch_one_coord(entry) for entry in (root_entry, *transitive_entries)
677
    )
678
    exported_digest = await merge_digests(MergeDigests(cpe.digest for cpe in classpath_entries))
9✔
679

680
    return FallibleClasspathEntry(
9✔
681
        description=str(request.component),
682
        result=CompileResult.SUCCEEDED,
683
        output=ClasspathEntry.merge(exported_digest, classpath_entries),
684
        exit_code=0,
685
    )
686

687

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

696

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

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

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

722
    if not all_compatible:
8✔
723
        raise NoCompatibleResolve(
×
724
            jvm, "The selected targets did not have a resolve in common", targets
725
        )
726
    resolve = compatible_resolve or jvm.default_resolve
8✔
727

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

738

739
@dataclass(frozen=True)
11✔
740
class ToolClasspathRequest:
11✔
741
    """A request to set up the classpath for a JVM tool by fetching artifacts and merging the
742
    classpath.
743

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

752
    prefix: str | None = None
11✔
753
    lockfile: GenerateJvmLockfileFromTool | None = None
11✔
754
    artifact_requirements: ArtifactRequirements = ArtifactRequirements()
11✔
755

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

762

763
@dataclass(frozen=True)
11✔
764
class ToolClasspath:
11✔
765
    """A fully fetched and merged classpath for running a JVM tool."""
766

767
    content: Snapshot
11✔
768

769
    @property
11✔
770
    def digest(self) -> Digest:
11✔
771
        return self.content.digest
11✔
772

773
    def classpath_entries(self, root: str | None = None) -> Iterator[str]:
11✔
774
        """Returns optionally prefixed classpath entry filenames.
775

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

784
        for file_name in self.content.files:
11✔
785
            yield os.path.join(root, file_name)
11✔
786

787

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

814
            resolution = await get_coursier_lockfile_for_resolve(
×
815
                CoursierResolveKey(
816
                    name=lockfile_req.resolve_name,
817
                    path=lockfile_req.lockfile,
818
                    digest=lockfile_snapshot.digest,
819
                )
820
            )
821

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

838
    classpath_entries = await coursier_fetch_lockfile(resolution)
11✔
839
    merged_snapshot = await digest_to_snapshot(
11✔
840
        **implicitly(MergeDigests(classpath_entry.digest for classpath_entry in classpath_entries))
841
    )
842
    if request.prefix is not None:
11✔
843
        merged_snapshot = await digest_to_snapshot(
10✔
844
            **implicitly(AddPrefix(merged_snapshot.digest, request.prefix))
845
        )
846
    return ToolClasspath(merged_snapshot)
11✔
847

848

849
def rules():
11✔
850
    return [
11✔
851
        *collect_rules(),
852
        *coursier_setup.rules(),
853
        UnionRule(ClasspathEntryRequest, CoursierFetchRequest),
854
    ]
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