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

pantsbuild / pants / 25441711719

06 May 2026 02:31PM UTC coverage: 92.915%. Remained the same
25441711719

push

github

web-flow
use sha pin (with comment) format for generated actions (#23312)

Per the GitHub Action best practices we recently enabled at #23249, we
should pin each action to a SHA so that the reference is actually
immutable.

This will -- I hope -- knock out a large chunk of the 421 alerts we
currently get from zizmor. The next followup would then be upgrades and
harmonizing the generated and none-generated pins.

Notice: This idea was suggested by Claude while going over pinact output
and I was surprised to see that post processing the yaml wasn't too
gross.

92206 of 99237 relevant lines covered (92.91%)

4.04 hits per line

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

91.87
/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
    `GeneratePexLockfile` and `GenerateJVMLockfile`, and register a union rule.
53

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

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

62

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

69

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

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

77

78
class UserGenerateLockfiles(Collection[GenerateLockfile]):
12✔
79
    """All user resolves for a particular language ecosystem to build.
80

81
    Each language ecosystem should set up a subclass of `RequestedUserResolveNames` (see its
82
    docstring), and implement a rule going from that subclass -> UserGenerateLockfiles. Each element
83
    in the returned `UserGenerateLockfiles` should be a subclass of `GenerateLockfile`, like
84
    `GeneratePexLockfile`.
85
    """
86

87

88
@union
12✔
89
class KnownUserResolveNamesRequest:
12✔
90
    """A hook for a language ecosystem to declare which resolves it has defined.
91

92
    Each language ecosystem should set up a subclass and register it with a UnionRule. Implement a
93
    rule that goes from the subclass -> KnownUserResolveNames, usually by simply reading the
94
    `resolves` option from the relevant subsystem.
95
    """
96

97

98
@dataclass(frozen=True)
12✔
99
class KnownUserResolveNames:
12✔
100
    """All defined user resolves for a particular language ecosystem.
101

102
    See KnownUserResolveNamesRequest for how to use this type. `option_name` should be formatted
103
    like `[options-scope].resolves`
104
    """
105

106
    names: tuple[str, ...]
12✔
107
    option_name: str
12✔
108
    requested_resolve_names_cls: type[RequestedUserResolveNames]
12✔
109

110

111
@rule(polymorphic=True)
12✔
112
async def get_known_user_resolve_names(req: KnownUserResolveNamesRequest) -> KnownUserResolveNames:
12✔
113
    raise NotImplementedError()
×
114

115

116
@union(in_scope_types=[EnvironmentName])
12✔
117
class RequestedUserResolveNames(DeduplicatedCollection[str]):
12✔
118
    """The user resolves requested for a particular language ecosystem.
119

120
    Each language ecosystem should set up a subclass and register it with a UnionRule. Implement a
121
    rule that goes from the subclass -> UserGenerateLockfiles.
122
    """
123

124
    sort_input = True
12✔
125

126

127
@rule(polymorphic=True)
12✔
128
async def get_user_generate_lockfiles(
12✔
129
    req: RequestedUserResolveNames, env_name: EnvironmentName
130
) -> UserGenerateLockfiles:
131
    raise NotImplementedError()
×
132

133

134
class PackageVersion(Protocol):
12✔
135
    """Protocol for backend specific implementations, to support language-ecosystem-specific version
136
    formats and sort rules.
137

138
    May support the `int` properties `major`, `minor` and `micro` to color diff based on semantic
139
    step taken.
140
    """
141

142
    def __eq__(self, other) -> bool: ...
143

144
    def __gt__(self, other) -> bool: ...
145

146
    def __lt__(self, other) -> bool: ...
147

148
    def __str__(self) -> str: ...
149

150

151
PackageName = str
12✔
152
LockfilePackages = FrozenDict[PackageName, PackageVersion]
12✔
153
ChangedPackages = FrozenDict[PackageName, tuple[PackageVersion, PackageVersion]]
12✔
154

155

156
@dataclass(frozen=True)
12✔
157
class LockfileDiff:
12✔
158
    path: str
12✔
159
    resolve_name: str
12✔
160
    added: LockfilePackages
12✔
161
    downgraded: ChangedPackages
12✔
162
    removed: LockfilePackages
12✔
163
    unchanged: LockfilePackages
12✔
164
    upgraded: ChangedPackages
12✔
165

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

187
    @staticmethod
12✔
188
    def __get_lockfile_packages(
12✔
189
        src: Mapping[str, PackageVersion], exclude: Iterable[str]
190
    ) -> LockfilePackages:
191
        return LockfilePackages(
2✔
192
            {name: version for name, version in src.items() if name not in exclude}
193
        )
194

195
    @staticmethod
12✔
196
    def __get_changed_packages(
12✔
197
        src: Mapping[str, tuple[PackageVersion, PackageVersion]],
198
        predicate: Callable[[PackageVersion, PackageVersion], bool],
199
    ) -> ChangedPackages:
200
        return ChangedPackages(
2✔
201
            {name: prev_curr for name, prev_curr in src.items() if predicate(*prev_curr)}
202
        )
203

204

205
class LockfileDiffPrinter(MaybeColor):
12✔
206
    def __init__(self, console: Console, color: bool, include_unchanged: bool) -> None:
12✔
207
        super().__init__(color)
1✔
208
        self.console = console
1✔
209
        self.include_unchanged = include_unchanged
1✔
210

211
    def print(self, diff: LockfileDiff) -> None:
12✔
212
        output = "\n".join(self.output_sections(diff))
1✔
213
        if not output:
1✔
214
            return
×
215
        self.console.print_stderr(
1✔
216
            self.style(" " * 66, style="underline")
217
            + f"\nLockfile diff: {diff.path} [{diff.resolve_name}]\n"
218
            + output
219
        )
220

221
    def output_sections(self, diff: LockfileDiff) -> Iterator[str]:
12✔
222
        if self.include_unchanged:
1✔
223
            yield from self.output_reqs("Unchanged dependencies", diff.unchanged, fg="blue")
1✔
224
        yield from self.output_changed("Upgraded dependencies", diff.upgraded)
1✔
225
        yield from self.output_changed("!! Downgraded dependencies !!", diff.downgraded)
1✔
226
        yield from self.output_reqs("Added dependencies", diff.added, fg="green", style="bold")
1✔
227
        yield from self.output_reqs("Removed dependencies", diff.removed, fg="magenta")
1✔
228

229
    def style(self, text: str, **kwargs) -> str:
12✔
230
        return cast(str, self.maybe_color(text, **kwargs))
1✔
231

232
    def title(self, text: str) -> str:
12✔
233
        heading = f"== {text:^60} =="
1✔
234
        return self.style("\n".join((" " * len(heading), heading, "")), style="underline")
1✔
235

236
    def output_reqs(self, heading: str, reqs: LockfilePackages, **kwargs) -> Iterator[str]:
12✔
237
        if not reqs:
1✔
238
            return
×
239

240
        yield self.title(heading)
1✔
241
        for name, version in reqs.items():
1✔
242
            name_s = self.style(f"{name:30}", fg="yellow")
1✔
243
            version_s = self.style(str(version), **kwargs)
1✔
244
            yield f"  {name_s} {version_s}"
1✔
245

246
    def output_changed(self, title: str, reqs: ChangedPackages) -> Iterator[str]:
12✔
247
        if not reqs:
1✔
248
            return
×
249

250
        yield self.title(title)
1✔
251
        label = "-->"
1✔
252
        for name, (prev, curr) in reqs.items():
1✔
253
            bump_attrs = self.get_bump_attrs(prev, curr)
1✔
254
            name_s = self.style(f"{name:30}", fg="yellow")
1✔
255
            prev_s = self.style(f"{str(prev):10}", fg="cyan")
1✔
256
            bump_s = self.style(f"{label:^7}", **bump_attrs)
1✔
257
            curr_s = self.style(str(curr), **bump_attrs)
1✔
258
            yield f"  {name_s} {prev_s} {bump_s} {curr_s}"
1✔
259

260
    _BUMPS = (
12✔
261
        ("major", dict(fg="red", style="bold")),
262
        ("minor", dict(fg="yellow")),
263
        ("micro", dict(fg="green")),
264
        # Default style
265
        (None, dict(fg="magenta")),
266
    )
267

268
    def get_bump_attrs(self, prev: PackageVersion, curr: PackageVersion) -> dict[str, str]:
12✔
269
        for key, attrs in self._BUMPS:
1✔
270
            if key is None or getattr(prev, key, None) != getattr(curr, key, None):
1✔
271
                return attrs
1✔
272
        return {}  # Should never happen, but let's be safe.
×
273

274

275
DEFAULT_TOOL_LOCKFILE = "<default>"
12✔
276

277

278
class UnrecognizedResolveNamesError(Exception):
12✔
279
    def __init__(
12✔
280
        self,
281
        unrecognized_resolve_names: list[str],
282
        all_valid_names: Iterable[str],
283
        all_valid_binaries: Iterable[str] | None = None,
284
        *,
285
        description_of_origin: str,
286
    ) -> None:
287
        all_valid_binaries = all_valid_binaries or set()
2✔
288

289
        if len(unrecognized_resolve_names) == 1:
2✔
290
            unrecognized_str = unrecognized_resolve_names[0]
2✔
291
            name_description = "name"
2✔
292
        else:
293
            unrecognized_str = str(sorted(unrecognized_resolve_names))
1✔
294
            name_description = "names"
1✔
295

296
        message = [
2✔
297
            f"Unrecognized resolve {name_description} from {description_of_origin}: {unrecognized_str}",
298
            f"All valid resolve names: {sorted(all_valid_names)}",
299
        ]
300
        if all_valid_binaries:
2✔
301
            message.append(f"All valid exportable binaries: {sorted(all_valid_binaries)}")
×
302

303
        should_be_bins = set(unrecognized_resolve_names) & set(all_valid_binaries)
2✔
304
        should_be_resolves = set(unrecognized_resolve_names) & set(all_valid_names)
2✔
305

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

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

314
        # "Did you mean?"
315
        for name in unrecognized_resolve_names:
2✔
316
            close_matches = difflib.get_close_matches(name, all_valid_names)
2✔
317
            if close_matches:
2✔
318
                suggestions = ", ".join(f"`{m}`" for m in close_matches)
1✔
319
                message.append(f"Did you mean: {suggestions} (for `{name}`)")
1✔
320

321
        super().__init__(softwrap("\n\n".join(message)))
2✔
322

323

324
class _ResolveProviderType(Enum):
12✔
325
    TOOL = 1
12✔
326
    USER = 2
12✔
327

328

329
@dataclass(frozen=True, order=True)
12✔
330
class _ResolveProvider:
12✔
331
    option_name: str
12✔
332

333

334
class AmbiguousResolveNamesError(Exception):
12✔
335
    def __init__(self, ambiguous_name: str, providers: set[_ResolveProvider]) -> None:
12✔
336
        msg = softwrap(
1✔
337
            f"""
338
            The same resolve name `{ambiguous_name}` is used by multiple options, which
339
            causes ambiguity: {providers}
340

341
            To fix, please update these options so that `{ambiguous_name}` is not used more
342
            than once.
343
            """
344
        )
345
        super().__init__(msg)
1✔
346

347

348
def _check_ambiguous_resolve_names(
12✔
349
    all_known_user_resolve_names: Iterable[KnownUserResolveNames],
350
) -> None:
351
    resolve_name_to_providers = defaultdict(set)
2✔
352
    for known_user_resolve_names in all_known_user_resolve_names:
2✔
353
        for resolve_name in known_user_resolve_names.names:
2✔
354
            resolve_name_to_providers[resolve_name].add(
2✔
355
                _ResolveProvider(known_user_resolve_names.option_name)
356
            )
357

358
    for resolve_name, providers in resolve_name_to_providers.items():
2✔
359
        if len(providers) > 1:
2✔
360
            raise AmbiguousResolveNamesError(resolve_name, providers)
1✔
361

362

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

371
    # If no resolves have been requested, we generate lockfiles for all user resolves
372
    if not requested_resolve_names:
2✔
373
        return [
1✔
374
            known_resolve_names.requested_resolve_names_cls(known_resolve_names.names)
375
            for known_resolve_names in all_known_user_resolve_names
376
        ]
377

378
    requested_user_resolve_names = []
2✔
379
    for known_resolve_names in all_known_user_resolve_names:
2✔
380
        requested = requested_resolve_names.intersection(known_resolve_names.names)
2✔
381
        if requested:
2✔
382
            requested_resolve_names -= requested
2✔
383
            requested_user_resolve_names.append(
2✔
384
                known_resolve_names.requested_resolve_names_cls(requested)
385
            )
386

387
    if requested_resolve_names:
2✔
388
        raise UnrecognizedResolveNamesError(
1✔
389
            unrecognized_resolve_names=sorted(requested_resolve_names),
390
            all_valid_names={
391
                *itertools.chain.from_iterable(
392
                    known_resolve_names.names
393
                    for known_resolve_names in all_known_user_resolve_names
394
                ),
395
            },
396
            description_of_origin="the option `--generate-lockfiles-resolve`",
397
        )
398

399
    return requested_user_resolve_names
2✔
400

401

402
def filter_lockfiles_for_unconfigured_exportable_tools(
12✔
403
    generate_lockfile_requests: Sequence[GenerateLockfile],
404
    exportabletools_by_name: dict[str, type[ExportableTool]],
405
    *,
406
    resolve_specified: bool,
407
) -> tuple[tuple[str, ...], tuple[GenerateLockfile, ...]]:
408
    """Filter lockfile requests for tools still using their default lockfiles."""
409

410
    valid_lockfiles = []
2✔
411
    errs = []
2✔
412

413
    for req in generate_lockfile_requests:
2✔
414
        if req.lockfile_dest != DEFAULT_TOOL_LOCKFILE:
2✔
415
            valid_lockfiles.append(req)
2✔
416
            continue
2✔
417

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

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

449
    return tuple(errs), tuple(valid_lockfiles)
2✔
450

451

452
class GenerateLockfilesSubsystem(GoalSubsystem):
12✔
453
    name = "generate-lockfiles"
12✔
454
    help = "Generate lockfiles for third-party dependencies."
12✔
455

456
    @classmethod
12✔
457
    def activated(cls, union_membership: UnionMembership) -> bool:
12✔
458
        return KnownUserResolveNamesRequest in union_membership
×
459

460
    resolve = StrListOption(
12✔
461
        advanced=False,
462
        help=softwrap(
463
            f"""
464
            Only generate lockfiles for the specified resolve(s).
465

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

471
            For example, you can run `{bin_name()} generate-lockfiles --resolve=black
472
            --resolve=pytest --resolve=data-science` to only generate lockfiles for those
473
            two tools and your resolve named `data-science`.
474

475
            If you specify an invalid resolve name, like 'fake', Pants will output all
476
            possible values.
477

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

491
            If a backend does not support syncing it will fall back to full regeneration of
492
            the lockfile, and this option will have no effect.
493

494
            Note that there may be edge cases where syncing will fail the next time it's run after
495
            options that affect lockfile generation are changed. In this case you may need to
496
            temporarily turn off `sync` and trigger a full regeneration of the lockfile.
497
            """
498
        ),
499
    )
500
    custom_command = StrOption(
12✔
501
        advanced=True,
502
        default=None,
503
        help=softwrap(
504
            f"""
505
            If set, lockfile metadata will say to run this command to regenerate the lockfile,
506
            rather than running `{bin_name()} generate-lockfiles --resolve=<name>` like normal.
507
            """
508
        ),
509
    )
510
    diff = BoolOption(
12✔
511
        default=True,
512
        help=softwrap(
513
            """
514
            Print a summary of changed distributions after generating the lockfile.
515
            """
516
        ),
517
    )
518
    diff_include_unchanged = BoolOption(
12✔
519
        default=False,
520
        help=softwrap(
521
            """
522
            Include unchanged distributions in the diff summary output. Implies `diff=true`.
523
            """
524
        ),
525
    )
526

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

531

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

536

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

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

576
    if tool_request_errors:
1✔
577
        raise ValueError("\n\n".join(tool_request_errors))
×
578

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

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

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

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

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

621
    return GenerateLockfilesGoal(exit_code=0)
1✔
622

623

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

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

631
    ret = default if default in request.environments else request.environments[0]
1✔
632

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

639
    return ret
1✔
640

641

642
# -----------------------------------------------------------------------------------------------
643
# Helpers for determining the resolve
644
# -----------------------------------------------------------------------------------------------
645

646

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

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

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

674
                {formatted_resolve_lists}
675

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

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

709
                Input targets:
710

711
                {bullet_list(sorted(t.address.spec for t in root_targets))}
712

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

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

725
        return NoCompatibleResolveException(
1✔
726
            softwrap(
727
                f"""
728
                {prefix}
729

730
                {bullet_list(deps_strings)}
731

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

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

743

744
def rules():
12✔
745
    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