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

pantsbuild / pants / 22744998495

06 Mar 2026 01:36AM UTC coverage: 92.931%. Remained the same
22744998495

Pull #23158

github

web-flow
Merge d00f1f3d9 into f0030f5e7
Pull Request #23158: A `sync` option for `generate-lockfiles`.

10 of 11 new or added lines in 2 files covered. (90.91%)

8 existing lines in 1 file now uncovered.

90965 of 97884 relevant lines covered (92.93%)

4.06 hits per line

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

91.96
/src/python/pants/core/goals/generate_lockfiles.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
12✔
5

6
import difflib
12✔
7
import itertools
12✔
8
import logging
12✔
9
from collections import defaultdict
12✔
10
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
12✔
11
from dataclasses import dataclass, replace
12✔
12
from enum import Enum
12✔
13
from typing import Protocol, cast
12✔
14

15
from pants.core.goals.resolves import ExportableTool
12✔
16
from pants.engine.collection import Collection, DeduplicatedCollection
12✔
17
from pants.engine.console import Console
12✔
18
from pants.engine.environment import ChosenLocalEnvironmentName, EnvironmentName
12✔
19
from pants.engine.fs import Digest, MergeDigests, Workspace
12✔
20
from pants.engine.goal import Goal, GoalSubsystem
12✔
21
from pants.engine.internals.selectors import concurrently
12✔
22
from pants.engine.intrinsics import merge_digests
12✔
23
from pants.engine.rules import collect_rules, goal_rule, implicitly, rule
12✔
24
from pants.engine.target import Target
12✔
25
from pants.engine.unions import UnionMembership, union
12✔
26
from pants.help.maybe_color import MaybeColor
12✔
27
from pants.option.global_options import GlobalOptions
12✔
28
from pants.option.option_types import BoolOption, StrListOption, StrOption
12✔
29
from pants.util.docutil import bin_name, doc_url
12✔
30
from pants.util.frozendict import FrozenDict
12✔
31
from pants.util.strutil import bullet_list, softwrap
12✔
32

33
logger = logging.getLogger(__name__)
12✔
34

35

36
@dataclass(frozen=True)
12✔
37
class GenerateLockfileResult:
12✔
38
    """The result of generating a lockfile for a particular resolve."""
39

40
    digest: Digest
12✔
41
    resolve_name: str
12✔
42
    path: str
12✔
43
    diff: LockfileDiff | None = None
12✔
44

45

46
@union(in_scope_types=[EnvironmentName])
12✔
47
@dataclass(frozen=True)
12✔
48
class GenerateLockfile:
12✔
49
    """A union base for generating ecosystem-specific lockfiles.
50

51
    Each language ecosystem should set up a subclass of `GenerateLockfile`, like
52
    `GeneratePythonLockfile` and `GenerateJVMLockfile`, and register a union rule. They should
53
    also set up a simple rule that goes from that class -> `WrappedGenerateLockfile`.
54

55
    Subclasses will usually want to add additional properties, such as what requirements to
56
    install and Python interpreter constraints.
57
    """
58

59
    resolve_name: str
12✔
60
    lockfile_dest: str
12✔
61
    diff: bool
12✔
62

63

64
@rule(polymorphic=True)
12✔
65
async def generate_lockfile(
12✔
66
    req: GenerateLockfile, env_name: EnvironmentName
67
) -> GenerateLockfileResult:
68
    raise NotImplementedError()
×
69

70

71
@dataclass(frozen=True)
12✔
72
class GenerateLockfileWithEnvironments(GenerateLockfile):
12✔
73
    """Allows a `GenerateLockfile` subclass to specify which environments the request is compatible
74
    with, if the relevant backend supports environments."""
75

76
    environments: tuple[EnvironmentName, ...]
12✔
77

78

79
@dataclass(frozen=True)
12✔
80
class WrappedGenerateLockfile:
12✔
81
    request: GenerateLockfile
12✔
82

83

