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

pantsbuild / pants / 24611823651

18 Apr 2026 07:09PM UTC coverage: 92.923% (-0.001%) from 92.924%
24611823651

Pull #23212

github

web-flow
Merge 3e81915ff into 0283af69e
Pull Request #23212: Add experimental uv resolver for Python lockfile generation

118 of 128 new or added lines in 4 files covered. (92.19%)

1 existing line in 1 file now uncovered.

91783 of 98773 relevant lines covered (92.92%)

4.04 hits per line

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

88.73
/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
12✔
8
import os.path
12✔
9
from collections import defaultdict
12✔
10
from dataclasses import dataclass
12✔
11
from operator import itemgetter
12✔
12

13
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
12✔
14
from pants.backend.python.subsystems.setup import LockfileResolver, PythonSetup
12✔
15
from pants.backend.python.subsystems.uv import download_uv_binary
12✔
16
from pants.backend.python.target_types import (
12✔
17
    PythonRequirementFindLinksField,
18
    PythonRequirementResolveField,
19
    PythonRequirementsField,
20
)
21
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
12✔
22
from pants.backend.python.util_rules.lockfile_diff import _generate_python_lockfile_diff
12✔
23
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
12✔
24
from pants.backend.python.util_rules.pex import (
12✔
25
    CompletePlatforms,
26
    digest_complete_platform_addresses,
27
    find_interpreter,
28
)
29
from pants.backend.python.util_rules.pex_cli import PexCliProcess, maybe_log_pex_stderr
12✔
30
from pants.backend.python.util_rules.pex_environment import PexSubsystem
12✔
31
from pants.backend.python.util_rules.pex_requirements import (
12✔
32
    PexRequirements,
33
    ResolvePexConfig,
34
    ResolvePexConfigRequest,
35
    determine_resolve_pex_config,
36
)
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.errors import OptionsError
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 GeneratePythonLockfile(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."""
91
        return calculate_invalidation_digest(self.requirements)
×
92

93

94
@dataclass(frozen=True)
12✔
95
class _PipArgsAndConstraintsSetup:
12✔
96
    resolve_config: ResolvePexConfig
12✔
97
    args: tuple[str, ...]
12✔
98
    digest: Digest
12✔
99

100

101
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
12✔
102
    resolve_config = await determine_resolve_pex_config(
2✔
103
        ResolvePexConfigRequest(resolve_name), **implicitly()
104
    )
105

106
    args = list(resolve_config.pex_args())
2✔
107
    digests: list[Digest] = []
2✔
108

109
    if resolve_config.constraints_file:
2✔
110
        args.append(f"--constraints={resolve_config.constraints_file.path}")
1✔
111
        digests.append(resolve_config.constraints_file.digest)
1✔
112

113
    input_digest = await merge_digests(MergeDigests(digests))
2✔
114
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
2✔
115

116

117
def _strip_named_repo(value: str) -> str:
12✔
118
    """Strip PEX-style ``name=URL`` prefix from index URLs.
119

120
    ``[python-repos].indexes`` entries may use the ``name=https://...`` form
121
    which PEX understands but uv does not.  This helper extracts just the URL.
122
    """
123
    if "=" not in value:
1✔
124
        return value
1✔
125
    maybe_name, maybe_url = value.split("=", 1)
1✔
126
    if (
1✔
127
        "://" not in maybe_name
128
        and ("://" in maybe_url or maybe_url.startswith("file:"))
129
    ):
130
        return maybe_url
1✔
131
    return value
1✔
132

133

134
def _build_uv_compile_argv(
12✔
135
    *,
136
    uv_exe: str,
137
    requirements_in_path: str,
138
    output_path: str,
139
    is_universal: bool,
140
    python_version: str | None,
141
    resolve_config: ResolvePexConfig,
142
    find_links: FrozenOrderedSet[str],
143
    extra_uv_args: tuple[str, ...],
144
) -> tuple[str, ...]:
145
    """Build the argv for ``uv pip compile``."""
146
    args: list[str] = [
1✔
147
        uv_exe,
148
        "pip",
149
        "compile",
150
        requirements_in_path,
151
        "--output-file",
152
        output_path,
153
        "--no-header",
154
        "--no-annotate",
155
    ]
156

157
    if is_universal:
1✔
158
        args.append("--universal")
1✔
159

160
    # Always pass --python-version so uv knows the target Python.
161
    # This avoids needing a real interpreter binary in the sandbox.
162
    if python_version:
1✔
163
        args.extend(["--python-version", python_version])
1✔
164

165
    # Index URLs
166
    index_urls = [_strip_named_repo(i) for i in resolve_config.indexes]
1✔
167
    if index_urls:
1✔
168
        args.extend(["--default-index", index_urls[0]])
1✔
169
        for extra_index in index_urls[1:]:
1✔
NEW
170
            args.extend(["--extra-index-url", extra_index])
×
171
    else:
NEW
172
        args.append("--no-index")
×
173

174
    # Find links
175
    for link in (*resolve_config.find_links, *find_links):
1✔
NEW
176
        args.extend(["--find-links", _strip_named_repo(link)])
×
177

178
    # Constraints
179
    if resolve_config.constraints_file:
1✔
NEW
180
        args.extend(["--constraint", resolve_config.constraints_file.path])
×
181

182
    # Binary restrictions
183
    if resolve_config.no_binary:
1✔
NEW
184
        for pkg in resolve_config.no_binary:
×
NEW
185
            args.extend(["--no-binary", pkg])
×
186
    if resolve_config.only_binary:
1✔
NEW
187
        for pkg in resolve_config.only_binary:
×
NEW
188
            args.extend(["--only-binary", pkg])
×
189

190
    # User passthrough args
191
    args.extend(extra_uv_args)
1✔
192

193
    return tuple(args)
1✔
194

195

196
@rule(desc="Generate Python lockfile", level=LogLevel.DEBUG)
12✔
197
async def generate_lockfile(
12✔
198
    req: GeneratePythonLockfile,
199
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
200
    python_setup: PythonSetup,
201
    pex_subsystem: PexSubsystem,
202
) -> GenerateLockfileResult:
203
    if not req.requirements:
2✔
204
        raise ValueError(
1✔
205
            f"Cannot generate lockfile with no requirements. Please add some requirements to {req.resolve_name}."
206
        )
207

208
    pip_args_setup = await _setup_pip_args_and_constraints_file(req.resolve_name)
2✔
209
    header_delimiter = "//"
2✔
210

211
    # Early validation for uv resolver — must happen before resolving complete_platforms
212
    # addresses, since those will fail to resolve as file targets before we can give
213
    # a clear error message.
214
    use_uv = python_setup.lockfile_resolver == LockfileResolver.uv
2✔
215
    if use_uv and req.complete_platforms:
2✔
216
        raise OptionsError(
1✔
217
            f"[python].lockfile_resolver = \"uv\" does not support complete_platforms "
218
            f"(set on resolve {req.resolve_name!r}). Use the default pex resolver instead."
219
        )
220

221
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
2✔
222

223
    # Resolve complete platform targets if specified
224
    complete_platforms: CompletePlatforms | None = None
2✔
225
    if req.complete_platforms:
2✔
226
        # Resolve target addresses to get platform JSON files
227
        complete_platforms = await digest_complete_platform_addresses(
×
228
            UnparsedAddressInputs(
229
                req.complete_platforms,
230
                owning_address=None,
231
                description_of_origin=f"the `[python].resolves_to_complete_platforms` for resolve `{req.resolve_name}`",
232
            )
233
        )
234

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

261
    if generate_lockfiles_subsystem.sync:
2✔
262
        existing_lockfile_digest = await path_globs_to_digest(
×
263
            PathGlobs(
264
                globs=(req.lockfile_dest,),
265
                # We ignore errors, since the lockfile may not exist.
266
                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
267
                conjunction=GlobExpansionConjunction.any_match,
268
            )
269
        )
270
    else:
271
        existing_lockfile_digest = EMPTY_DIGEST
2✔
272

273
    # ----- uv pre-resolve (opt-in) -----
274
    uv_compiled_digest: Digest = EMPTY_DIGEST
2✔
275
    uv_compiled_requirements_path = "__uv_compiled_requirements.txt"
2✔
276

277
    if use_uv:
2✔
278
        # Validate unsupported options (complete_platforms already checked above)
279
        if pip_args_setup.resolve_config.overrides:
1✔
280
            raise OptionsError(
1✔
281
                f"[python].lockfile_resolver = \"uv\" does not yet support per-resolve overrides "
282
                f"(set on resolve {req.resolve_name!r}). Use the default pex resolver instead."
283
            )
284
        if pip_args_setup.resolve_config.sources:
1✔
NEW
285
            raise OptionsError(
×
286
                f"[python].lockfile_resolver = \"uv\" does not yet support per-resolve sources "
287
                f"(set on resolve {req.resolve_name!r}). Use the default pex resolver instead."
288
            )
289
        if pip_args_setup.resolve_config.excludes:
1✔
NEW
290
            raise OptionsError(
×
291
                f"[python].lockfile_resolver = \"uv\" does not yet support per-resolve excludes "
292
                f"(set on resolve {req.resolve_name!r}). Use the default pex resolver instead."
293
            )
294

295
        is_universal = req.lock_style == "universal"
1✔
296

297
        if is_universal:
1✔
298
            # For universal locks, pass --python-version with the minimum compatible version.
299
            target_python_version = req.interpreter_constraints.minimum_python_version(
1✔
300
                python_setup.interpreter_versions_universe
301
            )
302
        else:
303
            # For strict/sources, validate that interpreter constraints select a single major.minor.
304
            versions = req.interpreter_constraints.partition_into_major_minor_versions(
1✔
305
                python_setup.interpreter_versions_universe
306
            )
307
            if len(versions) != 1:
1✔
308
                raise OptionsError(
1✔
309
                    f"[python].lockfile_resolver = \"uv\" with lock_style={req.lock_style!r} "
310
                    f"requires interpreter constraints that select exactly one Python major.minor "
311
                    f"version, but resolve {req.resolve_name!r} matched: {versions}. "
312
                    f"Use lock_style=\"universal\" or narrow the interpreter constraints."
313
                )
314
            target_python_version = versions[0]
1✔
315

316
        # Download uv binary (uses the DownloadedUv rule which includes user args)
317
        downloaded_uv = await download_uv_binary(**implicitly())
1✔
318

319
        # Write requirements to a temp input file
320
        requirements_in_path = "__uv_requirements.in"
1✔
321
        uv_input_digest = await create_digest(
1✔
322
            CreateDigest([
323
                FileContent(
324
                    requirements_in_path,
325
                    ("\n".join(sorted(req.requirements)) + "\n").encode("utf-8"),
326
                )
327
            ])
328
        )
329

330
        uv_argv = _build_uv_compile_argv(
1✔
331
            uv_exe=downloaded_uv.exe,
332
            requirements_in_path=requirements_in_path,
333
            output_path=uv_compiled_requirements_path,
334
            is_universal=is_universal,
335
            python_version=target_python_version,
336
            resolve_config=pip_args_setup.resolve_config,
337
            find_links=req.find_links,
338
            extra_uv_args=downloaded_uv.args_for_lockfile_resolve,
339
        )
340

341
        uv_process_input = await merge_digests(
1✔
342
            MergeDigests([downloaded_uv.digest, uv_input_digest, pip_args_setup.digest])
343
        )
344

345
        # Set up environment so uv can find the Python interpreter if needed.
346
        # UV_PYTHON_DOWNLOADS=never prevents uv from trying to download interpreters.
347
        # PATH includes the interpreter's directory so uv can find it.
348
        python_dir = os.path.dirname(python.path)
1✔
349
        uv_env = {
1✔
350
            "PATH": python_dir,
351
            "UV_PYTHON_DOWNLOADS": "never",
352
        }
353

354
        uv_result = await execute_process_or_raise(
1✔
355
            **implicitly(
356
                Process(
357
                    argv=uv_argv,
358
                    description=f"uv pip compile for {req.resolve_name}",
359
                    input_digest=uv_process_input,
360
                    output_files=(uv_compiled_requirements_path,),
361
                    env=uv_env,
362
                    cache_scope=ProcessCacheScope.PER_SESSION,
363
                )
364
            )
365
        )
366
        uv_compiled_digest = uv_result.output_digest
1✔
367

368
    # ----- PEX lock create -----
369
    # When using uv, pass --no-transitive and point at the pre-compiled requirements
370
    # instead of passing the raw requirement strings.
371
    if use_uv:
2✔
372
        requirement_args: tuple[str, ...] = (
1✔
373
            "--no-transitive",
374
            f"--requirement={uv_compiled_requirements_path}",
375
        )
376
    else:
377
        requirement_args = tuple(req.requirements)
2✔
378

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

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

498
    if req.diff:
2✔
499
        diff = await _generate_python_lockfile_diff(
×
500
            final_lockfile_digest, req.resolve_name, req.lockfile_dest
501
        )
502
    else:
503
        diff = None
2✔
504

505
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
2✔
506

507

508
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
12✔
509
    pass
12✔
510

511

512
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
12✔
513
    pass
12✔
514

515

516
@rule
12✔
517
async def determine_python_user_resolves(
12✔
518
    _: KnownPythonUserResolveNamesRequest,
519
    python_setup: PythonSetup,
520
    union_membership: UnionMembership,
521
) -> KnownUserResolveNames:
522
    """Find all know Python resolves, from both user-created resolves and internal tools."""
523
    python_tool_resolves = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
1✔
524

525
    tools_using_default_resolve = [
1✔
526
        resolve_name
527
        for resolve_name, subsystem_cls in python_tool_resolves.items()
528
        if (await _construct_subsystem(subsystem_cls)).install_from_resolve is None
529
    ]
530

531
    return KnownUserResolveNames(
1✔
532
        names=(
533
            *python_setup.resolves.keys(),
534
            *tools_using_default_resolve,
535
        ),  # the order of the keys doesn't matter since shadowing is done in `setup_user_lockfile_requests`
536
        option_name="[python].resolves",
537
        requested_resolve_names_cls=RequestedPythonUserResolveNames,
538
    )
539

540

541
@rule
12✔
542
async def setup_user_lockfile_requests(
12✔
543
    requested: RequestedPythonUserResolveNames,
544
    all_targets: AllTargets,
545
    python_setup: PythonSetup,
546
    union_membership: UnionMembership,
547
) -> UserGenerateLockfiles:
548
    """Transform the names of resolves requested into the `GeneratePythonLockfile` request object.
549

550
    Shadowing is done here by only checking internal resolves if the resolve is not a user-created
551
    resolve.
552
    """
553
    if not (python_setup.enable_resolves and python_setup.resolves_generate_lockfiles):
1✔
554
        return UserGenerateLockfiles()
×
555

556
    resolve_to_requirements_fields = defaultdict(set)
1✔
557
    resolve_to_find_links: dict[str, set[str]] = defaultdict(set)
1✔
558
    for tgt in all_targets:
1✔
559
        if not tgt.has_fields((PythonRequirementResolveField, PythonRequirementsField)):
1✔
560
            continue
×
561
        resolve = tgt[PythonRequirementResolveField].normalized_value(python_setup)
1✔
562
        resolve_to_requirements_fields[resolve].add(tgt[PythonRequirementsField])
1✔
563
        resolve_to_find_links[resolve].update(tgt[PythonRequirementFindLinksField].value or ())
1✔
564

565
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
1✔
566

567
    out = set()
1✔
568
    for resolve in requested:
1✔
569
        if resolve in python_setup.resolves:
1✔
570
            out.add(
1✔
571
                GeneratePythonLockfile(
572
                    requirements=PexRequirements.req_strings_from_requirement_fields(
573
                        resolve_to_requirements_fields[resolve]
574
                    ),
575
                    find_links=FrozenOrderedSet(resolve_to_find_links[resolve]),
576
                    interpreter_constraints=InterpreterConstraints(
577
                        python_setup.resolves_to_interpreter_constraints.get(
578
                            resolve, python_setup.interpreter_constraints
579
                        )
580
                    ),
581
                    resolve_name=resolve,
582
                    lockfile_dest=python_setup.resolves[resolve],
583
                    diff=False,
584
                    lock_style=python_setup.resolves_to_lock_style().get(resolve, "universal"),
585
                    complete_platforms=tuple(
586
                        python_setup.resolves_to_complete_platforms().get(resolve, [])
587
                    ),
588
                )
589
            )
590
        else:
591
            tool_cls: type[PythonToolBase] = tools[resolve]
×
592
            tool = await _construct_subsystem(tool_cls)
×
593

594
            # TODO: we shouldn't be managing default ICs in lockfile identification.
595
            #   We should find a better place to do this or a better way to default
596
            if tool.register_interpreter_constraints:
×
597
                ic = tool.interpreter_constraints
×
598
            else:
599
                ic = InterpreterConstraints(tool.default_interpreter_constraints)
×
600

601
            out.add(
×
602
                GeneratePythonLockfile(
603
                    requirements=FrozenOrderedSet(sorted(tool.requirements)),
604
                    find_links=FrozenOrderedSet(),
605
                    interpreter_constraints=ic,
606
                    resolve_name=resolve,
607
                    lockfile_dest=DEFAULT_TOOL_LOCKFILE,
608
                    diff=False,
609
                    lock_style="universal",  # Tools always use universal style
610
                    complete_platforms=(),  # Tools don't use complete platforms
611
                )
612
            )
613

614
    return UserGenerateLockfiles(out)
1✔
615

616

617
@dataclass(frozen=True)
12✔
618
class PythonSyntheticLockfileTargetsRequest(SyntheticTargetsRequest):
12✔
619
    """Register the type used to create synthetic targets for Python lockfiles.
620

621
    As the paths for all lockfiles are known up-front, we set the `path` field to
622
    `SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS` so that we get a single request for all
623
    our synthetic targets rather than one request per directory.
624
    """
625

626
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
12✔
627

628

629
def synthetic_lockfile_target_name(resolve: str) -> str:
12✔
630
    return f"_{resolve}_lockfile"
3✔
631

632

633
@rule
12✔
634
async def python_lockfile_synthetic_targets(
12✔
635
    request: PythonSyntheticLockfileTargetsRequest,
636
    python_setup: PythonSetup,
637
) -> SyntheticAddressMaps:
638
    if not python_setup.enable_synthetic_lockfiles:
11✔
639
        return SyntheticAddressMaps()
11✔
640

641
    resolves = [
3✔
642
        (os.path.dirname(lockfile), os.path.basename(lockfile), name)
643
        for name, lockfile in python_setup.resolves.items()
644
    ]
645

646
    return SyntheticAddressMaps.for_targets_request(
3✔
647
        request,
648
        [
649
            (
650
                os.path.join(spec_path, "BUILD.python-lockfiles"),
651
                tuple(
652
                    TargetAdaptor(
653
                        "_lockfiles",
654
                        name=synthetic_lockfile_target_name(name),
655
                        sources=(lockfile,),
656
                        __description_of_origin__=f"the [python].resolves option {name!r}",
657
                    )
658
                    for _, lockfile, name in lockfiles
659
                ),
660
            )
661
            for spec_path, lockfiles in itertools.groupby(sorted(resolves), key=itemgetter(0))
662
        ],
663
    )
664

665

666
def rules():
12✔
667
    return (
11✔
668
        *collect_rules(),
669
        UnionRule(GenerateLockfile, GeneratePythonLockfile),
670
        UnionRule(KnownUserResolveNamesRequest, KnownPythonUserResolveNamesRequest),
671
        UnionRule(RequestedUserResolveNames, RequestedPythonUserResolveNames),
672
        UnionRule(SyntheticTargetsRequest, PythonSyntheticLockfileTargetsRequest),
673
    )
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