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

pantsbuild / pants / 24638277381

19 Apr 2026 08:21PM UTC coverage: 52.377% (-40.5%) from 92.924%
24638277381

Pull #23274

github

web-flow
Merge 662e70df2 into 0283af69e
Pull Request #23274: rust: upgrade to v1.95.0

31658 of 60443 relevant lines covered (52.38%)

1.05 hits per line

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

50.36
/src/python/pants/backend/python/goals/lockfile.py
1
# Copyright 2021 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 itertools
2✔
7
import os.path
2✔
8
from collections import defaultdict
2✔
9
from dataclasses import dataclass
2✔
10
from operator import itemgetter
2✔
11

12
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
2✔
13
from pants.backend.python.subsystems.setup import PythonSetup
2✔
14
from pants.backend.python.target_types import (
2✔
15
    PythonRequirementFindLinksField,
16
    PythonRequirementResolveField,
17
    PythonRequirementsField,
18
)
19
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
2✔
20
from pants.backend.python.util_rules.lockfile_diff import _generate_python_lockfile_diff
2✔
21
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
2✔
22
from pants.backend.python.util_rules.pex import (
2✔
23
    CompletePlatforms,
24
    digest_complete_platform_addresses,
25
    find_interpreter,
26
)
27
from pants.backend.python.util_rules.pex_cli import PexCliProcess, maybe_log_pex_stderr
2✔
28
from pants.backend.python.util_rules.pex_environment import PexSubsystem
2✔
29
from pants.backend.python.util_rules.pex_requirements import (
2✔
30
    PexRequirements,
31
    ResolvePexConfig,
32
    ResolvePexConfigRequest,
33
    determine_resolve_pex_config,
34
)
35
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
2✔
36
from pants.core.goals.generate_lockfiles import (
2✔
37
    DEFAULT_TOOL_LOCKFILE,
38
    GenerateLockfile,
39
    GenerateLockfileResult,
40
    GenerateLockfilesSubsystem,
41
    KnownUserResolveNames,
42
    KnownUserResolveNamesRequest,
43
    RequestedUserResolveNames,
44
    UserGenerateLockfiles,
45
)
46
from pants.core.goals.resolves import ExportableTool
2✔
47
from pants.core.util_rules.lockfile_metadata import calculate_invalidation_digest
2✔
48
from pants.engine.addresses import UnparsedAddressInputs
2✔
49
from pants.engine.fs import (
2✔
50
    CreateDigest,
51
    Digest,
52
    FileContent,
53
    GlobExpansionConjunction,
54
    MergeDigests,
55
    PathGlobs,
56
)
57
from pants.engine.internals.native_engine import EMPTY_DIGEST
2✔
58
from pants.engine.internals.synthetic_targets import SyntheticAddressMaps, SyntheticTargetsRequest
2✔
59
from pants.engine.internals.target_adaptor import TargetAdaptor
2✔
60
from pants.engine.intrinsics import (
2✔
61
    create_digest,
62
    get_digest_contents,
63
    merge_digests,
64
    path_globs_to_digest,
65
)
66
from pants.engine.process import ProcessCacheScope, execute_process_or_raise
2✔
67
from pants.engine.rules import collect_rules, implicitly, rule
2✔
68
from pants.engine.target import AllTargets
2✔
69
from pants.engine.unions import UnionMembership, UnionRule
2✔
70
from pants.option.subsystem import _construct_subsystem
2✔
71
from pants.util.docutil import bin_name
2✔
72
from pants.util.logging import LogLevel
2✔
73
from pants.util.ordered_set import FrozenOrderedSet
2✔
74
from pants.util.pip_requirement import PipRequirement
2✔
75

76

77
@dataclass(frozen=True)
2✔
78
class GeneratePythonLockfile(GenerateLockfile):
2✔
79
    requirements: FrozenOrderedSet[str]
2✔
80
    find_links: FrozenOrderedSet[str]
2✔
81
    interpreter_constraints: InterpreterConstraints
2✔
82
    lock_style: str
2✔
83
    complete_platforms: tuple[str, ...]