84
class UserGenerateLockfiles(Collection[GenerateLockfile]):
12✔
85
    """All user resolves for a particular language ecosystem to build.
86

87
    Each language ecosystem should set up a subclass of `RequestedUserResolveNames` (see its
88
    docstring), and implement a rule going from that subclass -> UserGenerateLockfiles. Each element
89
    in the returned `UserGenerateLockfiles` should be a subclass of `GenerateLockfile`, like
90
    `GeneratePythonLockfile`.
91
    """
92

93

94
@union
12✔
95
class KnownUserResolveNamesRequest:
12✔
96
    """A hook for a language ecosystem to declare which resolves it has defined.
97

98
    Each language ecosystem should set up a subclass and register it with a UnionRule. Implement a
99
    rule that goes from the subclass -> KnownUserResolveNames, usually by simply reading the
100
    `resolves` option from the relevant subsystem.
101
    """
102

103

104
@dataclass(frozen=True)
12✔
105
class KnownUserResolveNames:
12✔
106
    """All defined user resolves for a particular language ecosystem.
107

108
    See KnownUserResolveNamesRequest for how to use this type. `option_name` should be formatted
109
    like `[options-scope].resolves`
110
    """
111

112
    names: tuple[str, ...]
12✔
113
    option_name: str
12✔
114
    requested_resolve_names_cls: type[RequestedUserResolveNames]
12✔
115

116

117
@rule(polymorphic=True)
12✔
118
async def get_known_user_resolve_names(req: KnownUserResolveNamesRequest) -> KnownUserResolveNames:
12✔
119
    raise NotImplementedError()
×
120

121

122
@union(in_scope_types=[EnvironmentName])
12✔
123
class RequestedUserResolveNames(DeduplicatedCollection[str]):
12✔
124
    """The user resolves requested for a particular language ecosystem.
125

126
    Each language ecosystem should set up a subclass and register it with a UnionRule. Implement a
127
    rule that goes from the subclass -> UserGenerateLockfiles.
128
    """
129

130
    sort_input = True
12✔
131

132

133
@rule(polymorphic=True)
12✔
134
async def get_user_generate_lockfiles(
12✔
135
    req: RequestedUserResolveNames, env_name: EnvironmentName
136
) -> UserGenerateLockfiles:
137
    raise NotImplementedError()
×
138

139

140
class PackageVersion(Protocol):
12✔
141
    """Protocol for backend specific implementations, to support language-ecosystem-specific version
142
    formats and sort rules.
143

144
    May support the `int` properties `major`, `minor` and `micro` to color diff based on semantic
145
    step taken.
146
    """
147

148
    def __eq__(self, other) -> bool: ...
149

150
    def __gt__(self, other) -> bool: ...
151

152
    def __lt__(self, other) -> bool: ...
153

154
    def __str__(self) -> str: ...
155

156

157
PackageName = str
12✔
158
LockfilePackages = FrozenDict[PackageName, PackageVersion]
12✔
159
ChangedPackages = FrozenDict[PackageName, tuple[PackageVersion, PackageVersion]]
12✔
160

161

162
@dataclass(frozen=True)
12✔
163
class LockfileDiff:
12✔
164
    path: str
12✔
165
    resolve_name: str
12✔
166
    added: LockfilePackages
12✔
167
    downgraded: ChangedPackages
12✔
168
    removed: LockfilePackages
12✔
169
    unchanged: LockfilePackages
12✔
170
    upgraded: ChangedPackages
12✔
171

172
    @classmethod
12✔
173
    def create(
12✔
174
        cls, path: str, resolve_name: str, old: LockfilePackages, new: LockfilePackages
175
    ) -> LockfileDiff:
176
        diff = {
2✔
177
            name: (old[name], new[name])
178
            for name in sorted({*old.keys(), *new.keys()})
179
            if name in old and name in new
180
        }
