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

pantsbuild / pants / 18517631058

15 Oct 2025 04:18AM UTC coverage: 69.207% (-11.1%) from 80.267%
18517631058

Pull #22745

github

web-flow
Merge 642a76ca1 into 99919310e
Pull Request #22745: [windows] Add windows support in the stdio crate.

53815 of 77759 relevant lines covered (69.21%)

2.42 hits per line

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

50.54
/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
7✔
5

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

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

32
logger = logging.getLogger(__name__)
7✔
33

34

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

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

44

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

50
    Each language ecosystem should set up a subclass of `GenerateLockfile`, like
51
    `GeneratePythonLockfile` and `GenerateJVMLockfile`, and register a union rule. They should
52
    also set up a simple rule that goes from that class -> `WrappedGenerateLockfile`.
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
7✔
59
    lockfile_dest: str
7✔
60
    diff: bool
7✔
61

62

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

69

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

77

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

82

83
class UserGenerateLockfiles(Collection[GenerateLockfile]):
7✔
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
    `GeneratePythonLockfile`.
90
    """
91

92

93
@union
7✔
94
class KnownUserResolveNamesRequest:
7✔
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)
7✔
104
class KnownUserResolveNames:
7✔
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, ...]
7✔
112
    option_name: str
7✔
113
    requested_resolve_names_cls: type[RequestedUserResolveNames]
7✔
114

115

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

120

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

131

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

138

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

160

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

171
    @classmethod
7✔
172
    def create(
7✔
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
7✔
193
    def __get_lockfile_packages(
7✔
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
7✔
201
    def __get_changed_packages(
7✔
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):
7✔
211
    def __init__(self, console: Console, color: bool, include_unchanged: bool) -> None:
7✔
212
        super().__init__(color)
×
213
        self.console = console
×
214
        self.include_unchanged = include_unchanged
×
215

216
    def print(self, diff: LockfileDiff) -> None:
7✔
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]:
7✔
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:
7✔
235
        return cast(str, self.maybe_color(text, **kwargs))
×
236

237
    def title(self, text: str) -> str:
7✔
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]:
7✔
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]:
7✔
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 = (
7✔
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]:
7✔
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>"
7✔
281

282

283
class UnrecognizedResolveNamesError(Exception):
7✔
284
    def __init__(
7✔
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()
1✔
293

294
        # TODO(#12314): maybe implement "Did you mean?"
295
        if len(unrecognized_resolve_names) == 1:
1✔
296
            unrecognized_str = unrecognized_resolve_names[0]
1✔
297
            name_description = "name"
1✔
298
        else:
299
            unrecognized_str = str(sorted(unrecognized_resolve_names))
1✔
300
            name_description = "names"
1✔
301

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

312
        if should_be_bins:
1✔
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:
1✔
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
        super().__init__(softwrap("\n\n".join(message)))
1✔
321

322

323
class _ResolveProviderType(Enum):
7✔
324
    TOOL = 1
7✔
325
    USER = 2
7✔
326

327

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

332

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

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

346

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

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

361

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

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

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

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

398
    return requested_user_resolve_names
×
399

400

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

409
    valid_lockfiles = []
×
410
    errs = []
×
411

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

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

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

448
    return tuple(errs), tuple(valid_lockfiles)
×
449

450

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

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

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

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

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

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

477
            If not specified, Pants will generate lockfiles for all resolves.
478
            """
479
        ),
480
    )
481
    custom_command = StrOption(
7✔
482
        advanced=True,
483
        default=None,
484
        help=softwrap(
485
            f"""
486
            If set, lockfile metadata will say to run this command to regenerate the lockfile,
487
            rather than running `{bin_name()} generate-lockfiles --resolve=<name>` like normal.
488
            """
489
        ),
490
    )
491
    diff = BoolOption(
7✔
492
        default=True,
493
        help=softwrap(
494
            """
495
            Print a summary of changed distributions after generating the lockfile.
496
            """
497
        ),
498
    )
499
    diff_include_unchanged = BoolOption(
7✔
500
        default=False,
501
        help=softwrap(
502
            """
503
            Include unchanged distributions in the diff summary output. Implies `diff=true`.
504
            """
505
        ),
506
    )
