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

pantsbuild / pants / 25405422172

05 May 2026 10:18PM UTC coverage: 92.879% (-0.07%) from 92.944%
25405422172

Pull #23319

github

web-flow
Merge c82d0f333 into e8b784f89
Pull Request #23319: [pants_ng] Scaffolding for a pants_ng mode.

25 of 76 new or added lines in 9 files covered. (32.89%)

209 existing lines in 15 files now uncovered.

92234 of 99306 relevant lines covered (92.88%)

4.05 hits per line

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

88.52
/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
12✔
5

6
import itertools
12✔
7
import os.path
12✔
8
from collections import defaultdict
12✔
9
from dataclasses import dataclass
12✔
10
from operator import itemgetter
12✔
11

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

78

79
@dataclass(frozen=True)
12✔
80
class GeneratePexLockfile(GenerateLockfile):
12✔
81
    requirements: FrozenOrderedSet[str]
12✔
82
    find_links: FrozenOrderedSet[str]
12✔
83
    interpreter_constraints: InterpreterConstraints
12✔
84
    lock_style: str
12✔
85
    complete_platforms: tuple[str, ...]
12✔
86

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

92

93
@dataclass(frozen=True)
12✔
94
class GenerateUvLockfile(GenerateLockfile):
12✔
95
    requirements: FrozenOrderedSet[str]
12✔
96
    find_links: FrozenOrderedSet[str]
12✔
97
    interpreter_constraints: InterpreterConstraints
12✔
98

99
    @property
12✔
100
    def requirements_hex_digest(self) -> str:
12✔
101
        """Produces a hex digest of the requirements input for this lockfile."""
UNCOV
102
        return calculate_invalidation_digest(self.requirements)
×
103

104

105
@dataclass(frozen=True)
12✔
106
class _PipArgsAndConstraintsSetup:
12✔
107
    resolve_config: ResolveConfig
12✔
108
    args: tuple[str, ...]
12✔
109
    digest: Digest
12✔
110

111

112
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
12✔
113
    resolve_config = await determine_resolve_config(
2✔
114
        ResolveConfigRequest(resolve_name), **implicitly()
115
    )
116

117
    args = list(resolve_config.pex_args())
2✔
118
    digests: list[Digest] = []
2✔
119

120
    if resolve_config.constraints_file:
2✔
121
        args.append(f"--constraints={resolve_config.constraints_file.path}")
1✔
122
        digests.append(resolve_config.constraints_file.digest)
1✔
123

124
    input_digest = await merge_digests(MergeDigests(digests))
2✔
125
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
2✔
126

127

128
@rule(desc="Generate Pex lockfile", level=LogLevel.DEBUG)
12✔
129
async def generate_pex_lockfile(
12✔
130
    req: GeneratePexLockfile,
131
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
132
    python_setup: PythonSetup,
133
    pex_subsystem: PexSubsystem,
134
) -> GenerateLockfileResult:
135
    if not req.requirements:
2✔
136
        raise ValueError(
1✔
137
            f"Cannot generate lockfile with no requirements. Please add some requirements to {req.resolve_name}."
138
        )
139

140
    pip_args_setup = await _setup_pip_args_and_constraints_file(req.resolve_name)
2✔
141
    header_delimiter = "//"
2✔
142

143
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
2✔
144

145
    # Resolve complete platform targets if specified
146
    complete_platforms: CompletePlatforms | None = None
2✔
147
    if req.complete_platforms:
2✔
148
        # Resolve target addresses to get platform JSON files
UNCOV
149
        complete_platforms = await digest_complete_platform_addresses(
×
150
            UnparsedAddressInputs(
151
                req.complete_platforms,
152
                owning_address=None,
153
                description_of_origin=f"the `[python].resolves_to_complete_platforms` for resolve `{req.resolve_name}`",
154
            )
155
        )
156

157
    # Add complete platforms if specified, otherwise use default target systems for universal locks
158
    if complete_platforms:
2✔
UNCOV
159
        target_system_args = tuple(
×
160
            f"--complete-platform={platform}" for platform in complete_platforms
161
        )
162
    elif req.lock_style == "universal":
2✔
163
        # PEX files currently only run on Linux and Mac machines; so we hard code this
164
        # limit on lock universality to avoid issues locking due to irrelevant
165
        # Windows-only dependency issues. See this Pex issue that originated from a
166
        # Pants user issue presented in Slack:
167
        #   https://github.com/pex-tool/pex/issues/1821
168
        #
169
        # Note: --target-system only applies to universal locks. For other lock styles
170
        # (strict, sources) without complete platforms, we don't specify platform args
171
        # and PEX will lock for the current platform only.
172
        target_system_args = (
2✔
173
            "--target-system",
174
            "linux",
175
            "--target-system",
176
            "mac",
177
        )
178
    else:
179
        # For non-universal lock styles without complete platforms, don't specify
180
        # platform arguments - PEX will lock for the current platform only
UNCOV
181
        target_system_args = ()
×
182

183
    if generate_lockfiles_subsystem.sync:
2✔
UNCOV
184
        existing_lockfile_digest = await path_globs_to_digest(
×
185
            PathGlobs(
186
                globs=(req.lockfile_dest,),
187
                # We ignore errors, since the lockfile may not exist.
188
                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
189
                conjunction=GlobExpansionConjunction.any_match,
190
            )
191
        )
192
    else:
193
        existing_lockfile_digest = EMPTY_DIGEST
2✔
194

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

256
    metadata = PythonLockfileMetadata.new(
2✔
257
        valid_for_interpreter_constraints=req.interpreter_constraints,
258
        requirements={
259
            PipRequirement.parse(
260
                i,
261
                description_of_origin=f"the lockfile {req.lockfile_dest} for the resolve {req.resolve_name}",
262
            )
263
            for i in req.requirements
264
        },
265
        manylinux=pip_args_setup.resolve_config.manylinux,
266
        requirement_constraints=(
267
            set(pip_args_setup.resolve_config.constraints_file.constraints)
268
            if pip_args_setup.resolve_config.constraints_file
269
            else set()
270
        ),
271
        only_binary=set(pip_args_setup.resolve_config.only_binary),
272
        no_binary=set(pip_args_setup.resolve_config.no_binary),
273
        excludes=set(pip_args_setup.resolve_config.excludes),
274
        overrides=set(pip_args_setup.resolve_config.overrides),
275
        sources=set(pip_args_setup.resolve_config.sources),
276
        lock_style=req.lock_style,
277
        complete_platforms=req.complete_platforms,
278
        uploaded_prior_to=pip_args_setup.resolve_config.uploaded_prior_to,
279
        lockfile_format=LockfileFormat.PEX,
280
        resolve=req.resolve_name,
281
    )
282
    regenerate_command = (
2✔
283
        generate_lockfiles_subsystem.custom_command
284
        or f"{bin_name()} generate-lockfiles --resolve={req.resolve_name}"
285
    )
286
    if python_setup.separate_lockfile_metadata_file:
2✔
287
        descr = f"This lockfile was generated by Pants. To regenerate, run: {regenerate_command}"
1✔
288
        metadata_digest = await create_digest(
1✔
289
            CreateDigest(
290
                [
291
                    FileContent(
292
                        PythonLockfileMetadata.metadata_location_for_lockfile(req.lockfile_dest),
293
                        metadata.to_json(with_description=descr).encode(),
294
                    ),
295
                ]
296
            )
297
        )
298
        final_lockfile_digest = await merge_digests(
1✔
299
            MergeDigests([metadata_digest, result.output_digest])
300
        )
301
    else:
302
        initial_lockfile_digest_contents = await get_digest_contents(result.output_digest)
2✔
303
        lockfile_with_header = metadata.add_header_to_lockfile(
2✔
304
            initial_lockfile_digest_contents[0].content,
305
            regenerate_command=regenerate_command,
306
            delimeter=header_delimiter,
307
        )
308
        final_lockfile_digest = await create_digest(
2✔
309
            CreateDigest(
310
                [
311
                    FileContent(req.lockfile_dest, lockfile_with_header),
312
                ]
313
            )
314
        )
315

316
    if req.diff:
2✔
UNCOV
317
        diff = await _generate_lockfile_diff(
×
318
            final_lockfile_digest, req.resolve_name, req.lockfile_dest, LockfileFormat.PEX
319
        )
320
    else:
321
        diff = None
2✔
322

323
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
2✔
324

325

326
@rule(desc="Generate uv lockfile", level=LogLevel.DEBUG)
12✔
327
async def generate_uv_lockfile(
12✔
328
    req: GenerateUvLockfile,
329
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
330
    downloaded_uv: DownloadedUv,
331
    uv_env: UvEnvironment,
332
) -> GenerateLockfileResult:
333
    if not req.interpreter_constraints:
2✔
UNCOV
334
        raise ValueError(
×
335
            f"Cannot generate uv lockfile for resolve {req.resolve_name} with no interpreter "
336
            "constraints. Please set `interpreter_constraints` for this resolve."
337
        )
338

339
    resolve_config = await determine_resolve_config(
2✔
340
        ResolveConfigRequest(req.resolve_name), **implicitly()
341
    )
342
    resolve_config.validate_for_uv(req.resolve_name)
2✔
343

344
    pyproject_content = generate_pyproject_toml(
2✔
345
        req.resolve_name, req.interpreter_constraints, req.requirements
346
    )
347

348
    if generate_lockfiles_subsystem.sync:
2✔
349
        # `uv lock` does a minimal update by default if an existing lockfile is present.
350
        # So we just need to make sure it is. There are no special flags to specify.
UNCOV
351
        existing_lockfile_digest = await path_globs_to_digest(
×
352
            PathGlobs(
353
                globs=(req.lockfile_dest,),
354
                # We ignore errors, since the lockfile may not exist.
355
                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
356
                conjunction=GlobExpansionConjunction.any_match,
357
            )
358
        )
359
    else:
360
        existing_lockfile_digest = EMPTY_DIGEST
2✔
361

362
    # uv always writes the lockfile to `uv.lock` in the project directory. We capture that
363
    # and rename it to req.lockfile_dest in the final digest.
364
    uv_lock_output = "uv.lock"
2✔
365
    uv_config = resolve_config.uv_config(extra_find_links=req.find_links)
2✔
366

367
    uv_config_digest = await create_digest(
2✔
368
        CreateDigest(
369
            [
370
                FileContent("pyproject.toml", pyproject_content.encode()),
371
                FileContent("uv.toml", uv_config.encode()),
372
            ]
373
        )
374
    )
375

376
    input_digest = await merge_digests(
2✔
377
        MergeDigests([downloaded_uv.digest, uv_config_digest, existing_lockfile_digest])
378
    )
379

380
    result = await execute_process_or_raise(
2✔
381
        **implicitly(
382
            Process(
383
                argv=(
384
                    *downloaded_uv.args(),
385
                    "lock",
386
                ),
387
                env=uv_env.env,
388
                input_digest=input_digest,
389
                output_files=(uv_lock_output,),
390
                append_only_caches=downloaded_uv.append_only_caches(),
391
                description=f"Generate uv lockfile for {req.resolve_name}",
392
                # We disable persistent caching so that when you generate a lockfile, you always
393
                # get the most up-to-date snapshot of the world.
394
                cache_scope=ProcessCacheScope.PER_SESSION,
395
            )
396
        )
397
    )
398

399
    # Rename uv.lock to the configured lockfile destination.
400
    uv_lock_contents = await get_digest_contents(result.output_digest)
2✔
401
    uv_lock_digest = await create_digest(
2✔
402
        CreateDigest([FileContent(req.lockfile_dest, next(iter(uv_lock_contents)).content)])
403
    )
404

405
    regenerate_command = (
2✔
406
        generate_lockfiles_subsystem.custom_command
407
        or f"{bin_name()} generate-lockfiles --resolve={req.resolve_name}"
408
    )
409
    descr = f"This lockfile was generated by Pants. To regenerate, run: {regenerate_command}"
2✔
410
    metadata = PythonLockfileMetadata.new(
2✔
411
        valid_for_interpreter_constraints=req.interpreter_constraints,
412
        requirements={
413
            PipRequirement.parse(
414
                r,
415
                description_of_origin=f"the lockfile {req.lockfile_dest} for the resolve {req.resolve_name}",
416
            )
417
            for r in req.requirements
418
        },
419
        manylinux=None,
420
        requirement_constraints=set(),
421
        only_binary=set(resolve_config.only_binary),
422
        no_binary=set(resolve_config.no_binary),
423
        excludes=set(),
424
        overrides=set(),
425
        sources=set(),
426
        lock_style="universal",
427
        complete_platforms=(),
428
        uploaded_prior_to=resolve_config.uploaded_prior_to,
429
        lockfile_format=LockfileFormat.UV,
430
        resolve=req.resolve_name,
431
    )
432
    metadata_digest = await create_digest(
2✔
433
        CreateDigest(
434
            [
435
                FileContent(
436
                    PythonLockfileMetadata.metadata_location_for_lockfile(req.lockfile_dest),
437
                    metadata.to_json(with_description=descr).encode(),
438
                ),
439
            ]
440
        )
441
    )