181
        return cls(
2✔
182
            path=path,
183
            resolve_name=resolve_name,
184
            added=cls.__get_lockfile_packages(new, old),
185
            downgraded=cls.__get_changed_packages(diff, lambda prev, curr: prev > curr),
186
            removed=cls.__get_lockfile_packages(old, new),
187
            unchanged=LockfilePackages(
188
                {name: curr for name, (prev, curr) in diff.items() if prev == curr}
189
            ),
190
            upgraded=cls.__get_changed_packages(diff, lambda prev, curr: prev < curr),
191
        )
192

193
    @staticmethod
12✔
194
    def __get_lockfile_packages(
12✔
195
        src: Mapping[str, PackageVersion], exclude: Iterable[str]
196
    ) -> LockfilePackages:
197
        return LockfilePackages(
2✔
198
            {name: version for name, version in src.items() if name not in exclude}
199
        )
200

201
    @staticmethod
12✔
202
    def __get_changed_packages(
12✔
203
        src: Mapping[str, tuple[PackageVersion, PackageVersion]],
204
        predicate: Callable[[PackageVersion, PackageVersion], bool],
205
    ) -> ChangedPackages:
206
        return ChangedPackages(
2✔
207
            {name: prev_curr for name, prev_curr in src.items() if predicate(*prev_curr)}
208
        )
209

210

211
class LockfileDiffPrinter(MaybeColor):
12✔
212
    def __init__(self, console: Console, color: bool, include_unchanged: bool) -> None:
12✔
213
        super().__init__(color)
1✔
214
        self.console = console
1✔
215
        self.include_unchanged = include_unchanged
1✔
216

217
    def print(self, diff: LockfileDiff) -> None:
12✔
218
        output = "\n".join(self.output_sections(diff))
1✔
219
        if not output:
1✔
220
            return
×
221
        self.console.print_stderr(
1✔
222
            self.style(" " * 66, style="underline")
223
            + f"\nLockfile diff: {diff.path} [{diff.resolve_name}]\n"
224
            + output
225
        )
226

227
    def output_sections(self, diff: LockfileDiff) -> Iterator[str]:
12✔
228
        if self.include_unchanged:
1✔
229
            yield from self.output_reqs("Unchanged dependencies", diff.unchanged, fg="blue")
1✔
230
        yield from self.output_changed("Upgraded dependencies", diff.upgraded)
1✔
231
        yield from self.output_changed("!! Downgraded dependencies !!", diff.downgraded)
1✔
232
        yield from self.output_reqs("Added dependencies", diff.added, fg="green", style="bold")
1✔
233
        yield from self.output_reqs("Removed dependencies", diff.removed, fg="magenta")
1✔
234

235
    def style(self, text: str, **kwargs) -> str:
12✔
236
        return cast(str, self.maybe_color(text, **kwargs))
1✔
237

238
    def title(self, text: str) -> str:
12✔
239
        heading = f"== {text:^60} =="
1✔
240
        return self.style("\n".join((" " * len(heading), heading, "")), style="underline")
1✔
241

242
    def output_reqs(self, heading: str, reqs: LockfilePackages, **kwargs) -> Iterator[str]:
12✔
243
        if not reqs:
1✔
244
            return
×
245

246
        yield self.title(heading)
1✔
247
        for name, version in reqs.items():
1✔
248
            name_s = self.style(f"{name:30}", fg="yellow")
1✔
249
            version_s = self.style(str(version), **kwargs)
1✔
250
            yield f"  {name_s} {version_s}"
1✔
251

252
    def output_changed(self, title: str, reqs: ChangedPackages) -> Iterator[str]:
12✔
253
        if not reqs:
1✔
254
            return
×
255

256
        yield self.title(title)
1✔
257
        label = "-->"
1✔
258
        for name, (prev, curr) in reqs.items():
1✔
259
            bump_attrs = self.get_bump_attrs(prev, curr)
1✔
260
            name_s = self.style(f"{name:30}", fg="yellow")
1✔
261
            prev_s = self.style(f"{str(prev):10}", fg="cyan")
1✔
262
            bump_s = self.style(f"{label:^7}", **bump_attrs)
1✔
263
            curr_s = self.style(str(curr), **bump_attrs)
1✔
264
            yield f"  {name_s} {prev_s} {bump_s} {curr_s}"
