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

pantsbuild / pants / 23103890449

15 Mar 2026 05:08AM UTC coverage: 92.908% (-0.02%) from 92.932%
23103890449

Pull #22949

github

web-flow
Merge efc7d06cb into f07276df6
Pull Request #22949: Add experimental uv resolver for Python lockfiles

66 of 96 new or added lines in 5 files covered. (68.75%)

1 existing line in 1 file now uncovered.

91235 of 98199 relevant lines covered (92.91%)

4.05 hits per line

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

77.49
/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 LockfileResolver, PythonSetup
12✔
14
from pants.backend.python.subsystems.uv import Uv
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_python_lockfile_diff
12✔
22
from pants.backend.python.util_rules.lockfile_metadata import 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
    ResolvePexConfig,
33
    ResolvePexConfigRequest,
34
    determine_resolve_pex_config,
35
)
36
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
12✔
37
from pants.core.goals.generate_lockfiles import (
12✔
38
    DEFAULT_TOOL_LOCKFILE,
39
    GenerateLockfile,
40
    GenerateLockfileResult,
41
    GenerateLockfilesSubsystem,
42
    KnownUserResolveNames,
43
    KnownUserResolveNamesRequest,
44
    RequestedUserResolveNames,
45
    UserGenerateLockfiles,
46
    WrappedGenerateLockfile,
47
)
48
from pants.core.goals.resolves import ExportableTool
12✔
49
from pants.core.util_rules.external_tool import download_external_tool
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.platform import Platform
12✔
70
from pants.engine.process import Process, ProcessCacheScope, execute_process_or_raise
12✔
71
from pants.engine.rules import collect_rules, implicitly, rule
12✔
72
from pants.engine.target import AllTargets
12✔
73
from pants.engine.unions import UnionMembership, UnionRule
12✔
74
from pants.option.errors import OptionsError
12✔
75
from pants.option.subsystem import _construct_subsystem
12✔
76
from pants.util.docutil import bin_name
12✔
77
from pants.util.logging import LogLevel
12✔
78
from pants.util.ordered_set import FrozenOrderedSet
12✔
79
from pants.util.pip_requirement import PipRequirement
12✔
80
from pants.util.strutil import softwrap
12✔
81

82

83
@dataclass(frozen=True)
12✔
84
class GeneratePythonLockfile(GenerateLockfile):
12✔
85
    requirements: FrozenOrderedSet[str]
12✔
86
    find_links: FrozenOrderedSet[str]
12✔
87
    interpreter_constraints: InterpreterConstraints
12✔
88
    lock_style: str
12✔
89
    complete_platforms: tuple[str, ...]
12✔
90

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

96

97
@rule
12✔
98
async def wrap_python_lockfile_request(request: GeneratePythonLockfile) -> WrappedGenerateLockfile:
12✔
99
    return WrappedGenerateLockfile(request)
×
100

101

102
@dataclass(frozen=True)
12✔
103
class _PipArgsAndConstraintsSetup:
12✔
104
    resolve_config: ResolvePexConfig
12✔
105
    args: tuple[str, ...]
12✔
106
    digest: Digest
12✔
107

108

109
def _strip_named_repo(value: str) -> str:
12✔
110
    """Strip Pex-style named repo values like `name=https://...` down to the URL/path.
111

112
    Pants allows `[python-repos].indexes` / `[python-repos].find_links` entries to optionally be
113
    named so they can be referenced by `--source` in Pex. `uv` does not understand this syntax.
114
    """
NEW
115
    maybe_name, maybe_url = value.split("=", 1) if "=" in value else ("", value)
×
NEW
116
    if (
×
117
        maybe_name
118
        and "://" not in maybe_name
119
        and ("://" in maybe_url or maybe_url.startswith("file:"))
120
    ):
NEW
121
        return maybe_url
×
NEW
122
    return value
×
123

124

125
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
12✔
126
    resolve_config = await determine_resolve_pex_config(
2✔
127
        ResolvePexConfigRequest(resolve_name), **implicitly()
128
    )
129

130
    args = list(resolve_config.pex_args())
2✔
131
    digests: list[Digest] = []
2✔
132

133
    if resolve_config.constraints_file:
2✔
134
        args.append(f"--constraints={resolve_config.constraints_file.path}")
1✔
135
        digests.append(resolve_config.constraints_file.digest)
1✔
136

137
    input_digest = await merge_digests(MergeDigests(digests))
