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

pantsbuild / pants / 24055979590

06 Apr 2026 11:17PM UTC coverage: 52.37% (-40.5%) from 92.908%
24055979590

Pull #23225

github

web-flow
Merge 67474653c into 542ca048d
Pull Request #23225: Add --test-show-all-batch-targets to expose all targets in batched pytest

6 of 17 new or added lines in 2 files covered. (35.29%)

23030 existing lines in 605 files now uncovered.

31643 of 60422 relevant lines covered (52.37%)

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:
UNCOV
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
        }
UNCOV
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:
UNCOV
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:
UNCOV
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✔
UNCOV
213
        super().__init__(color)
×
UNCOV
214
        self.console = console
×
UNCOV
215
        self.include_unchanged = include_unchanged
×
216

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

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

238
    def title(self, text: str) -> str:
2✔
UNCOV
239
        heading = f"== {text:^60} =="
×
UNCOV
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✔
UNCOV
243
        if not reqs:
×
244
            return
×
245

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

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

UNCOV
256
        yield self.title(title)
×
UNCOV
257
        label = "-->"
×
UNCOV
258
        for name, (prev, curr) in reqs.items():
×
UNCOV
259
            bump_attrs = self.get_bump_attrs(prev, curr)
×
UNCOV
260
            name_s = self.style(f"{name:30}", fg="yellow")
×
UNCOV
261
            prev_s = self.style(f"{str(prev):10}", fg="cyan")
×
UNCOV
262
            bump_s = self.style(f"{label:^7}", **bump_attrs)
×
UNCOV
263
            curr_s = self.style(str(curr), **bump_attrs)
×
UNCOV
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✔
UNCOV
275
        for key, attrs in self._BUMPS:
×
UNCOV
276
            if key is None or getattr(prev, key, None) != getattr(curr, key, None):
×
UNCOV
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:
UNCOV
293
        all_valid_binaries = all_valid_binaries or set()
×
294

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

UNCOV
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
        ]
UNCOV
306
        if all_valid_binaries:
×
307
            message.append(f"All valid exportable binaries: {sorted(all_valid_binaries)}")
×
308

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

UNCOV
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

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

UNCOV
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✔
UNCOV
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
        )
UNCOV
351
        super().__init__(msg)
×
352

353

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

UNCOV
364
    for resolve_name, providers in resolve_name_to_providers.items():
×
UNCOV
365
        if len(providers) > 1:
×
UNCOV
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.
UNCOV
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
UNCOV
378
    if not requested_resolve_names:
×
UNCOV
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

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

UNCOV
393
    if requested_resolve_names:
×
UNCOV
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

UNCOV
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

UNCOV
416
    valid_lockfiles = []
×
UNCOV
417
    errs = []
×
418

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

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

UNCOV
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✔
UNCOV
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:
UNCOV
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
    )
UNCOV
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.
UNCOV
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
    )
UNCOV
571
    resolve_specified = bool(generate_lockfiles_subsystem.resolve)
×
572
    # We filter "user" requests because we're moving to combine user and tool lockfiles
UNCOV
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

UNCOV
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.
UNCOV
589
    if generate_lockfiles_subsystem.request_diffs:
×
UNCOV
590
        all_requests = tuple(replace(req, diff=True) for req in applicable_user_requests)
×
591
    else:
592
        all_requests = applicable_user_requests
×
593

UNCOV
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.
UNCOV
608
    merged_digest = await merge_digests(MergeDigests(res.digest for res in results))
×
UNCOV
609
    workspace.write_digest(merged_digest)
×
610

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

UNCOV
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

UNCOV
627
    return GenerateLockfilesGoal(exit_code=0)
×
628

629

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

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

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

UNCOV
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

UNCOV
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:
UNCOV
665
        resolves_to_addresses = defaultdict(list)
×
UNCOV
666
        for tgt in targets:
×
UNCOV
667
            maybe_resolve = maybe_get_resolve(tgt)
×
UNCOV
668
            if maybe_resolve is not None:
×
UNCOV
669
                resolves_to_addresses[maybe_resolve].append(tgt.address.spec)
×
670

UNCOV
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
        )
UNCOV
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:
UNCOV
699
        if len(root_targets) == 1:
×
UNCOV
700
            addr = root_targets[0].address
×
UNCOV
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
            )
UNCOV
707
            change_input_targets_instructions = f"of the target {addr}"
×
708
        else:
UNCOV
709
            assert root_targets
×
UNCOV
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
            )
UNCOV
722
            change_input_targets_instructions = "of the input targets"
×
723

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

UNCOV
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✔
UNCOV
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