1✔
265

266
    _BUMPS = (
12✔
267
        ("major", dict(fg="red", style="bold")),
268
        ("minor", dict(fg="yellow")),
269
        ("micro", dict(fg="green")),
270
        # Default style
271
        (None, dict(fg="magenta")),
272
    )
273

274
    def get_bump_attrs(self, prev: PackageVersion, curr: PackageVersion) -> dict[str, str]:
12✔
275
        for key, attrs in self._BUMPS:
1✔
276
            if key is None or getattr(prev, key, None) != getattr(curr, key, None):
1✔
277
                return attrs
1✔
278
        return {}  # Should never happen, but let's be safe.
×
279

280

281
DEFAULT_TOOL_LOCKFILE = "<default>"
12✔
282

283

284
class UnrecognizedResolveNamesError(Exception):
12✔
285
    def __init__(
12✔
286
        self,
287
        unrecognized_resolve_names: list[str],
288
        all_valid_names: Iterable[str],
289
        all_valid_binaries: Iterable[str] | None = None,
290
        *,
291
        description_of_origin: str,
292
    ) -> None:
293
        all_valid_binaries = all_valid_binaries or set()
2✔
294

295
        if len(unrecognized_resolve_names) == 1:
2✔
296
            unrecognized_str = unrecognized_resolve_names[0]
2✔
297
            name_description = "name"
2✔
298
        else:
299
            unrecognized_str = str(sorted(unrecognized_resolve_names))
1✔
300
            name_description = "names"
1✔
301

302
        message = [
2✔
303
            f"Unrecognized resolve {name_description} from {description_of_origin}: {unrecognized_str}",
304
            f"All valid resolve names: {sorted(all_valid_names)}",
305
        ]
306
        if all_valid_binaries:
2✔
307
            message.append(f"All valid exportable binaries: {sorted(all_valid_binaries)}")
×
308

309
        should_be_bins = set(unrecognized_resolve_names) & set(all_valid_binaries)
2✔
310
        should_be_resolves = set(unrecognized_resolve_names) & set(all_valid_names)
2✔
311

312
        if should_be_bins:
2✔
313
            cmd = " ".join([f"--bin={e}" for e in should_be_bins])
×
314
            message.append(f"HINT: Some resolves should be binaries, try with `{cmd}`")
×
315

316
        if should_be_resolves:
2✔
317
            cmd = " ".join([f"--resolve={e}" for e in should_be_resolves])
×
318
            message.append(f"HINT: Some binaries should be resolves, try with `{cmd}`")
×
319

320
        # "Did you mean?"
321
        for name in unrecognized_resolve_names:
2✔
322
            close_matches = difflib.get_close_matches(name, all_valid_names)
2✔
323
            if close_matches:
2✔
324
                suggestions = ", ".join(f"`{m}`" for m in close_matches)
1✔
325
                message.append(f"Did you mean: {suggestions} (for `{name}`)")
1✔
326

327
        super().__init__(softwrap("\n\n".join(message)))
2✔
328

329

330
class _ResolveProviderType(Enum):
12✔
331
    TOOL = 1
12✔
332
    USER = 2
12✔
333

334

335
@dataclass(frozen=True, order=True)
12✔
336
class _ResolveProvider:
12✔
337
    option_name: str
12✔
338

339

340
class AmbiguousResolveNamesError(Exception):
12✔
341
    def __init__(self, ambiguous_name: str, providers: set[_ResolveProvider]) -> None:
12✔
342
        msg = softwrap(
1✔
343
            f"""
344
            The same resolve name `{ambiguous_name}` is used by multiple options, which
345
            causes ambiguity: {providers}
346

347
            To fix, please update these options so that `{ambiguous_name}` is not used more
348
            than once.
349
            """
350
        )
351
        super().__init__(msg)
1✔
352

353

354
def _check_ambiguous_resolve_names(
12✔
355
    all_known_user_resolve_names: Iterable[KnownUserResolveNames],
356
) -> None:
357
    resolve_name_to_providers = defaultdict(set)