2✔
138
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
2✔
139

140

141
@rule(desc="Generate Python lockfile", level=LogLevel.DEBUG)
12✔
142
async def generate_lockfile(
12✔
143
    req: GeneratePythonLockfile,
144
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
145
    python_setup: PythonSetup,
146
    pex_subsystem: PexSubsystem,
147
    uv: Uv,
148
    platform: Platform,
149
) -> GenerateLockfileResult:
150
    if not req.requirements:
2✔
151
        raise ValueError(
1✔
152
            f"Cannot generate lockfile with no requirements. Please add some requirements to {req.resolve_name}."
153
        )
154

155
    pip_args_setup = await _setup_pip_args_and_constraints_file(req.resolve_name)
2✔
156
    header_delimiter = "//"
2✔
157

158
    use_uv = python_setup.lockfile_resolver == LockfileResolver.uv
2✔
159
    uv_compile_output_digest: Digest | None = None
2✔
160
    uv_compiled_requirements_path = "__uv_compiled_requirements.txt"
2✔
161
    resolve_config = pip_args_setup.resolve_config
2✔
162

163
    if use_uv:
2✔
164
        if req.lock_style == "universal":
1✔
165
            raise OptionsError(
1✔
166
                softwrap(
167
                    f"""
168
                    `[python].lockfile_resolver = "uv"` does not yet support `lock_style="universal"`.
169

170
                    To use uv today, set `[python].resolves_to_lock_style` for `{req.resolve_name}` to
171
                    `"strict"` or `"sources"`, or set `[python].lockfile_resolver = "pip"`.
172
                    """
173
                )
174
            )
175
        if req.complete_platforms:
1✔
NEW
176
            raise OptionsError(
×
177
                softwrap(
178
                    f"""
179
                    `[python].lockfile_resolver = "uv"` does not yet support `complete_platforms`.
180

181
                    Either remove `[python].resolves_to_complete_platforms` for `{req.resolve_name}`,
182
                    or set `[python].lockfile_resolver = "pip"`.
183
                    """
184
                )
185
            )
186

187
        if resolve_config.overrides or resolve_config.sources or resolve_config.excludes:
1✔
NEW
188
            raise OptionsError(
×
189
                softwrap(
190
                    f"""
191
                    `[python].lockfile_resolver = "uv"` is not yet compatible with per-resolve
192
                    `overrides`, `sources`, or `excludes` for `{req.resolve_name}`.
193

194
                    Either remove these settings for the resolve, or set `[python].lockfile_resolver = "pip"`.
195
                    """
196
                )
197
            )
198

199
        # `uv pip compile` resolves for a single interpreter, so only enable it when
200
        # interpreter constraints narrow to a single major/minor version.
201
        if not req.interpreter_constraints:
1✔
202
            raise OptionsError(
1✔
203
                softwrap(
204
                    f"""
205
                    `[python].lockfile_resolver = "uv"` requires interpreter constraints to be set
206
                    (and to select a single Python major/minor version) for `{req.resolve_name}`.
207

208
                    For example:
209
                      [python]
210
                      interpreter_constraints = [\"CPython==3.11.*\"]
211
                    """
212
                )
213
            )
214
        majors_minors = {
1✔
215
            (maj, minor)
216
            for maj, minor, _ in req.interpreter_constraints.enumerate_python_versions(
217
                python_setup.interpreter_versions_universe
218
            )
219
        }
220
        if len(majors_minors) != 1:
1✔
221
            raise OptionsError(
1✔
222
                softwrap(
223
                    f"""
224
                    `[python].lockfile_resolver = "uv"` currently requires interpreter constraints
225
                    to select exactly one Python major/minor version for `{req.resolve_name}`, but
226
                    the constraints `{req.interpreter_constraints}` select: {sorted(majors_minors)}.
227

228
                    Either narrow your constraints (e.g. `CPython==3.11.*`) or set
229
                    `[python].lockfile_resolver = "pip"`.
230
                    """
231
                )
232
            )
233

234
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
2✔
235

236
    # Resolve complete platform targets if specified
237
    complete_platforms: CompletePlatforms | None = None
2✔
238
    if req.complete_platforms:
2✔
239
        # Resolve target addresses to get platform JSON files