2✔
84

85
    @property
2✔
86
    def requirements_hex_digest(self) -> str:
2✔
87
        """Produces a hex digest of the requirements input for this lockfile."""
88
        return calculate_invalidation_digest(self.requirements)
×
89

90

91
@dataclass(frozen=True)
2✔
92
class _PipArgsAndConstraintsSetup:
2✔
93
    resolve_config: ResolvePexConfig
2✔
94
    args: tuple[str, ...]
2✔
95
    digest: Digest
2✔
96

97

98
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
2✔
99
    resolve_config = await determine_resolve_pex_config(
×
100
        ResolvePexConfigRequest(resolve_name), **implicitly()
101
    )
102

103
    args = list(resolve_config.pex_args())
×
104
    digests: list[Digest] = []
×
105

106
    if resolve_config.constraints_file:
×
107
        args.append(f"--constraints={resolve_config.constraints_file.path}")
×
108
        digests.append(resolve_config.constraints_file.digest)
×
109

110
    input_digest = await merge_digests(MergeDigests(digests))
×
111
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
×
112

113

114
@rule(desc="Generate Python lockfile", level=LogLevel.DEBUG)
2✔
115
async def generate_lockfile(
2✔
116
    req: GeneratePythonLockfile,
117
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
118
    python_setup: PythonSetup,
119
    pex_subsystem: PexSubsystem,
120
) -> GenerateLockfileResult:
121
    if not req.requirements:
×
122
        raise ValueError(
×
123
            f"Cannot generate lockfile with no requirements. Please add some requirements to {req.resolve_name}."
124
        )
125

126
    pip_args_setup = await _setup_pip_args_and_constraints_file(req.resolve_name)
×
127
    header_delimiter = "//"
×
128

129
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
×
130

131
    # Resolve complete platform targets if specified
132
    complete_platforms: CompletePlatforms | None = None
×
133
    if req.complete_platforms:
×
134
        # Resolve target addresses to get platform JSON files
135
        complete_platforms = await digest_complete_platform_addresses(
×
136
            UnparsedAddressInputs(
137
                req.complete_platforms,
138
                owning_address=None,
139
                description_of_origin=f"the `[python].resolves_to_complete_platforms` for resolve `{req.resolve_name}`",
140
            )
141
        )
142

143
    # Add complete platforms if specified, otherwise use default target systems for universal locks
144
    if complete_platforms:
×
145
        target_system_args = tuple(
×
146
            f"--complete-platform={platform}" for platform in complete_platforms
147
        )
148
    elif req.lock_style == "universal":
×
149
        # PEX files currently only run on Linux and Mac machines; so we hard code this
150
        # limit on lock universality to avoid issues locking due to irrelevant
151
        # Windows-only dependency issues. See this Pex issue that originated from a
152
        # Pants user issue presented in Slack:
153
        #   https://github.com/pex-tool/pex/issues/1821
154
        #
155
        # Note: --target-system only applies to universal locks. For other lock styles
156
        # (strict, sources) without complete platforms, we don't specify platform args
157
        # and PEX will lock for the current platform only.
158
        target_system_args = (
×
159
            "--target-system",
160
            "linux",
161
            "--target-system",
162
            "mac",
163
        )
164
    else:
165
        # For non-universal lock styles without complete platforms, don't specify
166
        # platform arguments - PEX will lock for the current platform only
167
        target_system_args = ()
×
168

169
    if generate_lockfiles_subsystem.sync:
×
170
        existing_lockfile_digest = await path_globs_to_digest(
×
171
            PathGlobs(
172
                globs=(req.lockfile_dest,),
173
                # We ignore errors, since the lockfile may not exist.
174
                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
175
                conjunction=GlobExpansionConjunction.any_match,
176
            )
177
        )
178
    else:
179
        existing_lockfile_digest = EMPTY_DIGEST
×
180

181
    output_flag = "--lock" if generate_lockfiles_subsystem.sync else "--output"