2✔
358
    for known_user_resolve_names in all_known_user_resolve_names:
2✔
359
        for resolve_name in known_user_resolve_names.names:
2✔
360
            resolve_name_to_providers[resolve_name].add(
2✔
361
                _ResolveProvider(known_user_resolve_names.option_name)
362
            )
363

364
    for resolve_name, providers in resolve_name_to_providers.items():
2✔
365
        if len(providers) > 1:
2✔
366
            raise AmbiguousResolveNamesError(resolve_name, providers)
1✔
367

368

369
def determine_resolves_to_generate(
12✔
370
    all_known_user_resolve_names: Iterable[KnownUserResolveNames],
371
    requested_resolve_names: set[str],
372
) -> list[RequestedUserResolveNames]:
373
    """Apply the `--resolve` option to determine which resolves are specified."""
374
    # Resolve names must be globally unique, so check for ambiguity across backends.
375
    _check_ambiguous_resolve_names(all_known_user_resolve_names)
2✔
376

377
    # If no resolves have been requested, we generate lockfiles for all user resolves
378
    if not requested_resolve_names:
2✔
379
        return [
1✔
380
            known_resolve_names.requested_resolve_names_cls(known_resolve_names.names)
381
            for known_resolve_names in all_known_user_resolve_names
382
        ]
383

384
    requested_user_resolve_names = []
2✔
385
    for known_resolve_names in all_known_user_resolve_names:
2✔
386
        requested = requested_resolve_names.intersection(known_resolve_names.names)
2✔
387
        if requested:
2✔
388
            requested_resolve_names -= requested
2✔
389
            requested_user_resolve_names.append(
2✔
390
                known_resolve_names.requested_resolve_names_cls(requested)
391
            )
392

393
    if requested_resolve_names:
2✔
394
        raise UnrecognizedResolveNamesError(
1✔
395
            unrecognized_resolve_names=sorted(requested_resolve_names),
396
            all_valid_names={
397
                *itertools.chain.from_iterable(
398
                    known_resolve_names.names
399
                    for known_resolve_names in all_known_user_resolve_names
400
                ),
401
            },
402
            description_of_origin="the option `--generate-lockfiles-resolve`",
403
        )
404

405
    return requested_user_resolve_names
2✔
406

407

408
def filter_lockfiles_for_unconfigured_exportable_tools(
12✔
409
    generate_lockfile_requests: Sequence[GenerateLockfile],
410
    exportabletools_by_name: dict[str, type[ExportableTool]],
411
    *,
412
    resolve_specified: bool,
413
) -> tuple[tuple[str, ...], tuple[GenerateLockfile, ...]]:
414
    """Filter lockfile requests for tools still using their default lockfiles."""
415

416
    valid_lockfiles = []
2✔
417
    errs = []
2✔
418

419
    for req in generate_lockfile_requests:
2✔
420
        if req.lockfile_dest != DEFAULT_TOOL_LOCKFILE:
2✔
421
            valid_lockfiles.append(req)
2✔
422
            continue
2✔
423

424
        if req.resolve_name in exportabletools_by_name:
1✔
425
            if resolve_specified:
1✔
426
                # A user has asked us to generate a tool which is using a default lockfile
427
                errs.append(
1✔
428
                    exportabletools_by_name[
429
                        req.resolve_name
430
                    ].help_for_generate_lockfile_with_default_location(req.resolve_name)
431
                )
432
            else:
433
                # When a user selects no resolves, we try generating lockfiles for all resolves.
434
                # The intention is clearly to not generate lockfiles for internal tools, so we skip them here.
435
                continue
1✔
436
        else:
437
            # Arriving at this case is either a user error or an implementation error, but we can be helpful