240
        complete_platforms = await digest_complete_platform_addresses(
×
241
            UnparsedAddressInputs(
242
                req.complete_platforms,
243
                owning_address=None,
244
                description_of_origin=f"the `[python].resolves_to_complete_platforms` for resolve `{req.resolve_name}`",
245
            )
246
        )
247

248
    # Add complete platforms if specified, otherwise use default target systems for universal locks
249
    if complete_platforms:
2✔
250
        target_system_args = tuple(
×
251
            f"--complete-platform={platform}" for platform in complete_platforms
252
        )
253
    elif req.lock_style == "universal":
2✔
254
        # PEX files currently only run on Linux and Mac machines; so we hard code this
255
        # limit on lock universality to avoid issues locking due to irrelevant
256
        # Windows-only dependency issues. See this Pex issue that originated from a
257
        # Pants user issue presented in Slack:
258
        #   https://github.com/pex-tool/pex/issues/1821
259
        #
260
        # Note: --target-system only applies to universal locks. For other lock styles
261
        # (strict, sources) without complete platforms, we don't specify platform args
262
        # and PEX will lock for the current platform only.
263
        target_system_args = (
2✔
264
            "--target-system",
265
            "linux",
266
            "--target-system",
267
            "mac",
268
        )
269
    else:
270
        # For non-universal lock styles without complete platforms, don't specify
271
        # platform arguments - PEX will lock for the current platform only
272
        target_system_args = ()
×
273

274
    if generate_lockfiles_subsystem.sync:
2✔
275
        existing_lockfile_digest = await path_globs_to_digest(
×
276
            PathGlobs(
277
                globs=(req.lockfile_dest,),
278
                # We ignore errors, since the lockfile may not exist.
279
                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
280
                conjunction=GlobExpansionConjunction.any_match,
281
            )
282
        )
283
    else:
284
        existing_lockfile_digest = EMPTY_DIGEST
2✔
285

286
    output_flag = "--lock" if generate_lockfiles_subsystem.sync else "--output"
2✔
287

288
    if use_uv:
2✔
NEW
289
        downloaded_uv = await download_external_tool(uv.get_request(platform))
×
290

NEW
291
        requirements_in = "__uv_requirements.in"
×
NEW
292
        uv_input_digest = await create_digest(
×
293
            CreateDigest(
294
                [
295
                    FileContent(
296
                        requirements_in,
297
                        ("\n".join(req.requirements) + "\n").encode("utf-8"),
298
                    )
299
                ]
300
            )
301
        )
302

NEW
303
        uv_index_args: list[str] = []
×
NEW
304
        index_urls = [_strip_named_repo(i) for i in resolve_config.indexes]
×
NEW
305
        if index_urls:
×
NEW
306
            uv_index_args.extend(["--default-index", index_urls[0]])
×
NEW
307
            for extra_index in index_urls[1:]:
×
NEW
308
                uv_index_args.extend(["--index", extra_index])
×
309
        else:
NEW
310
            uv_index_args.append("--no-index")
×
311

NEW
312
        uv_find_links_args = list(
×
313
            itertools.chain.from_iterable(
314
                ["--find-links", _strip_named_repo(link)]
315
                for link in (*resolve_config.find_links, *req.find_links)
316
            )
317
        )
318

NEW
319
        uv_constraints_args: list[str] = []
×
NEW
320
        if resolve_config.constraints_file:
×
NEW
321
            uv_constraints_args.extend(["--constraints", resolve_config.constraints_file.path])
×
322

NEW
323
        uv_build_args: list[str] = []
×
NEW
324
        if resolve_config.no_binary:
×
NEW
325
            uv_build_args.extend(["--no-binary", ",".join(resolve_config.no_binary)])
×
NEW
326
        if resolve_config.only_binary:
×
NEW
327
            uv_build_args.extend(["--only-binary", ",".join(resolve_config.only_binary)])
×
328

NEW
329
        uv_process_input_digest = await merge_digests(
×
330
            MergeDigests([downloaded_uv.digest, uv_input_digest, pip_args_setup.digest])
331
        )