×
182
    result = await execute_process_or_raise(
×
183
        **implicitly(
184
            PexCliProcess(
185
                subcommand=("lock", "sync" if generate_lockfiles_subsystem.sync else "create"),
186
                extra_args=(
187
                    f"{output_flag}={req.lockfile_dest}",
188
                    f"--style={req.lock_style}",
189
                    "--pip-version",
190
                    python_setup.pip_version,
191
                    "--resolver-version",
192
                    "pip-2020-resolver",
193
                    "--preserve-pip-download-log",
194
                    "pex-pip-download.log",
195
                    *target_system_args,
196
                    # This makes diffs more readable when lockfiles change.
197
                    "--indent=2",
198
                    f"--python-path={python.path}",
199
                    *(f"--find-links={link}" for link in req.find_links),
200
                    *pip_args_setup.args,
201
                    # When complete platforms are specified, don't pass interpreter constraints.
202
                    # The complete platforms already define Python versions and platforms.
203
                    # Passing both causes PEX to generate duplicate locked_requirements entries
204
                    # when the local platform matches a complete platform.
205
                    # TODO(#9560): Consider validating that these platforms are valid with the
206
                    #  interpreter constraints.
207
                    *(
208
                        req.interpreter_constraints.generate_pex_arg_list()
209
                        if not complete_platforms
210
                        else ()
211
                    ),
212
                    *(
213
                        f"--override={override}"
214
                        for override in pip_args_setup.resolve_config.overrides
215
                    ),
216
                    *req.requirements,
217
                ),
218
                additional_input_digest=await merge_digests(
219
                    MergeDigests(
220
                        [existing_lockfile_digest, pip_args_setup.digest]
221
                        + ([complete_platforms.digest] if complete_platforms else [])
222
                    )
223
                ),
224
                output_files=(req.lockfile_dest,),
225
                description=f"Generate lockfile for {req.resolve_name}",
226
                # Instead of caching lockfile generation with LMDB, we instead use the invalidation
227
                # scheme from `lockfile_metadata.py` to check for stale/invalid lockfiles. This is
228
                # necessary so that our invalidation is resilient to deleting LMDB or running on a
229
                # new machine.
230
                #
231
                # We disable caching with LMDB so that when you generate a lockfile, you always get
232
                # the most up-to-date snapshot of the world. This is generally desirable and also
233
                # necessary to avoid an awkward edge case where different developers generate
234
                # different lockfiles even when generating at the same time. See
235
                # https://github.com/pantsbuild/pants/issues/12591.
236
                cache_scope=ProcessCacheScope.PER_SESSION,
237
            )
238
        )
239
    )
240
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
241

242
    metadata = PythonLockfileMetadata.new(
×
243
        valid_for_interpreter_constraints=req.interpreter_constraints,
244
        requirements={
245
            PipRequirement.parse(
246
                i,
247
                description_of_origin=f"the lockfile {req.lockfile_dest} for the resolve {req.resolve_name}",
248
            )
249
            for i in req.requirements
250
        },
251
        manylinux=pip_args_setup.resolve_config.manylinux,
252
        requirement_constraints=(
253
            set(pip_args_setup.resolve_config.constraints_file.constraints)
254
            if pip_args_setup.resolve_config.constraints_file
255
            else set()
256
        ),
257
        only_binary=set(pip_args_setup.resolve_config.only_binary),
258
        no_binary=set(pip_args_setup.resolve_config.no_binary),
259
        excludes=set(pip_args_setup.resolve_config.excludes),
260
        overrides=set(pip_args_setup.resolve_config.overrides),
261
        sources=set(pip_args_setup.resolve_config.sources),
262
        lock_style=req.lock_style,
263
        complete_platforms=req.complete_platforms,
264
        uploaded_prior_to=pip_args_setup.resolve_config.uploaded_prior_to,
265
    )
266
    regenerate_command = (
×
267
        generate_lockfiles_subsystem.custom_command
268
        or f"{bin_name()} generate-lockfiles --resolve={req.resolve_name}"
269
    )
270
    if python_setup.separate_lockfile_metadata_file:
×
271
        descr = f"This lockfile was generated by Pants. To regenerate, run: {regenerate_command}"
