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

pantsbuild / pants / 26380816428

25 May 2026 02:57AM UTC coverage: 52.312% (-40.6%) from 92.89%
26380816428

Pull #23368

github

web-flow
Merge 7410b48e1 into 7b1060c81
Pull Request #23368: Run Linux ARM CI on Depot runners (Cherry-pick of #23363)

31807 of 60802 relevant lines covered (52.31%)

1.05 hits per line

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

45.1
/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
2✔
5

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

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

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

35

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

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

45

46
@union(in_scope_types=[EnvironmentName])
2✔
47
@dataclass(frozen=True)
2✔
48
class GenerateLockfile:
2✔
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
2✔
59
    lockfile_dest: str
2✔
60
    diff: bool
2✔
61

62

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

69

70
@dataclass(frozen=True)
2✔
71
class GenerateLockfileWithEnvironments(GenerateLockfile):
2✔
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, ...]
2✔
76

77

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

82

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

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

92

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

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

102

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

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

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

115

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

120

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

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

129
    sort_input = True
2✔
130

131

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

138

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

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

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

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

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

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

155

156
PackageName = str
2✔
157
LockfilePackages = FrozenDict[PackageName, PackageVersion]
2✔
158
ChangedPackages = FrozenDict[PackageName, tuple[PackageVersion, PackageVersion]]
2✔
159

160

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

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

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

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

209

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

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

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

234
    def style(self, text: str, **kwargs) -> str:
2✔
235
        return cast(str, self.maybe_color(text, **kwargs))
×
236

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

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

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

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

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

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

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

279

280
DEFAULT_TOOL_LOCKFILE = "<default>"
2✔
281

282

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

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

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

308
        should_be_bins = set(unrecognized_resolve_names) & set(all_valid_binaries)
×
309
        should_be_resolves = set(unrecognized_resolve_names) & set(all_valid_names)
×
310

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

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

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

326
        super().__init__(softwrap("\n\n".join(message)))
×
327

328

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

333

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

338

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

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

352

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

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

367

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

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

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

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

404
    return requested_user_resolve_names
×
405

406

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

415
    valid_lockfiles = []
×
416
    errs = []
×
417

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

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

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

454
    return tuple(errs), tuple(valid_lockfiles)
×
455

456

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

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

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

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

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

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

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

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

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

532
    @property
2✔
533
    def request_diffs(self) -> bool:
2✔
534
        return self.diff or self.diff_include_unchanged
×
535

536

537
class GenerateLockfilesGoal(Goal):
2✔
538
    subsystem_cls = GenerateLockfilesSubsystem
2✔
539
    environment_behavior = Goal.EnvironmentBehavior.USES_ENVIRONMENTS
2✔
540

541

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

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

581
    if tool_request_errors:
×
582
        raise ValueError("\n\n".join(tool_request_errors))
×
583

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

593
    results = await concurrently(
×
594
        generate_lockfile(
595
            **implicitly(
596
                {
597
                    req: GenerateLockfile,
598
                    _preferred_environment(req, local_environment.val): EnvironmentName,
599
                }
600
            )
601
        )
602
        for req in all_requests
603
    )
604

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

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

616
    if diffs:
×
617
        diff_formatter = LockfileDiffPrinter(
×
618
            console=console,
619
            color=global_options.colors,
620
            include_unchanged=generate_lockfiles_subsystem.diff_include_unchanged,
621
        )
622
        for diff in diffs:
×
623
            diff_formatter.print(diff)
×
624
        console.print_stderr("\n")
×
625

626
    return GenerateLockfilesGoal(exit_code=0)
×
627

628

629
def _preferred_environment(request: GenerateLockfile, default: EnvironmentName) -> EnvironmentName:
2✔
630
    if not isinstance(request, GenerateLockfileWithEnvironments):
×
631
        return default  # This request has not been migrated to use environments.
×
632

633
    if len(request.environments) == 1:
×
634
        return request.environments[0]
×
635

636
    ret = default if default in request.environments else request.environments[0]
×
637

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

644
    return ret
×
645

646

647
# -----------------------------------------------------------------------------------------------
648
# Helpers for determining the resolve
649
# -----------------------------------------------------------------------------------------------
650

651

652
class NoCompatibleResolveException(Exception):
2✔
653
    """No compatible resolve could be found for a set of targets."""
654

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

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

679
                {formatted_resolve_lists}
680

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

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

714
                Input targets:
715

716
                {bullet_list(sorted(t.address.spec for t in root_targets))}
717

718
                Bad dependencies:
719
                """
720
            )
721
            change_input_targets_instructions = "of the input targets"
×
722

723
        deps_strings = []
×
724
        for dep in dependencies:
×
725
            maybe_resolve = maybe_get_resolve(dep)
×
726
            if maybe_resolve is None or maybe_resolve == root_resolve:
×
727
                continue
×
728
            deps_strings.append(f"{dep.address} ({maybe_resolve})")
×
729

730
        return NoCompatibleResolveException(
×
731
            softwrap(
732
                f"""
733
                {prefix}
734

735
                {bullet_list(deps_strings)}
736

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

743
                For more information, see {doc_url(doc_url_slug)}.
744
                """
745
            )
746
        )
747

748

749
def rules():
2✔
750
    return collect_rules()
×
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