NEW
332
        uv_result = await execute_process_or_raise(
×
333
            **implicitly(
334
                Process(
335
                    argv=(
336
                        downloaded_uv.exe,
337
                        "pip",
338
                        "compile",
339
                        requirements_in,
340
                        "--output-file",
341
                        uv_compiled_requirements_path,
342
                        "--format",
343
                        "requirements.txt",
344
                        "--no-header",
345
                        "--no-annotate",
346
                        "--python",
347
                        python.path,
348
                        *uv_index_args,
349
                        *uv_find_links_args,
350
                        *uv_constraints_args,
351
                        *uv_build_args,
352
                        *uv.args,
353
                    ),
354
                    input_digest=uv_process_input_digest,
355
                    output_files=(uv_compiled_requirements_path,),
356
                    description=f"Resolve requirements for {req.resolve_name} with uv",
357
                    cache_scope=ProcessCacheScope.PER_SESSION,
358
                )
359
            )
360
        )
NEW
361
        uv_compile_output_digest = uv_result.output_digest
×
362

363
    result = await execute_process_or_raise(
2✔
364
        **implicitly(
365
            PexCliProcess(
366
                subcommand=("lock", "sync" if generate_lockfiles_subsystem.sync else "create"),
367
                extra_args=(
368
                    f"{output_flag}={req.lockfile_dest}",
369
                    f"--style={req.lock_style}",
370
                    "--pip-version",
371
                    python_setup.pip_version,
372
                    "--resolver-version",
373
                    "pip-2020-resolver",
374
                    "--preserve-pip-download-log",
375
                    "pex-pip-download.log",
376
                    *target_system_args,
377
                    # This makes diffs more readable when lockfiles change.
378
                    "--indent=2",
379
                    f"--python-path={python.path}",
380
                    *(f"--find-links={link}" for link in req.find_links),
381
                    *pip_args_setup.args,
382
                    # When complete platforms are specified, don't pass interpreter constraints.
383
                    # The complete platforms already define Python versions and platforms.
384
                    # Passing both causes PEX to generate duplicate locked_requirements entries
385
                    # when the local platform matches a complete platform.
386
                    # TODO(#9560): Consider validating that these platforms are valid with the
387
                    #  interpreter constraints.
388
                    *(
389
                        req.interpreter_constraints.generate_pex_arg_list()
390
                        if not complete_platforms
391
                        else ()
392
                    ),
393
                    *(
394
                        f"--override={override}"
395
                        for override in pip_args_setup.resolve_config.overrides
396
                    ),
397
                    *(
398
                        (
399
                            "--no-transitive",
400
                            "--requirement",
401
                            uv_compiled_requirements_path,
402
                        )
403
                        if use_uv
404
                        else tuple(req.requirements)
405
                    ),
406
                ),
407
                additional_input_digest=await merge_digests(
408
                    MergeDigests(
409
                        [existing_lockfile_digest, pip_args_setup.digest]
410
                        + ([uv_compile_output_digest] if uv_compile_output_digest else [])
411
                        + ([complete_platforms.digest] if complete_platforms else [])
412
                    )
413
                ),
414
                output_files=(req.lockfile_dest,),
415
                description=f"Generate lockfile for {req.resolve_name}",
416
                # Instead of caching lockfile generation with LMDB, we instead use the invalidation
417
                # scheme from `lockfile_metadata.py` to check for stale/invalid lockfiles. This is
418
                # necessary so that our invalidation is resilient to deleting LMDB or running on a
419
                # new machine.
420
                #
421
                # We disable caching with LMDB so that when you generate a lockfile, you always get
422
                # the most up-to-date snapshot of the world. This is generally desirable and also
423
                # necessary to avoid an awkward edge case where different developers generate
424
                # different lockfiles even when generating at the same time. See
425
                # https://github.com/pantsbuild/pants/issues/12591.
426
                cache_scope=ProcessCacheScope.PER_SESSION,
427
            )
428
        )
429
    )
430
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
2✔
431

432
    metadata = PythonLockfileMetadata.new(
2✔
433
        valid_for_interpreter_constraints=req.interpreter_constraints,
434
        requirements={
435
            PipRequirement.parse(
436
                i,
437
                description_of_origin=f"the lockfile {req.lockfile_dest} for the resolve {req.resolve_name}",
438
            )
439
            for i in req.requirements
440
        },
441
        manylinux=pip_args_setup.resolve_config.manylinux,
442
        requirement_constraints=(
443
            set(pip_args_setup.resolve_config.constraints_file.constraints)
444
            if pip_args_setup.resolve_config.constraints_file
445
            else set()
446
        ),
447
        only_binary=set(pip_args_setup.resolve_config.only_binary),
448
        no_binary=set(pip_args_setup.resolve_config.no_binary),
449
        excludes=set(pip_args_setup.resolve_config.excludes),
450
        overrides=set(pip_args_setup.resolve_config.overrides),
451
        sources=set(pip_args_setup.resolve_config.sources),
452
        lock_style=req.lock_style,
453
        complete_platforms=req.complete_platforms,
454
    )