438
            errs.append(
×
439
                softwrap(
440
                    f"""
441
                    The resolve {req.resolve_name} is using the lockfile destination {DEFAULT_TOOL_LOCKFILE}.
442
                    This destination is used as a sentinel to signal that internal tools should use their bundled lockfile.
443
                    However, the resolve {req.resolve_name} does not appear to be an exportable tool.
444

445
                    If you intended to generate a lockfile for a resolve you specified,
446
                    you should specify a file as the lockfile destination.
447
                    If this is indeed a tool that should be exportable, this is a bug:
448
                    This tool does not appear to be exportable the way we expect.
449
                    It may need a `UnionRule` to `ExportableTool`
450
                    """
451
                )
452
            )
453
            continue
×
454

455
    return tuple(errs), tuple(valid_lockfiles)
2✔
456

457

458
class GenerateLockfilesSubsystem(GoalSubsystem):
12✔
459
    name = "generate-lockfiles"
12✔
460
    help = "Generate lockfiles for third-party dependencies."
12✔
461

462
    @classmethod
12✔
463
    def activated(cls, union_membership: UnionMembership) -> bool:
12✔
464
        return KnownUserResolveNamesRequest in union_membership
×
465

466
    resolve = StrListOption(
12✔
467
        advanced=False,
468
        help=softwrap(
469
            f"""
470
            Only generate lockfiles for the specified resolve(s).
471

472
            Resolves are the logical names for the different lockfiles used in your project.
473
            For your own code's dependencies, these come from backend-specific configuration
474
            such as `[python].resolves`. For tool lockfiles, resolve names are the options
475
            scope for that tool such as `black`, `pytest`, and `mypy-protobuf`.
476

477
            For example, you can run `{bin_name()} generate-lockfiles --resolve=black
478
            --resolve=pytest --resolve=data-science` to only generate lockfiles for those
479
            two tools and your resolve named `data-science`.
480

481
            If you specify an invalid resolve name, like 'fake', Pants will output all
482
            possible values.
483

484
            If not specified, Pants will generate lockfiles for all resolves.
485
            """
486
        ),
487
    )
488
    sync = BoolOption(
12✔
489
        advanced=False,
490
        default=False,
491
        help=softwrap(
492
            """
493
            Attempt a minimal update of the lockfile, preserving existing dependency versions
494
            wherever possible. The resulting lockfile will be a valid solution for the requested
495
            dependency versions, but it may not include the latest versions available.
496

497
            If a backend does not support syncing it will fall back to full regeneration of
498
            the lockfile, and this option will have no effect.
499
            """
500
        ),
501
    )
502
    custom_command = StrOption(
12✔
503
        advanced=True,
504
        default=None,
505
        help=softwrap(
506
            f"""
507
            If set, lockfile metadata will say to run this command to regenerate the lockfile,
508
            rather than running `{bin_name()} generate-lockfiles --resolve=<name>` like normal.
509
            """
510
        ),
511
    )
512
    diff = BoolOption(
12✔
513
        default=True,
514
        help=softwrap(
515
            """
516
            Print a summary of changed distributions after generating the lockfile.
517
            """
518
        ),
519
    )
520
    diff_include_unchanged = BoolOption(
12✔
521
        default=False,
522
        help=softwrap(
523
            """
524
            Include unchanged distributions in the diff summary output. Implies `diff=true`.
525
            """
526
        ),
527
    )
528

529
    @property
12✔
530
    def request_diffs(self) -> bool:
12✔
531
        return self.diff or self.diff_include_unchanged
1✔
532

533

534
class GenerateLockfilesGoal(Goal):
12✔
535
    subsystem_cls = GenerateLockfilesSubsystem
12✔
536
    environment_behavior = Goal.EnvironmentBehavior.USES_ENVIRONMENTS
12✔
537

538

539
@goal_rule
12✔
540
async def generate_lockfiles_goal(
12✔
541
    workspace: Workspace,
542
    union_membership: UnionMembership,
543
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
544
    local_environment: ChosenLocalEnvironmentName,
545
    console: Console,
546
    global_options: GlobalOptions,
547
) -> GenerateLockfilesGoal:
548
    known_user_resolve_names = await concurrently(
1✔
549
        get_known_user_resolve_names(**implicitly({request(): KnownUserResolveNamesRequest}))
550
        for request in union_membership.get(KnownUserResolveNamesRequest)
551
    )