442
    final_lockfile_digest = await merge_digests(MergeDigests([metadata_digest, uv_lock_digest]))
2✔
443

444
    if req.diff:
2✔
UNCOV
445
        diff = await _generate_lockfile_diff(
×
446
            final_lockfile_digest, req.resolve_name, req.lockfile_dest, LockfileFormat.UV
447
        )
448
    else:
449
        diff = None
2✔
450

451
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
2✔
452

453

454
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
12✔
455
    pass
12✔
456

457

458
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
12✔
459
    pass
12✔
460

461

462
@rule
12✔
463
async def determine_python_user_resolves(
12✔
464
    _: KnownPythonUserResolveNamesRequest,
465
    python_setup: PythonSetup,
466
    union_membership: UnionMembership,
467
) -> KnownUserResolveNames:
468
    """Find all know Python resolves, from both user-created resolves and internal tools."""
469
    python_tool_resolves = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
1✔
470

471
    tools_using_default_resolve = [
1✔
472
        resolve_name
473
        for resolve_name, subsystem_cls in python_tool_resolves.items()
474
        if (await _construct_subsystem(subsystem_cls)).install_from_resolve is None
475
    ]
476

477
    return KnownUserResolveNames(
1✔
478
        names=(
479
            *python_setup.resolves.keys(),
480
            *tools_using_default_resolve,
481
        ),  # the order of the keys doesn't matter since shadowing is done in `setup_user_lockfile_requests`
482
        option_name="[python].resolves",
483
        requested_resolve_names_cls=RequestedPythonUserResolveNames,
484
    )
485

486

487
@rule
12✔
488
async def setup_user_lockfile_requests(
12✔
489
    requested: RequestedPythonUserResolveNames,
490
    all_targets: AllTargets,
491
    python_setup: PythonSetup,
492
    union_membership: UnionMembership,
493
) -> UserGenerateLockfiles:
494
    """Transform the names of resolves requested into the appropriate lockfile request object.
495

496
    Shadowing is done here by only checking internal resolves if the resolve is not a user-created
497
    resolve.
498
    """
499
    if not (python_setup.enable_resolves and python_setup.resolves_generate_lockfiles):
1✔
UNCOV
500
        return UserGenerateLockfiles()
×
501

502
    resolve_to_requirements_fields = defaultdict(set)
1✔
503
    resolve_to_find_links: dict[str, set[str]] = defaultdict(set)
1✔
504
    for tgt in all_targets:
1✔
505
        if not tgt.has_fields((PythonRequirementResolveField, PythonRequirementsField)):
1✔
UNCOV
506
            continue
×
507
        resolve = tgt[PythonRequirementResolveField].normalized_value(python_setup)
1✔
508
        resolve_to_requirements_fields[resolve].add(tgt[PythonRequirementsField])
1✔
509
        resolve_to_find_links[resolve].update(tgt[PythonRequirementFindLinksField].value or ())
1✔
510

511
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
1✔
512

513
    out: set[GenerateLockfile] = set()
1✔
514
    for resolve in requested:
1✔
515
        if resolve in python_setup.resolves:
1✔
516
            requirements = PexRequirements.req_strings_from_requirement_fields(
1✔
517
                resolve_to_requirements_fields[resolve]
518
            )
519
            find_links = FrozenOrderedSet(resolve_to_find_links[resolve])
1✔
520
            interpreter_constraints = InterpreterConstraints(
1✔
521
                python_setup.resolves_to_interpreter_constraints.get(
522
                    resolve, python_setup.interpreter_constraints
523
                )
524
            )
525
            lockfile_dest = python_setup.resolves[resolve]
1✔
526
            if python_setup.resolver == Resolver.uv:
1✔
UNCOV
527
                out.add(
×
528
                    GenerateUvLockfile(
529
                        requirements=requirements,
530
                        find_links=find_links,
531
                        interpreter_constraints=interpreter_constraints,
532
                        resolve_name=resolve,
533
                        lockfile_dest=lockfile_dest,
534
                        diff=False,
535
                    )
536
                )
537
            else:
538
                out.add(
1✔
539
                    GeneratePexLockfile(
540
                        requirements=requirements,
541
                        find_links=find_links,
542
                        interpreter_constraints=interpreter_constraints,
543
                        resolve_name=resolve,
544
                        lockfile_dest=lockfile_dest,
545
                        diff=False,
546
                        lock_style=python_setup.resolves_to_lock_style().get(resolve, "universal"),
547
                        complete_platforms=tuple(
548
                            python_setup.resolves_to_complete_platforms().get(resolve, [])
549
                        ),
550
                    )
551
                )
552
        else:
UNCOV
553
            tool_cls: type[PythonToolBase] = tools[resolve]
×
UNCOV
554
            tool = await _construct_subsystem(tool_cls)
×
555

556
            # TODO: we shouldn't be managing default ICs in lockfile identification.
557
            #   We should find a better place to do this or a better way to default
UNCOV
558
            if tool.register_interpreter_constraints:
×
UNCOV
559
                ic = tool.interpreter_constraints
×
560
            else:
UNCOV
561
                ic = InterpreterConstraints(tool.default_interpreter_constraints)
×
562

UNCOV
563
            if python_setup.resolver == Resolver.uv:
×
UNCOV
564
                out.add(
×
565
                    GenerateUvLockfile(
566
                        requirements=FrozenOrderedSet(sorted(tool.requirements)),
567
                        find_links=FrozenOrderedSet(),
568
                        interpreter_constraints=ic,
569
                        resolve_name=resolve,
570
                        lockfile_dest=DEFAULT_TOOL_LOCKFILE,
571
                        diff=False,
572
                    )
573
                )
574
            else:
UNCOV
575
                out.add(
×
576
                    GeneratePexLockfile(
577
                        requirements=FrozenOrderedSet(sorted(tool.requirements)),
578
                        find_links=FrozenOrderedSet(),
579
                        interpreter_constraints=ic,
580
                        resolve_name=resolve,
581
                        lockfile_dest=DEFAULT_TOOL_LOCKFILE,
582
                        diff=False,
583
                        lock_style="universal",  # Tools always use universal style
584
                        complete_platforms=(),  # Tools don't use complete platforms
585
                    )
586
                )
587

588
    return UserGenerateLockfiles(out)
1✔
589

590

591
@dataclass(frozen=True)
12✔
592
class PythonSyntheticLockfileTargetsRequest(SyntheticTargetsRequest):
12✔
593
    """Register the type used to create synthetic targets for Python lockfiles.
594

595
    As the paths for all lockfiles are known up-front, we set the `path` field to
596
    `SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS` so that we get a single request for all
597
    our synthetic targets rather than one request per directory.
598
    """
599

600
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
12✔
601

602

603
def synthetic_lockfile_target_name(resolve: str) -> str:
12✔
604
    return f"_{resolve}_lockfile"
3✔
605

606

607
@rule
12✔
608
async def python_lockfile_synthetic_targets(
12✔
609
    request: PythonSyntheticLockfileTargetsRequest,
610
    python_setup: PythonSetup,
611
) -> SyntheticAddressMaps:
612
    if not python_setup.enable_synthetic_lockfiles:
11✔
613
        return SyntheticAddressMaps()
11✔
614

615
    resolves = [
3✔
616
        (os.path.dirname(lockfile), os.path.basename(lockfile), name)
617
        for name, lockfile in python_setup.resolves.items()
618
    ]
619

620
    return SyntheticAddressMaps.for_targets_request(
3✔
621
        request,
622
        [
623
            (
624
                os.path.join(spec_path, "BUILD.python-lockfiles"),
625
                tuple(
626
                    TargetAdaptor(
627
                        "_lockfiles",
628
                        name=synthetic_lockfile_target_name(name),
629
                        sources=(lockfile,),
630
                        __description_of_origin__=f"the [python].resolves option {name!r}",
631
                    )
632
                    for _, lockfile, name in lockfiles
633
                ),
634
            )
635
            for spec_path, lockfiles in itertools.groupby(sorted(resolves), key=itemgetter(0))
636
        ],
637
    )
638

639

640
def rules():
12✔
641
    return (
11✔
642
        *collect_rules(),
643
        UnionRule(GenerateLockfile, GeneratePexLockfile),
644
        UnionRule(GenerateLockfile, GenerateUvLockfile),
645
        UnionRule(KnownUserResolveNamesRequest, KnownPythonUserResolveNamesRequest),
646
        UnionRule(RequestedUserResolveNames, RequestedPythonUserResolveNames),
647
        UnionRule(SyntheticTargetsRequest, PythonSyntheticLockfileTargetsRequest),
648
    )
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