455
    regenerate_command = (
2✔
456
        generate_lockfiles_subsystem.custom_command
457
        or f"{bin_name()} generate-lockfiles --resolve={req.resolve_name}"
458
    )
459
    if python_setup.separate_lockfile_metadata_file:
2✔
460
        descr = f"This lockfile was generated by Pants. To regenerate, run: {regenerate_command}"
1✔
461
        metadata_digest = await create_digest(
1✔
462
            CreateDigest(
463
                [
464
                    FileContent(
465
                        PythonLockfileMetadata.metadata_location_for_lockfile(req.lockfile_dest),
466
                        metadata.to_json(with_description=descr).encode(),
467
                    ),
468
                ]
469
            )
470
        )
471
        final_lockfile_digest = await merge_digests(
1✔
472
            MergeDigests([metadata_digest, result.output_digest])
473
        )
474
    else:
475
        initial_lockfile_digest_contents = await get_digest_contents(result.output_digest)
2✔
476
        lockfile_with_header = metadata.add_header_to_lockfile(
2✔
477
            initial_lockfile_digest_contents[0].content,
478
            regenerate_command=regenerate_command,
479
            delimeter=header_delimiter,
480
        )
481
        final_lockfile_digest = await create_digest(
2✔
482
            CreateDigest(
483
                [
484
                    FileContent(req.lockfile_dest, lockfile_with_header),
485
                ]
486
            )
487
        )
488

489
    if req.diff:
2✔
490
        diff = await _generate_python_lockfile_diff(
×
491
            final_lockfile_digest, req.resolve_name, req.lockfile_dest
492
        )
493
    else:
494
        diff = None
2✔
495

496
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
2✔
497

498

499
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
12✔
500
    pass
12✔
501

502

503
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
12✔
504
    pass
12✔
505

506

507
@rule
12✔
508
async def determine_python_user_resolves(
12✔
509
    _: KnownPythonUserResolveNamesRequest,
510
    python_setup: PythonSetup,
511
    union_membership: UnionMembership,
512
) -> KnownUserResolveNames:
513
    """Find all know Python resolves, from both user-created resolves and internal tools."""
514
    python_tool_resolves = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
1✔
515

516
    tools_using_default_resolve = [
1✔
517
        resolve_name
518
        for resolve_name, subsystem_cls in python_tool_resolves.items()
519
        if (await _construct_subsystem(subsystem_cls)).install_from_resolve is None
520
    ]
521

522
    return KnownUserResolveNames(
1✔
523
        names=(
524
            *python_setup.resolves.keys(),
525
            *tools_using_default_resolve,
526
        ),  # the order of the keys doesn't matter since shadowing is done in `setup_user_lockfile_requests`
527
        option_name="[python].resolves",
528
        requested_resolve_names_cls=RequestedPythonUserResolveNames,
529
    )
530

531

532
@rule
12✔
533
async def setup_user_lockfile_requests(
12✔
534
    requested: RequestedPythonUserResolveNames,
535
    all_targets: AllTargets,
536
    python_setup: PythonSetup,
537
    union_membership: UnionMembership,
538
) -> UserGenerateLockfiles:
539
    """Transform the names of resolves requested into the `GeneratePythonLockfile` request object.
540

541
    Shadowing is done here by only checking internal resolves if the resolve is not a user-created
542
    resolve.
543
    """
544
    if not (python_setup.enable_resolves and python_setup.resolves_generate_lockfiles):
1✔
545
        return UserGenerateLockfiles()
×
546

547
    resolve_to_requirements_fields = defaultdict(set)
1✔
548
    find_links: set[str] = set()
1✔
549
    for tgt in all_targets:
1✔
550
        if not tgt.has_fields((PythonRequirementResolveField, PythonRequirementsField)):
1✔
551
            continue
×
552
        resolve = tgt[PythonRequirementResolveField].normalized_value(python_setup)
1✔
553
        resolve_to_requirements_fields[resolve].add(tgt[PythonRequirementsField])
1✔
554
        find_links.update(tgt[PythonRequirementFindLinksField].value or ())
1✔
555

556
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
1✔
557

558
    out = set()
1✔
559
    for resolve in requested:
1✔
560
        if resolve in python_setup.resolves:
1✔
561
            out.add(
1✔
562
                GeneratePythonLockfile(
563
                    requirements=PexRequirements.req_strings_from_requirement_fields(
564
                        resolve_to_requirements_fields[resolve]
565
                    ),
566
                    find_links=FrozenOrderedSet(find_links),
567
                    interpreter_constraints=InterpreterConstraints(
568
                        python_setup.resolves_to_interpreter_constraints.get(
569
                            resolve, python_setup.interpreter_constraints
570
                        )
571
                    ),
572
                    resolve_name=resolve,
573
                    lockfile_dest=python_setup.resolves[resolve],
574
                    diff=False,
575
                    lock_style=python_setup.resolves_to_lock_style().get(resolve, "universal"),
576
                    complete_platforms=tuple(
577
                        python_setup.resolves_to_complete_platforms().get(resolve, [])
578
                    ),
579
                )
580
            )
581
        else:
582
            tool_cls: type[PythonToolBase] = tools[resolve]
×
583
            tool = await _construct_subsystem(tool_cls)
×
584

585
            # TODO: we shouldn't be managing default ICs in lockfile identification.
586
            #   We should find a better place to do this or a better way to default
587
            if tool.register_interpreter_constraints:
×
588
                ic = tool.interpreter_constraints
×
589
            else:
590
                ic = InterpreterConstraints(tool.default_interpreter_constraints)
×
591

592
            out.add(
×
593
                GeneratePythonLockfile(
594
                    requirements=FrozenOrderedSet(sorted(tool.requirements)),
595
                    find_links=FrozenOrderedSet(find_links),
596
                    interpreter_constraints=ic,
597
                    resolve_name=resolve,
598
                    lockfile_dest=DEFAULT_TOOL_LOCKFILE,
599
                    diff=False,
600
                    lock_style="universal",  # Tools always use universal style
601
                    complete_platforms=(),  # Tools don't use complete platforms
602
                )
603
            )
604

605
    return UserGenerateLockfiles(out)
1✔
606

607

608
@dataclass(frozen=True)
12✔
609
class PythonSyntheticLockfileTargetsRequest(SyntheticTargetsRequest):
12✔
610
    """Register the type used to create synthetic targets for Python lockfiles.
611

612
    As the paths for all lockfiles are known up-front, we set the `path` field to
613
    `SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS` so that we get a single request for all
614
    our synthetic targets rather than one request per directory.
615
    """
616

617
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
12✔
618

619

620
def synthetic_lockfile_target_name(resolve: str) -> str:
12✔
621
    return f"_{resolve}_lockfile"
3✔
622

623

624
@rule
12✔
625
async def python_lockfile_synthetic_targets(
12✔
626
    request: PythonSyntheticLockfileTargetsRequest,
627
    python_setup: PythonSetup,
628
) -> SyntheticAddressMaps:
629
    if not python_setup.enable_synthetic_lockfiles:
11✔
630
        return SyntheticAddressMaps()
11✔
631

632
    resolves = [
3✔
633
        (os.path.dirname(lockfile), os.path.basename(lockfile), name)
634
        for name, lockfile in python_setup.resolves.items()
635
    ]
636

637
    return SyntheticAddressMaps.for_targets_request(
3✔
638
        request,
639
        [
640
            (
641
                os.path.join(spec_path, "BUILD.python-lockfiles"),
642
                tuple(
643
                    TargetAdaptor(
644
                        "_lockfiles",
645
                        name=synthetic_lockfile_target_name(name),
646
                        sources=(lockfile,),
647
                        __description_of_origin__=f"the [python].resolves option {name!r}",
648
                    )
649
                    for _, lockfile, name in lockfiles
650
                ),
651
            )
652
            for spec_path, lockfiles in itertools.groupby(sorted(resolves), key=itemgetter(0))
653
        ],
654
    )
655

656

657
def rules():
12✔
658
    return (
11✔
659
        *collect_rules(),
660
        UnionRule(GenerateLockfile, GeneratePythonLockfile),
661
        UnionRule(KnownUserResolveNamesRequest, KnownPythonUserResolveNamesRequest),
662
        UnionRule(RequestedUserResolveNames, RequestedPythonUserResolveNames),
663
        UnionRule(SyntheticTargetsRequest, PythonSyntheticLockfileTargetsRequest),
664
    )
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