552
    requested_user_resolve_names = determine_resolves_to_generate(
1✔
553
        known_user_resolve_names,
554
        set(generate_lockfiles_subsystem.resolve),
555
    )
556

557
    # This is the "planning" phase of lockfile generation. Currently this is all done in the local
558
    # environment, since there's not currently a clear mechanism to prescribe an environment.
559
    all_specified_user_requests = await concurrently(
1✔
560
        get_user_generate_lockfiles(
561
            **implicitly(
562
                {resolve_names: RequestedUserResolveNames, local_environment.val: EnvironmentName}
563
            )
564
        )
565
        for resolve_names in requested_user_resolve_names
566
    )
567
    resolve_specified = bool(generate_lockfiles_subsystem.resolve)
1✔
568
    # We filter "user" requests because we're moving to combine user and tool lockfiles
569
    (
1✔
570
        tool_request_errors,
571
        applicable_user_requests,
572
    ) = filter_lockfiles_for_unconfigured_exportable_tools(
573
        list(itertools.chain(*all_specified_user_requests)),
574
        {e.options_scope: e for e in union_membership.get(ExportableTool)},
575
        resolve_specified=resolve_specified,
576
    )
577

578
    if tool_request_errors:
1✔
UNCOV
579
        raise ValueError("\n\n".join(tool_request_errors))
×
580

581
    # Execute the actual lockfile generation in each request's environment.
582
    # Currently, since resolves specify a single filename for output, we pick a reasonable
583
    # environment to execute the request in. Currently we warn if multiple environments are
584
    # specified.
585
    if generate_lockfiles_subsystem.request_diffs:
1✔
586
        all_requests = tuple(replace(req, diff=True) for req in applicable_user_requests)
1✔
587
    else:
UNCOV
588
        all_requests = applicable_user_requests
×
589

590
    results = await concurrently(
1✔
591
        generate_lockfile(
592
            **implicitly(
593
                {
594
                    req: GenerateLockfile,
595
                    _preferred_environment(req, local_environment.val): EnvironmentName,
596
                }
597
            )
598
        )
599
        for req in all_requests
600
    )
601

602
    # Lockfiles are actually written here. This would be an acceptable place to handle conflict
603
    # resolution behaviour if we start executing requests in multiple environments.
604
    merged_digest = await merge_digests(MergeDigests(res.digest for res in results))
1✔
605
    workspace.write_digest(merged_digest)
1✔
606

607
    diffs: list[LockfileDiff] = []
1✔
608
    for result in results:
1✔
609
        logger.info(f"Wrote lockfile for the resolve `{result.resolve_name}` to {result.path}")
1✔
610
        if result.diff is not None:
1✔
UNCOV
611
            diffs.append(result.diff)
×
612

613
    if diffs:
1✔
UNCOV
614
        diff_formatter = LockfileDiffPrinter(
×
615
            console=console,
616
            color=global_options.colors,
617
            include_unchanged=generate_lockfiles_subsystem.diff_include_unchanged,
618
        )
UNCOV
619
        for diff in diffs:
×
UNCOV
620
            diff_formatter.print(diff)
×
UNCOV
621
        console.print_stderr("\n")
×
622

623
    return GenerateLockfilesGoal(exit_code=0)
1✔
624

625

626
def _preferred_environment(request: GenerateLockfile, default: EnvironmentName) -> EnvironmentName:
12✔
627
    if not isinstance(request, GenerateLockfileWithEnvironments):
2✔
628
        return default  # This request has not been migrated to use environments.
2✔
629

630
    if len(request.environments) == 1:
1✔
631
        return request.environments[0]
1✔
632

633
    ret = default if default in request.environments else request.environments[0]
1✔
634

635
    logger.warning(
1✔
636
        f"The `{request.__class__.__name__}` for resolve `{request.resolve_name}` specifies more "
637
        "than one environment. Pants will generate the lockfile using only the environment "
638
        f"`{ret.val}`, which may have unintended effects when executing in the other environments."
639
    )
