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

pantsbuild / pants / 23177125175

17 Mar 2026 03:32AM UTC coverage: 52.677% (-40.3%) from 92.932%
23177125175

Pull #23177

github

web-flow
Merge 1824dfbf4 into 0b9fdfb0e
Pull Request #23177: Bump the gha-deps group across 1 directory with 4 updates

31687 of 60153 relevant lines covered (52.68%)

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
    `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
2✔
60
    lockfile_dest: str
2✔
61
    diff: bool
2✔
62

63

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

70

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

78

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

83

84
class UserGenerateLockfiles(Collection[GenerateLockfile]):
2✔
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
2✔
95
class KnownUserResolveNamesRequest:
2✔
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)
2✔
105
class KnownUserResolveNames:
2✔
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, ...]
2✔
113
    option_name: str
2✔
114
    requested_resolve_names_cls: type[RequestedUserResolveNames]
2✔
115

116

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

121

122
@union(in_scope_types=[EnvironmentName])
2✔
123
class RequestedUserResolveNames(DeduplicatedCollection[str]):
2✔
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
2✔
131

132

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

139

140
class PackageVersion(Protocol):
2✔
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
2✔
158
LockfilePackages = FrozenDict[PackageName, PackageVersion]
2✔
159
ChangedPackages = FrozenDict[PackageName, tuple[PackageVersion, PackageVersion]]
2✔
160

161

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

172
    @classmethod
2✔
173
    def create(
2✔
174
        cls, path: str, resolve_name: str, old: LockfilePackages, new: LockfilePackages
175
    ) -> LockfileDiff:
176
        diff = {
×
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(
×
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
2✔
194
    def __get_lockfile_packages(
2✔
195
        src: Mapping[str, PackageVersion], exclude: Iterable[str]
196
    ) -> LockfilePackages:
197
        return LockfilePackages(
×
198
            {name: version for name, version in src.items() if name not in exclude}
199
        )
200

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

210

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

217
    def print(self, diff: LockfileDiff) -> None:
2✔
218
        output = "\n".join(self.output_sections(diff))
×
219
        if not output:
×
220
            return
×
221
        self.console.print_stderr(
×
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]:
2✔
228
        if self.include_unchanged:
×
229
            yield from self.output_reqs("Unchanged dependencies", diff.unchanged, fg="blue")
×
230
        yield from self.output_changed("Upgraded dependencies", diff.upgraded)
×
231
        yield from self.output_changed("!! Downgraded dependencies !!", diff.downgraded)
×
232
        yield from self.output_reqs("Added dependencies", diff.added, fg="green", style="bold")
×
233
        yield from self.output_reqs("Removed dependencies", diff.removed, fg="magenta")
×
234

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

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

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

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

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

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

266
    _BUMPS = (
2✔
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]:
2✔
275
        for key, attrs in self._BUMPS:
×
276
            if key is None or getattr(prev, key, None) != getattr(curr, key, None):
×
277
                return attrs
×
278
        return {}  # Should never happen, but let's be safe.
×
279

280

281
DEFAULT_TOOL_LOCKFILE = "<default>"
2✔
282

283

284
class UnrecognizedResolveNamesError(Exception):
2✔
285
    def __init__(
2✔
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()
×
294

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

302
        message = [
×
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:
×
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)
×
310
        should_be_resolves = set(unrecognized_resolve_names) & set(all_valid_names)
×
311

312
        if should_be_bins:
×
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:
×
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:
×
322
            close_matches = difflib.get_close_matches(name, all_valid_names)
×
323
            if close_matches:
×
324
                suggestions = ", ".join(f"`{m}`" for m in close_matches)
×
325
                message.append(f"Did you mean: {suggestions} (for `{name}`)")
×
326

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

329

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

334

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

339

340
class AmbiguousResolveNamesError(Exception):
2✔
341
    def __init__(self, ambiguous_name: str, providers: set[_ResolveProvider]) -> None:
2✔
342
        msg = softwrap(
×
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)
×
352

353

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

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

368

369
def determine_resolves_to_generate(
2✔
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)
×
376

377
    # If no resolves have been requested, we generate lockfiles for all user resolves
378
    if not requested_resolve_names:
×
379
        return [
×
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 = []
×
385
    for known_resolve_names in all_known_user_resolve_names:
×
386
        requested = requested_resolve_names.intersection(known_resolve_names.names)
×
387
        if requested:
×
388
            requested_resolve_names -= requested
×
389
            requested_user_resolve_names.append(
×
390
                known_resolve_names.requested_resolve_names_cls(requested)
391
            )
392

393
    if requested_resolve_names:
×
394
        raise UnrecognizedResolveNamesError(
×
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
×
406

407

408
def filter_lockfiles_for_unconfigured_exportable_tools(
2✔
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 = []
×
417
    errs = []
×
418

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

424
        if req.resolve_name in exportabletools_by_name:
×
425
            if resolve_specified:
×
426
                # A user has asked us to generate a tool which is using a default lockfile
427
                errs.append(
×
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
×
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)
×
456

457

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

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

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

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

537

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

542

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

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

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

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

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

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

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

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

627
    return GenerateLockfilesGoal(exit_code=0)
×
628

629

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

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

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

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

645
    return ret
×
646

647

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

652

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

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

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

680
                {formatted_resolve_lists}
681

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

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

715
                Input targets:
716

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

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

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

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

736
                {bullet_list(deps_strings)}
737

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

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

749

750
def rules():
2✔
751
    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