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

pantsbuild / pants / 20332790708

18 Dec 2025 09:48AM UTC coverage: 64.992% (-15.3%) from 80.295%
20332790708

Pull #22949

github

web-flow
Merge f730a56cd into 407284c67
Pull Request #22949: Add experimental uv resolver for Python lockfiles

54 of 97 new or added lines in 5 files covered. (55.67%)

8270 existing lines in 295 files now uncovered.

48990 of 75379 relevant lines covered (64.99%)

1.81 hits per line

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

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

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

12
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
5✔
13
from pants.backend.python.subsystems.setup import LockfileResolver, PythonSetup
5✔
14
from pants.backend.python.subsystems.uv import Uv
5✔
15
from pants.backend.python.target_types import (
5✔
16
    PythonRequirementFindLinksField,
17
    PythonRequirementResolveField,
18
    PythonRequirementsField,
19
)
20
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
5✔
21
from pants.backend.python.util_rules.lockfile_diff import _generate_python_lockfile_diff
5✔
22
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
5✔
23
from pants.backend.python.util_rules.pex import (
5✔
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
5✔
29
from pants.backend.python.util_rules.pex_environment import PexSubsystem
5✔
30
from pants.backend.python.util_rules.pex_requirements import (
5✔
31
    PexRequirements,
32
    ResolvePexConfig,
33
    ResolvePexConfigRequest,
34
    determine_resolve_pex_config,
35
)
36
from pants.core.goals.generate_lockfiles import (
5✔
37
    DEFAULT_TOOL_LOCKFILE,
38
    GenerateLockfile,
39
    GenerateLockfileResult,
40
    GenerateLockfilesSubsystem,
41
    KnownUserResolveNames,
42
    KnownUserResolveNamesRequest,
43
    RequestedUserResolveNames,
44
    UserGenerateLockfiles,
45
    WrappedGenerateLockfile,
46
)
47
from pants.core.goals.resolves import ExportableTool
5✔
48
from pants.core.util_rules.external_tool import download_external_tool
5✔
49
from pants.core.util_rules.lockfile_metadata import calculate_invalidation_digest
5✔
50
from pants.engine.addresses import UnparsedAddressInputs
5✔
51
from pants.engine.fs import CreateDigest, Digest, FileContent, MergeDigests
5✔
52
from pants.engine.internals.synthetic_targets import SyntheticAddressMaps, SyntheticTargetsRequest
5✔
53
from pants.engine.internals.target_adaptor import TargetAdaptor
5✔
54
from pants.engine.intrinsics import create_digest, get_digest_contents, merge_digests
5✔
55
from pants.engine.platform import Platform
5✔
56
from pants.engine.process import Process, ProcessCacheScope, fallible_to_exec_result_or_raise
5✔
57
from pants.engine.rules import collect_rules, implicitly, rule
5✔
58
from pants.engine.target import AllTargets
5✔
59
from pants.engine.unions import UnionMembership, UnionRule
5✔
60
from pants.option.errors import OptionsError
5✔
61
from pants.option.subsystem import _construct_subsystem
5✔
62
from pants.util.docutil import bin_name
5✔
63
from pants.util.logging import LogLevel
5✔
64
from pants.util.ordered_set import FrozenOrderedSet
5✔
65
from pants.util.pip_requirement import PipRequirement
5✔
66
from pants.util.strutil import softwrap
5✔
67

68

69
@dataclass(frozen=True)
5✔
70
class GeneratePythonLockfile(GenerateLockfile):
5✔
71
    requirements: FrozenOrderedSet[str]
5✔
72
    find_links: FrozenOrderedSet[str]
5✔
73
    interpreter_constraints: InterpreterConstraints
5✔
74
    lock_style: str
5✔
75
    complete_platforms: tuple[str, ...]
5✔
76

77
    @property
5✔
78
    def requirements_hex_digest(self) -> str:
5✔
79
        """Produces a hex digest of the requirements input for this lockfile."""
80
        return calculate_invalidation_digest(self.requirements)
×
81

82

83
@rule
5✔
84
async def wrap_python_lockfile_request(request: GeneratePythonLockfile) -> WrappedGenerateLockfile:
5✔
85
    return WrappedGenerateLockfile(request)
×
86

87

88
@dataclass(frozen=True)
5✔
89
class _PipArgsAndConstraintsSetup:
5✔
90
    resolve_config: ResolvePexConfig
5✔
91
    args: tuple[str, ...]
5✔
92
    digest: Digest
5✔
93

94

95
def _strip_named_repo(value: str) -> str:
5✔
96
    """Strip Pex-style named repo values like `name=https://...` down to the URL/path.
97

98
    Pants allows `[python-repos].indexes` / `[python-repos].find_links` entries to optionally be
99
    named so they can be referenced by `--source` in Pex. `uv` does not understand this syntax.
100
    """
NEW
101
    maybe_name, maybe_url = value.split("=", 1) if "=" in value else ("", value)
×
NEW
102
    if maybe_name and "://" not in maybe_name and ("://" in maybe_url or maybe_url.startswith("file:")):
×
NEW
103
        return maybe_url
×
NEW
104
    return value
×
105

106

107
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
5✔
NEW
108
    resolve_config = await determine_resolve_pex_config(
×
109
        ResolvePexConfigRequest(resolve_name), **implicitly()
110
    )
111

112
    args = list(resolve_config.pex_args())
×
UNCOV
113
    digests: list[Digest] = []
×
114

UNCOV
115
    if resolve_config.constraints_file:
×
116
        args.append(f"--constraints={resolve_config.constraints_file.path}")
×
117
        digests.append(resolve_config.constraints_file.digest)
×
118

119
    input_digest = await merge_digests(MergeDigests(digests))
×
120
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
×
121

122

123
@rule(desc="Generate Python lockfile", level=LogLevel.DEBUG)
5✔
124
async def generate_lockfile(
5✔
125
    req: GeneratePythonLockfile,
126
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
127
    python_setup: PythonSetup,
128
    pex_subsystem: PexSubsystem,
129
    uv: Uv,
130
    platform: Platform,
131
) -> GenerateLockfileResult:
UNCOV
132
    if not req.requirements:
×
NEW
133
        raise ValueError(
×
134
            f"Cannot generate lockfile with no requirements. Please add some requirements to {req.resolve_name}."
135
        )
136

137
    pip_args_setup = await _setup_pip_args_and_constraints_file(req.resolve_name)
×
UNCOV
138
    header_delimiter = "//"
×
139

UNCOV
140
    use_uv = python_setup.lockfile_resolver == LockfileResolver.uv
×
141
    uv_compile_output_digest: Digest | None = None
×
142
    uv_compiled_requirements_path = "__uv_compiled_requirements.txt"
×
UNCOV
143
    resolve_config = pip_args_setup.resolve_config
×
144

NEW
145
    if use_uv:
×
NEW
146
        if req.lock_style == "universal":
×
NEW
147
            raise OptionsError(
×
148
                softwrap(
149
                    f"""
150
                    `[python].lockfile_resolver = "uv"` does not yet support `lock_style="universal"`.
151

152
                    To use uv today, set `[python].resolves_to_lock_style` for `{req.resolve_name}` to
153
                    `"strict"` or `"sources"`, or set `[python].lockfile_resolver = "pip"`.
154
                    """
155
                )
156
            )
NEW
157
        if req.complete_platforms:
×
NEW
158
            raise OptionsError(
×
159
                softwrap(
160
                    f"""
161
                    `[python].lockfile_resolver = "uv"` does not yet support `complete_platforms`.
162

163
                    Either remove `[python].resolves_to_complete_platforms` for `{req.resolve_name}`,
164
                    or set `[python].lockfile_resolver = "pip"`.
165
                    """
166
                )
167
            )
168

NEW
169
        if resolve_config.overrides or resolve_config.sources or resolve_config.excludes:
×
NEW
170
            raise OptionsError(
×
171
                softwrap(
172
                    f"""
173
                    `[python].lockfile_resolver = "uv"` is not yet compatible with per-resolve
174
                    `overrides`, `sources`, or `excludes` for `{req.resolve_name}`.
175

176
                    Either remove these settings for the resolve, or set `[python].lockfile_resolver = "pip"`.
177
                    """
178
                )
179
            )
180

181
        # `uv pip compile` resolves for a single interpreter, so only enable it when
182
        # interpreter constraints narrow to a single major/minor version.
NEW
183
        if not req.interpreter_constraints:
×
NEW
184
            raise OptionsError(
×
185
                softwrap(
186
                    f"""
187
                    `[python].lockfile_resolver = "uv"` requires interpreter constraints to be set
188
                    (and to select a single Python major/minor version) for `{req.resolve_name}`.
189

190
                    For example:
191
                      [python]
192
                      interpreter_constraints = [\"CPython==3.11.*\"]
193
                    """
194
                )
195
            )
NEW
196
        majors_minors = {
×
197
            (maj, minor)
198
            for maj, minor, _ in req.interpreter_constraints.enumerate_python_versions(
199
                python_setup.interpreter_versions_universe
200
            )
201
        }
NEW
202
        if len(majors_minors) != 1:
×
NEW
203
            raise OptionsError(
×
204
                softwrap(
205
                    f"""
206
                    `[python].lockfile_resolver = "uv"` currently requires interpreter constraints
207
                    to select exactly one Python major/minor version for `{req.resolve_name}`, but
208
                    the constraints `{req.interpreter_constraints}` select: {sorted(majors_minors)}.
209

210
                    Either narrow your constraints (e.g. `CPython==3.11.*`) or set
211
                    `[python].lockfile_resolver = "pip"`.
212
                    """
213
                )
214
            )
215

NEW
216
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
×
217

218
    # Resolve complete platform targets if specified
NEW
219
    complete_platforms: CompletePlatforms | None = None
×
UNCOV
220
    if req.complete_platforms:
×
221
        # Resolve target addresses to get platform JSON files
UNCOV
222
        complete_platforms = await digest_complete_platform_addresses(
×
223
            UnparsedAddressInputs(
224
                req.complete_platforms,
225
                owning_address=None,
226
                description_of_origin=f"the `[python].resolves_to_complete_platforms` for resolve `{req.resolve_name}`",
227
            )
228
        )
229

230
    # Add complete platforms if specified, otherwise use default target systems for universal locks
UNCOV
231
    if complete_platforms:
×
UNCOV
232
        target_system_args = tuple(
×
233
            f"--complete-platform={platform}" for platform in complete_platforms
234
        )
235
    elif req.lock_style == "universal":
×
236
        # PEX files currently only run on Linux and Mac machines; so we hard code this
237
        # limit on lock universality to avoid issues locking due to irrelevant
238
        # Windows-only dependency issues. See this Pex issue that originated from a
239
        # Pants user issue presented in Slack:
240
        #   https://github.com/pex-tool/pex/issues/1821
241
        #
242
        # Note: --target-system only applies to universal locks. For other lock styles
243
        # (strict, sources) without complete platforms, we don't specify platform args
244
        # and PEX will lock for the current platform only.
UNCOV
245
        target_system_args = (
×
246
            "--target-system",
247
            "linux",
248
            "--target-system",
249
            "mac",
250
        )
251
    else:
252
        # For non-universal lock styles without complete platforms, don't specify
253
        # platform arguments - PEX will lock for the current platform only
UNCOV
254
        target_system_args = ()
×
255

UNCOV
256
    if use_uv:
×
UNCOV
257
        downloaded_uv = await download_external_tool(uv.get_request(platform))
×
258

UNCOV
259
        requirements_in = "__uv_requirements.in"
×
NEW
260
        uv_input_digest = await create_digest(
×
261
            CreateDigest(
262
                [
263
                    FileContent(
264
                        requirements_in,
265
                        ("\n".join(req.requirements) + "\n").encode("utf-8"),
266
                    )
267
                ]
268
            )
269
        )
270

NEW
271
        uv_index_args: list[str] = []
×
NEW
272
        index_urls = [_strip_named_repo(i) for i in resolve_config.indexes]
×
NEW
273
        if index_urls:
×
NEW
274
            uv_index_args.extend(["--default-index", index_urls[0]])
×
NEW
275
            for extra_index in index_urls[1:]:
×
NEW
276
                uv_index_args.extend(["--index", extra_index])
×
277
        else:
NEW
278
            uv_index_args.append("--no-index")
×
279

NEW
280
        uv_find_links_args = list(
×
281
            itertools.chain.from_iterable(
282
                (
283
                    ["--find-links", _strip_named_repo(link)]
284
                    for link in (*resolve_config.find_links, *req.find_links)
285
                )
286
            )
287
        )
288

NEW
289
        uv_constraints_args: list[str] = []
×
NEW
290
        if resolve_config.constraints_file:
×
NEW
291
            uv_constraints_args.extend(["--constraints", resolve_config.constraints_file.path])
×
292

NEW
293
        uv_build_args: list[str] = []
×
NEW
294
        if resolve_config.no_binary:
×
NEW
295
            uv_build_args.extend(["--no-binary", ",".join(resolve_config.no_binary)])
×
NEW
296
        if resolve_config.only_binary:
×
NEW
297
            uv_build_args.extend(["--only-binary", ",".join(resolve_config.only_binary)])
×
298

NEW
299
        uv_process_input_digest = await merge_digests(
×
300
            MergeDigests([downloaded_uv.digest, uv_input_digest, pip_args_setup.digest])
301
        )
NEW
302
        uv_result = await fallible_to_exec_result_or_raise(
×
303
            **implicitly(
304
                Process(
305
                    argv=(
306
                        downloaded_uv.exe,
307
                        "pip",
308
                        "compile",
309
                        requirements_in,
310
                        "--output-file",
311
                        uv_compiled_requirements_path,
312
                        "--format",
313
                        "requirements.txt",
314
                        "--no-header",
315
                        "--no-annotate",
316
                        "--python",
317
                        python.path,
318
                        *uv_index_args,
319
                        *uv_find_links_args,
320
                        *uv_constraints_args,
321
                        *uv_build_args,
322
                        *uv.args,
323
                    ),
324
                    input_digest=uv_process_input_digest,
325
                    output_files=(uv_compiled_requirements_path,),
326
                    description=f"Resolve requirements for {req.resolve_name} with uv",
327
                    cache_scope=ProcessCacheScope.PER_SESSION,
328
                )
329
            )
330
        )
NEW
331
        uv_compile_output_digest = uv_result.output_digest
×
332

NEW
333
    result = await fallible_to_exec_result_or_raise(
×
334
        **implicitly(
335
            PexCliProcess(
336
                subcommand=("lock", "create"),
337
                extra_args=(
338
                    f"--output={req.lockfile_dest}",
339
                    f"--style={req.lock_style}",
340
                    "--pip-version",
341
                    python_setup.pip_version,
342
                    "--resolver-version",
343
                    "pip-2020-resolver",
344
                    "--preserve-pip-download-log",
345
                    "pex-pip-download.log",
346
                    *target_system_args,
347
                    # This makes diffs more readable when lockfiles change.
348
                    "--indent=2",
349
                    f"--python-path={python.path}",
350
                    *(f"--find-links={link}" for link in req.find_links),
351
                    *pip_args_setup.args,
352
                    # When complete platforms are specified, don't pass interpreter constraints.
353
                    # The complete platforms already define Python versions and platforms.
354
                    # Passing both causes PEX to generate duplicate locked_requirements entries
355
                    # when the local platform matches a complete platform.
356
                    # TODO(#9560): Consider validating that these platforms are valid with the
357
                    #  interpreter constraints.
358
                    *(
359
                        req.interpreter_constraints.generate_pex_arg_list()
360
                        if not complete_platforms
361
                        else ()
362
                    ),
363
                    *(
364
                        f"--override={override}"
365
                        for override in pip_args_setup.resolve_config.overrides
366
                    ),
367
                    *(
368
                        (
369
                            "--no-transitive",
370
                            "--requirement",
371
                            uv_compiled_requirements_path,
372
                        )
373
                        if use_uv
374
                        else tuple(req.requirements)
375
                    ),
376
                ),
377
                additional_input_digest=await merge_digests(
378
                    MergeDigests(
379
                        [pip_args_setup.digest]
380
                        + ([uv_compile_output_digest] if uv_compile_output_digest else [])
381
                        + ([complete_platforms.digest] if complete_platforms else [])
382
                    )
383
                ),
384
                output_files=(req.lockfile_dest,),
385
                description=f"Generate lockfile for {req.resolve_name}",
386
                # Instead of caching lockfile generation with LMDB, we instead use the invalidation
387
                # scheme from `lockfile_metadata.py` to check for stale/invalid lockfiles. This is
388
                # necessary so that our invalidation is resilient to deleting LMDB or running on a
389
                # new machine.
390
                #
391
                # We disable caching with LMDB so that when you generate a lockfile, you always get
392
                # the most up-to-date snapshot of the world. This is generally desirable and also
393
                # necessary to avoid an awkward edge case where different developers generate
394
                # different lockfiles even when generating at the same time. See
395
                # https://github.com/pantsbuild/pants/issues/12591.
396
                cache_scope=ProcessCacheScope.PER_SESSION,
397
            )
398
        )
399
    )
UNCOV
400
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
401

402
    metadata = PythonLockfileMetadata.new(
×
403
        valid_for_interpreter_constraints=req.interpreter_constraints,
404
        requirements={
405
            PipRequirement.parse(
406
                i,
407
                description_of_origin=f"the lockfile {req.lockfile_dest} for the resolve {req.resolve_name}",
408
            )
409
            for i in req.requirements
410
        },
411
        manylinux=pip_args_setup.resolve_config.manylinux,
412
        requirement_constraints=(
413
            set(pip_args_setup.resolve_config.constraints_file.constraints)
414
            if pip_args_setup.resolve_config.constraints_file
415
            else set()
416
        ),
417
        only_binary=set(pip_args_setup.resolve_config.only_binary),
418
        no_binary=set(pip_args_setup.resolve_config.no_binary),
419
        excludes=set(pip_args_setup.resolve_config.excludes),
420
        overrides=set(pip_args_setup.resolve_config.overrides),
421
        sources=set(pip_args_setup.resolve_config.sources),
422
        lock_style=req.lock_style,
423
        complete_platforms=req.complete_platforms,
424
    )
UNCOV
425
    regenerate_command = (
×
426
        generate_lockfiles_subsystem.custom_command
427
        or f"{bin_name()} generate-lockfiles --resolve={req.resolve_name}"
428
    )
UNCOV
429
    if python_setup.separate_lockfile_metadata_file:
×
UNCOV
430
        descr = f"This lockfile was generated by Pants. To regenerate, run: {regenerate_command}"
×
431
        metadata_digest = await create_digest(
×
432
            CreateDigest(
433
                [
434
                    FileContent(
435
                        PythonLockfileMetadata.metadata_location_for_lockfile(req.lockfile_dest),
436
                        metadata.to_json(with_description=descr).encode(),
437
                    ),
438
                ]
439
            )
440
        )
UNCOV
441
        final_lockfile_digest = await merge_digests(
×
442
            MergeDigests([metadata_digest, result.output_digest])
443
        )
444
    else:
UNCOV
445
        initial_lockfile_digest_contents = await get_digest_contents(result.output_digest)
×
UNCOV
446
        lockfile_with_header = metadata.add_header_to_lockfile(
×
447
            initial_lockfile_digest_contents[0].content,
448
            regenerate_command=regenerate_command,
449
            delimeter=header_delimiter,
450
        )
UNCOV
451
        final_lockfile_digest = await create_digest(
×
452
            CreateDigest(
453
                [
454
                    FileContent(req.lockfile_dest, lockfile_with_header),
455
                ]
456
            )
457
        )
458

UNCOV
459
    if req.diff:
×
UNCOV
460
        diff = await _generate_python_lockfile_diff(
×
461
            final_lockfile_digest, req.resolve_name, req.lockfile_dest
462
        )
463
    else:
UNCOV
464
        diff = None
×
465

466
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
×
467

468

469
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
5✔
470
    pass
5✔
471

472

473
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
5✔
474
    pass
5✔
475

476

477
@rule
5✔
478
async def determine_python_user_resolves(
5✔
479
    _: KnownPythonUserResolveNamesRequest,
480
    python_setup: PythonSetup,
481
    union_membership: UnionMembership,
482
) -> KnownUserResolveNames:
483
    """Find all know Python resolves, from both user-created resolves and internal tools."""
UNCOV
484
    python_tool_resolves = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
485

486
    tools_using_default_resolve = [
×
487
        resolve_name
488
        for resolve_name, subsystem_cls in python_tool_resolves.items()
489
        if (await _construct_subsystem(subsystem_cls)).install_from_resolve is None
490
    ]
491

UNCOV
492
    return KnownUserResolveNames(
×
493
        names=(
494
            *python_setup.resolves.keys(),
495
            *tools_using_default_resolve,
496
        ),  # the order of the keys doesn't matter since shadowing is done in `setup_user_lockfile_requests`
497
        option_name="[python].resolves",
498
        requested_resolve_names_cls=RequestedPythonUserResolveNames,
499
    )
500

501

502
@rule
5✔
503
async def setup_user_lockfile_requests(
5✔
504
    requested: RequestedPythonUserResolveNames,
505
    all_targets: AllTargets,
506
    python_setup: PythonSetup,
507
    union_membership: UnionMembership,
508
) -> UserGenerateLockfiles:
509
    """Transform the names of resolves requested into the `GeneratePythonLockfile` request object.
510

511
    Shadowing is done here by only checking internal resolves if the resolve is not a user-created
512
    resolve.
513
    """
UNCOV
514
    if not (python_setup.enable_resolves and python_setup.resolves_generate_lockfiles):
×
UNCOV
515
        return UserGenerateLockfiles()
×
516

517
    resolve_to_requirements_fields = defaultdict(set)
×
UNCOV
518
    find_links: set[str] = set()
×
519
    for tgt in all_targets:
×
520
        if not tgt.has_fields((PythonRequirementResolveField, PythonRequirementsField)):
×
521
            continue
×
522
        resolve = tgt[PythonRequirementResolveField].normalized_value(python_setup)
×
523
        resolve_to_requirements_fields[resolve].add(tgt[PythonRequirementsField])
×
524
        find_links.update(tgt[PythonRequirementFindLinksField].value or ())
×
525

526
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
527

528
    out = set()
×
UNCOV
529
    for resolve in requested:
×
530
        if resolve in python_setup.resolves:
×
531
            out.add(
×
532
                GeneratePythonLockfile(
533
                    requirements=PexRequirements.req_strings_from_requirement_fields(
534
                        resolve_to_requirements_fields[resolve]
535
                    ),
536
                    find_links=FrozenOrderedSet(find_links),
537
                    interpreter_constraints=InterpreterConstraints(
538
                        python_setup.resolves_to_interpreter_constraints.get(
539
                            resolve, python_setup.interpreter_constraints
540
                        )
541
                    ),
542
                    resolve_name=resolve,
543
                    lockfile_dest=python_setup.resolves[resolve],
544
                    diff=False,
545
                    lock_style=python_setup.resolves_to_lock_style().get(resolve, "universal"),
546
                    complete_platforms=tuple(
547
                        python_setup.resolves_to_complete_platforms().get(resolve, [])
548
                    ),
549
                )
550
            )
551
        else:
UNCOV
552
            tool_cls: type[PythonToolBase] = tools[resolve]
×
UNCOV
553
            tool = await _construct_subsystem(tool_cls)
×
554

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

562
            out.add(
×
563
                GeneratePythonLockfile(
564
                    requirements=FrozenOrderedSet(sorted(tool.requirements)),
565
                    find_links=FrozenOrderedSet(find_links),
566
                    interpreter_constraints=ic,
567
                    resolve_name=resolve,
568
                    lockfile_dest=DEFAULT_TOOL_LOCKFILE,
569
                    diff=False,
570
                    lock_style="universal",  # Tools always use universal style
571
                    complete_platforms=(),  # Tools don't use complete platforms
572
                )
573
            )
574

UNCOV
575
    return UserGenerateLockfiles(out)
×
576

577

578
@dataclass(frozen=True)
5✔
579
class PythonSyntheticLockfileTargetsRequest(SyntheticTargetsRequest):
5✔
580
    """Register the type used to create synthetic targets for Python lockfiles.
581

582
    As the paths for all lockfiles are known up-front, we set the `path` field to
583
    `SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS` so that we get a single request for all
584
    our synthetic targets rather than one request per directory.
585
    """
586

587
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
5✔
588

589

590
def synthetic_lockfile_target_name(resolve: str) -> str:
5✔
UNCOV
591
    return f"_{resolve}_lockfile"
×
592

593

594
@rule
5✔
595
async def python_lockfile_synthetic_targets(
5✔
596
    request: PythonSyntheticLockfileTargetsRequest,
597
    python_setup: PythonSetup,
598
) -> SyntheticAddressMaps:
UNCOV
599
    if not python_setup.enable_synthetic_lockfiles:
×
UNCOV
600
        return SyntheticAddressMaps()
×
601

602
    resolves = [
×
603
        (os.path.dirname(lockfile), os.path.basename(lockfile), name)
604
        for name, lockfile in python_setup.resolves.items()
605
    ]
606

UNCOV
607
    return SyntheticAddressMaps.for_targets_request(
×
608
        request,
609
        [
610
            (
611
                os.path.join(spec_path, "BUILD.python-lockfiles"),
612
                tuple(
613
                    TargetAdaptor(
614
                        "_lockfiles",
615
                        name=synthetic_lockfile_target_name(name),
616
                        sources=(lockfile,),
617
                        __description_of_origin__=f"the [python].resolves option {name!r}",
618
                    )
619
                    for _, lockfile, name in lockfiles
620
                ),
621
            )
622
            for spec_path, lockfiles in itertools.groupby(sorted(resolves), key=itemgetter(0))
623
        ],
624
    )
625

626

627
def rules():
5✔
628
    return (
4✔
629
        *collect_rules(),
630
        UnionRule(GenerateLockfile, GeneratePythonLockfile),
631
        UnionRule(KnownUserResolveNamesRequest, KnownPythonUserResolveNamesRequest),
632
        UnionRule(RequestedUserResolveNames, RequestedPythonUserResolveNames),
633
        UnionRule(SyntheticTargetsRequest, PythonSyntheticLockfileTargetsRequest),
634
    )
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

© 2025 Coveralls, Inc