507

508
    @property
7✔
509
    def request_diffs(self) -> bool:
7✔
510
        return self.diff or self.diff_include_unchanged
×
511

512

513
class GenerateLockfilesGoal(Goal):
7✔
514
    subsystem_cls = GenerateLockfilesSubsystem
7✔
515
    environment_behavior = Goal.EnvironmentBehavior.USES_ENVIRONMENTS
7✔
516

517

518
@goal_rule
7✔
519
async def generate_lockfiles_goal(
7✔
520
    workspace: Workspace,
521
    union_membership: UnionMembership,
522
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
523
    local_environment: ChosenLocalEnvironmentName,
524
    console: Console,
525
    global_options: GlobalOptions,
526
) -> GenerateLockfilesGoal:
527
    known_user_resolve_names = await concurrently(
×
528
        get_known_user_resolve_names(**implicitly({request(): KnownUserResolveNamesRequest}))
529
        for request in union_membership.get(KnownUserResolveNamesRequest)
530
    )
531
    requested_user_resolve_names = determine_resolves_to_generate(
×
532
        known_user_resolve_names,
533
        set(generate_lockfiles_subsystem.resolve),
534
    )
535

536
    # This is the "planning" phase of lockfile generation. Currently this is all done in the local
537
    # environment, since there's not currently a clear mechanism to prescribe an environment.
538
    all_specified_user_requests = await concurrently(
×
539
        get_user_generate_lockfiles(
540
            **implicitly(
541
                {resolve_names: RequestedUserResolveNames, local_environment.val: EnvironmentName}
542
            )
543
        )
544
        for resolve_names in requested_user_resolve_names
545
    )
546
    resolve_specified = bool(generate_lockfiles_subsystem.resolve)
×
547
    # We filter "user" requests because we're moving to combine user and tool lockfiles
548
    (
×
549
        tool_request_errors,
550
        applicable_user_requests,
551
    ) = filter_lockfiles_for_unconfigured_exportable_tools(
552
        list(itertools.chain(*all_specified_user_requests)),
553
        {e.options_scope: e for e in union_membership.get(ExportableTool)},
554
        resolve_specified=resolve_specified,
555
    )
556

557
    if tool_request_errors:
×
558
        raise ValueError("\n\n".join(tool_request_errors))
×
559

560
    # Execute the actual lockfile generation in each request's environment.
561
    # Currently, since resolves specify a single filename for output, we pick a reasonable
562
    # environment to execute the request in. Currently we warn if multiple environments are
563
    # specified.
564
    if generate_lockfiles_subsystem.request_diffs:
×
565
        all_requests = tuple(replace(req, diff=True) for req in applicable_user_requests)
×
566
    else:
567
        all_requests = applicable_user_requests
×
568

569
    results = await concurrently(
×
570
        generate_lockfile(
571
            **implicitly(
572
                {
573
                    req: GenerateLockfile,
574
                    _preferred_environment(req, local_environment.val): EnvironmentName,
575
                }
576
            )
577
        )
578
        for req in all_requests
579
    )
580

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

586
    diffs: list[LockfileDiff] = []
×
587
    for result in results:
×
588
        logger.info(f"Wrote lockfile for the resolve `{result.resolve_name}` to {result.path}")
×
589
        if result.diff is not None:
×
590
            diffs.append(result.diff)
×
591

592
    if diffs:
×
593
        diff_formatter = LockfileDiffPrinter(
×
594
            console=console,
595
            color=global_options.colors,
596
            include_unchanged=generate_lockfiles_subsystem.diff_include_unchanged,
597
        )
598
        for diff in diffs:
×
599
            diff_formatter.print(diff)
×
600
        console.print_stderr("\n")
×
601

602
    return GenerateLockfilesGoal(exit_code=0)
×
603

604

605
def _preferred_environment(request: GenerateLockfile, default: EnvironmentName) -> EnvironmentName:
7✔
606
    if not isinstance(request, GenerateLockfileWithEnvironments):
×
607
        return default  # This request has not been migrated to use environments.
×
608

609
    if len(request.environments) == 1:
×
610
        return request.environments[0]
×
611

612
    ret = default if default in request.environments else request.environments[0]