640

641
    return ret
1✔
642

643

644
# -----------------------------------------------------------------------------------------------
645
# Helpers for determining the resolve
646
# -----------------------------------------------------------------------------------------------
647

648

649
class NoCompatibleResolveException(Exception):
12✔
650
    """No compatible resolve could be found for a set of targets."""
651

652
    @classmethod
12✔
653
    def bad_input_roots(
12✔
654
        cls,
655
        targets: Iterable[Target],
656
        *,
657
        maybe_get_resolve: Callable[[Target], str | None],
658
        doc_url_slug: str,
659
        workaround: str | None,
660
    ) -> NoCompatibleResolveException:
661
        resolves_to_addresses = defaultdict(list)
2✔
662
        for tgt in targets:
2✔
663
            maybe_resolve = maybe_get_resolve(tgt)
2✔
664
            if maybe_resolve is not None:
2✔
665
                resolves_to_addresses[maybe_resolve].append(tgt.address.spec)
2✔
666

667
        formatted_resolve_lists = "\n\n".join(
2✔
668
            f"{resolve}:\n{bullet_list(sorted(addresses))}"
669
            for resolve, addresses in sorted(resolves_to_addresses.items())
670
        )
671
        return NoCompatibleResolveException(
2✔
672
            softwrap(
673
                f"""
674
                The input targets did not have a resolve in common.
675

676
                {formatted_resolve_lists}
677

678
                Targets used together must use the same resolve, set by the `resolve` field. For more
679
                information on 'resolves' (lockfiles), see {doc_url(doc_url_slug)}.
680
                """
681
            )
682
            + (f"\n\n{workaround}" if workaround else "")
683
        )
684

685
    @classmethod
12✔
686
    def bad_dependencies(
12✔
687
        cls,
688
        *,
689
        maybe_get_resolve: Callable[[Target], str | None],
690
        doc_url_slug: str,
691
        root_resolve: str,
692
        root_targets: Sequence[Target],
693
        dependencies: Iterable[Target],
694
    ) -> NoCompatibleResolveException:
695
        if len(root_targets) == 1:
1✔
696
            addr = root_targets[0].address
1✔
697
            prefix = softwrap(
1✔
698
                f"""
699
                The target {addr} uses the `resolve` `{root_resolve}`, but some of its
700
                dependencies are not compatible with that resolve:
701
                """
702
            )
703
            change_input_targets_instructions = f"of the target {addr}"
1✔
704
        else:
705
            assert root_targets
1✔
706
            prefix = softwrap(
1✔
707
                f"""
708
                The input targets use the `resolve` `{root_resolve}`, but some of their
709
                dependencies are not compatible with that resolve.
710

711
                Input targets:
712

713
                {bullet_list(sorted(t.address.spec for t in root_targets))}
714

715
                Bad dependencies:
716
                """
717
            )
718
            change_input_targets_instructions = "of the input targets"
1✔
719

720
        deps_strings = []
1✔
721
        for dep in dependencies:
1✔
722
            maybe_resolve = maybe_get_resolve(dep)
1✔
723
            if maybe_resolve is None or maybe_resolve == root_resolve:
1✔
UNCOV
724
                continue
×
725
            deps_strings.append(f"{dep.address} ({maybe_resolve})")
1✔
726

727
        return NoCompatibleResolveException(
1✔
728
            softwrap(
729
                f"""
730
                {prefix}
731

732
                {bullet_list(deps_strings)}
733

734
                All dependencies must work with the same `resolve`. To fix this, either change
735
                the `resolve=` field on those dependencies to `{root_resolve}`, or change
736
                the `resolve=` {change_input_targets_instructions}. If those dependencies should
737
                work with multiple resolves, use the `parametrize` mechanism with the `resolve=`
738
                field or manually create multiple targets for the same entity.
739

740
                For more information, see {doc_url(doc_url_slug)}.
741
                """
742
            )
743
        )
744

745

746
def rules():
12✔
747
    return collect_rules()
7✔
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