×
272
        metadata_digest = await create_digest(
×
273
            CreateDigest(
274
                [
275
                    FileContent(
276
                        PythonLockfileMetadata.metadata_location_for_lockfile(req.lockfile_dest),
277
                        metadata.to_json(with_description=descr).encode(),
278
                    ),
279
                ]
280
            )
281
        )
282
        final_lockfile_digest = await merge_digests(
×
283
            MergeDigests([metadata_digest, result.output_digest])
284
        )
285
    else:
286
        initial_lockfile_digest_contents = await get_digest_contents(result.output_digest)
×
287
        lockfile_with_header = metadata.add_header_to_lockfile(
×
288
            initial_lockfile_digest_contents[0].content,
289
            regenerate_command=regenerate_command,
290
            delimeter=header_delimiter,
291
        )
292
        final_lockfile_digest = await create_digest(
×
293
            CreateDigest(
294
                [
295
                    FileContent(req.lockfile_dest, lockfile_with_header),
296
                ]
297
            )
298
        )
299

300
    if req.diff:
×
301
        diff = await _generate_python_lockfile_diff(
×
302
            final_lockfile_digest, req.resolve_name, req.lockfile_dest
303
        )
304
    else:
305
        diff = None
×
306

307
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
×
308

309

310
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
2✔
311
    pass
2✔
312

313

314
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
2✔
315
    pass
2✔
316

317

318
@rule
2✔
319
async def determine_python_user_resolves(
2✔
320
    _: KnownPythonUserResolveNamesRequest,
321
    python_setup: PythonSetup,
322
    union_membership: UnionMembership,
323
) -> KnownUserResolveNames:
324
    """Find all know Python resolves, from both user-created resolves and internal tools."""
325
    python_tool_resolves = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
326

327
    tools_using_default_resolve = [
×
328
        resolve_name
329
        for resolve_name, subsystem_cls in python_tool_resolves.items()
330
        if (await _construct_subsystem(subsystem_cls)).install_from_resolve is None
331
    ]
332

333
    return KnownUserResolveNames(
×
334
        names=(
335
            *python_setup.resolves.keys(),
336
            *tools_using_default_resolve,
337
        ),  # the order of the keys doesn't matter since shadowing is done in `setup_user_lockfile_requests`
338
        option_name="[python].resolves",
339
        requested_resolve_names_cls=RequestedPythonUserResolveNames,
340
    )
341

342

343
@rule
2✔
344
async def setup_user_lockfile_requests(
2✔
345
    requested: RequestedPythonUserResolveNames,
346
    all_targets: AllTargets,
347
    python_setup: PythonSetup,
348
    union_membership: UnionMembership,
349
) -> UserGenerateLockfiles:
350
    """Transform the names of resolves requested into the `GeneratePythonLockfile` request object.
351

352
    Shadowing is done here by only checking internal resolves if the resolve is not a user-created
353
    resolve.
354
    """
355
    if not (python_setup.enable_resolves and python_setup.resolves_generate_lockfiles):
×
356
        return UserGenerateLockfiles()
×
357

358
    resolve_to_requirements_fields = defaultdict(set)
×
359
    resolve_to_find_links: dict[str, set[str]] = defaultdict(set)
×
360
    for tgt in all_targets:
×
361
        if not tgt.has_fields((PythonRequirementResolveField, PythonRequirementsField)):
×
362
            continue
×
363
        resolve = tgt[PythonRequirementResolveField].normalized_value(python_setup)
×
364
        resolve_to_requirements_fields[resolve].add(tgt[PythonRequirementsField])
×
365
        resolve_to_find_links[resolve].update(tgt[PythonRequirementFindLinksField].value or ())
×
366

367
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
368

369
    out = set()
×
370
    for resolve in requested:
×
371
        if resolve in python_setup.resolves:
×
372
            out.add(
×
373
                GeneratePythonLockfile(
374
                    requirements=PexRequirements.req_strings_from_requirement_fields(
375
                        resolve_to_requirements_fields[resolve]
376
                    ),
377
                    find_links=FrozenOrderedSet(resolve_to_find_links[resolve]),
378
                    interpreter_constraints=InterpreterConstraints(
379
                        python_setup.resolves_to_interpreter_constraints.get(
380
                            resolve, python_setup.interpreter_constraints
381
                        )
382
                    ),
383
                    resolve_name=resolve,
384
                    lockfile_dest=python_setup.resolves[resolve],
385
                    diff=False,
386
                    lock_style=python_setup.resolves_to_lock_style().get(resolve, "universal"),
387
                    complete_platforms=tuple(
388
                        python_setup.resolves_to_complete_platforms().get(resolve, [])
389
                    ),
390
                )
391
            )
392
        else:
393
            tool_cls: type[PythonToolBase] = tools[resolve]
×
394
            tool = await _construct_subsystem(tool_cls)
×
395

396
            # TODO: we shouldn't be managing default ICs in lockfile identification.
397
            #   We should find a better place to do this or a better way to default
398
            if tool.register_interpreter_constraints:
×
399
                ic = tool.interpreter_constraints
×
400
            else:
401
                ic = InterpreterConstraints(tool.default_interpreter_constraints)
×
402

403
            out.add(
×
404
                GeneratePythonLockfile(
405
                    requirements=FrozenOrderedSet(sorted(tool.requirements)),
406
                    find_links=FrozenOrderedSet(),
407
                    interpreter_constraints=ic,
408
                    resolve_name=resolve,
409
                    lockfile_dest=DEFAULT_TOOL_LOCKFILE,
410
                    diff=False,
411
                    lock_style="universal",  # Tools always use universal style
412
                    complete_platforms=(),  # Tools don't use complete platforms
413
                )
414
            )
415

416
    return UserGenerateLockfiles(out)
×
417

418

419
@dataclass(frozen=True)
2✔
420
class PythonSyntheticLockfileTargetsRequest(SyntheticTargetsRequest):
2✔
421
    """Register the type used to create synthetic targets for Python lockfiles.
422

423
    As the paths for all lockfiles are known up-front, we set the `path` field to
424
    `SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS` so that we get a single request for all
425
    our synthetic targets rather than one request per directory.
426
    """
427

428
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
2✔
429

430

431
def synthetic_lockfile_target_name(resolve: str) -> str:
2✔
432
    return f"_{resolve}_lockfile"
×
433

434

435
@rule
2✔
436
async def python_lockfile_synthetic_targets(
2✔
437
    request: PythonSyntheticLockfileTargetsRequest,
438
    python_setup: PythonSetup,
439
) -> SyntheticAddressMaps:
440
    if not python_setup.enable_synthetic_lockfiles:
2✔
441
        return SyntheticAddressMaps()
2✔
442

443
    resolves = [
×
444
        (os.path.dirname(lockfile), os.path.basename(lockfile), name)
445
        for name, lockfile in python_setup.resolves.items()
446
    ]
447

448
    return SyntheticAddressMaps.for_targets_request(
×
449
        request,
450
        [
451
            (
452
                os.path.join(spec_path, "BUILD.python-lockfiles"),
453
                tuple(
454
                    TargetAdaptor(
455
                        "_lockfiles",
456
                        name=synthetic_lockfile_target_name(name),
457
                        sources=(lockfile,),
458
                        __description_of_origin__=f"the [python].resolves option {name!r}",
459
                    )
460
                    for _, lockfile, name in lockfiles
461
                ),
462
            )
463
            for spec_path, lockfiles in itertools.groupby(sorted(resolves), key=itemgetter(0))
464
        ],
465
    )
466

467

468
def rules():
2✔
469
    return (
2✔
470
        *collect_rules(),
471
        UnionRule(GenerateLockfile, GeneratePythonLockfile),
472
        UnionRule(KnownUserResolveNamesRequest, KnownPythonUserResolveNamesRequest),
473
        UnionRule(RequestedUserResolveNames, RequestedPythonUserResolveNames),
474
        UnionRule(SyntheticTargetsRequest, PythonSyntheticLockfileTargetsRequest),
475
    )
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