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

pantsbuild / pants / 25405181773

05 May 2026 10:12PM UTC coverage: 92.884% (-0.03%) from 92.911%
25405181773

Pull #23320

github

web-flow
Merge aa9992c41 into 736246ca9
Pull Request #23320: Support for uv lockfiles as an alternative to pex lockfiles (cherry-pick of #23302)

443 of 505 new or added lines in 23 files covered. (87.72%)

11 existing lines in 1 file now uncovered.

92029 of 99080 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
    WrappedGenerateLockfile,
48
)
49
from pants.core.goals.resolves import ExportableTool
12✔
50
from pants.core.util_rules.lockfile_metadata import calculate_invalidation_digest
12✔
51
from pants.engine.addresses import UnparsedAddressInputs
12✔
52
from pants.engine.fs import (
12✔
53
    CreateDigest,
54
    Digest,
55
    FileContent,
56
    GlobExpansionConjunction,
57
    MergeDigests,
58
    PathGlobs,
59
)
60
from pants.engine.internals.native_engine import EMPTY_DIGEST
12✔
61
from pants.engine.internals.synthetic_targets import SyntheticAddressMaps, SyntheticTargetsRequest
12✔
62
from pants.engine.internals.target_adaptor import TargetAdaptor
12✔
63
from pants.engine.intrinsics import (
12✔
64
    create_digest,
65
    get_digest_contents,
66
    merge_digests,
67
    path_globs_to_digest,
68
)
69
from pants.engine.process import Process, ProcessCacheScope, execute_process_or_raise
12✔
70
from pants.engine.rules import collect_rules, implicitly, rule
12✔
71
from pants.engine.target import AllTargets
12✔
72
from pants.engine.unions import UnionMembership, UnionRule
12✔
73
from pants.option.subsystem import _construct_subsystem
12✔
74
from pants.util.docutil import bin_name
12✔
75
from pants.util.logging import LogLevel
12✔
76
from pants.util.ordered_set import FrozenOrderedSet
12✔
77
from pants.util.pip_requirement import PipRequirement
12✔
78

79

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

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

93

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

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

105

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

112

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

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

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

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

128

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

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

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

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

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

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

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

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

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

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

326

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

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

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

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

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

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

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

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

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

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

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

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

454

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

458

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

462

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

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

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

487

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

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

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

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

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

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

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

589
    return UserGenerateLockfiles(out)
1✔
590

591

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

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

601
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
12✔
602

603

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

607

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

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

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

640

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