×
613

614
    logger.warning(
×
615
        f"The `{request.__class__.__name__}` for resolve `{request.resolve_name}` specifies more "
616
        "than one environment. Pants will generate the lockfile using only the environment "
617
        f"`{ret.val}`, which may have unintended effects when executing in the other environments."
618
    )
619

620
    return ret
×
621

622

623
# -----------------------------------------------------------------------------------------------
624
# Helpers for determining the resolve
625
# -----------------------------------------------------------------------------------------------
626

627

628
class NoCompatibleResolveException(Exception):
7✔
629
    """No compatible resolve could be found for a set of targets."""
630

631
    @classmethod
7✔
632
    def bad_input_roots(
7✔
633
        cls,
634
        targets: Iterable[Target],
635
        *,
636
        maybe_get_resolve: Callable[[Target], str | None],
637
        doc_url_slug: str,
638
        workaround: str | None,
639
    ) -> NoCompatibleResolveException:
640
        resolves_to_addresses = defaultdict(list)
×
641
        for tgt in targets:
×
642
            maybe_resolve = maybe_get_resolve(tgt)
×
643
            if maybe_resolve is not None:
×
644
                resolves_to_addresses[maybe_resolve].append(tgt.address.spec)
×
645

646
        formatted_resolve_lists = "\n\n".join(
×
647
            f"{resolve}:\n{bullet_list(sorted(addresses))}"
648
            for resolve, addresses in sorted(resolves_to_addresses.items())
649
        )
650
        return NoCompatibleResolveException(
×
651
            softwrap(
652
                f"""
653
                The input targets did not have a resolve in common.
654

655
                {formatted_resolve_lists}
656

657
                Targets used together must use the same resolve, set by the `resolve` field. For more
658
                information on 'resolves' (lockfiles), see {doc_url(doc_url_slug)}.
659
                """
660
            )
661
            + (f"\n\n{workaround}" if workaround else "")
662
        )
663

664
    @classmethod
7✔
665
    def bad_dependencies(
7✔
666
        cls,
667
        *,
668
        maybe_get_resolve: Callable[[Target], str | None],
669
        doc_url_slug: str,
670
        root_resolve: str,
671
        root_targets: Sequence[Target],
672
        dependencies: Iterable[Target],
673
    ) -> NoCompatibleResolveException:
674
        if len(root_targets) == 1:
×
675
            addr = root_targets[0].address
×
676
            prefix = softwrap(
×
677
                f"""
678
                The target {addr} uses the `resolve` `{root_resolve}`, but some of its
679
                dependencies are not compatible with that resolve:
680
                """
681
            )
682
            change_input_targets_instructions = f"of the target {addr}"
×
683
        else:
684
            assert root_targets
×
685
            prefix = softwrap(
×
686
                f"""
687
                The input targets use the `resolve` `{root_resolve}`, but some of their
688
                dependencies are not compatible with that resolve.
689

690
                Input targets:
691

692
                {bullet_list(sorted(t.address.spec for t in root_targets))}
693

694
                Bad dependencies:
695
                """
696
            )
697
            change_input_targets_instructions = "of the input targets"
×
698

699
        deps_strings = []
×
700
        for dep in dependencies:
×
701
            maybe_resolve = maybe_get_resolve(dep)
×
702
            if maybe_resolve is None or maybe_resolve == root_resolve:
×
703
                continue
×
704
            deps_strings.append(f"{dep.address} ({maybe_resolve})")
×
705

706
        return NoCompatibleResolveException(
×
707
            softwrap(
708
                f"""
709
                {prefix}
710

711
                {bullet_list(deps_strings)}
712

713
                All dependencies must work with the same `resolve`. To fix this, either change
714
                the `resolve=` field on those dependencies to `{root_resolve}`, or change
715
                the `resolve=` {change_input_targets_instructions}. If those dependencies should
716
                work with multiple resolves, use the `parametrize` mechanism with the `resolve=`
717
                field or manually create multiple targets for the same entity.
718

719
                For more information, see {doc_url(doc_url_slug)}.
720
                """
721
            )
722
        )
723

724

725
def rules():
7✔
726
    return collect_rules()
3✔
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