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

pantsbuild / pants / 20334032453

18 Dec 2025 10:33AM UTC coverage: 80.269% (-0.03%) from 80.295%
20334032453

Pull #22949

github

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

51 of 96 new or added lines in 5 files covered. (53.13%)

3 existing lines in 2 files now uncovered.

78547 of 97855 relevant lines covered (80.27%)

3.36 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
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.core.goals.generate_lockfiles import (
12✔
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
12✔
48
from pants.core.util_rules.external_tool import download_external_tool
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 CreateDigest, Digest, FileContent, MergeDigests
12✔
52
from pants.engine.internals.synthetic_targets import SyntheticAddressMaps, SyntheticTargetsRequest
12✔
53
from pants.engine.internals.target_adaptor import TargetAdaptor
12✔
54
from pants.engine.intrinsics import create_digest, get_digest_contents, merge_digests
12✔
55
from pants.engine.platform import Platform
12✔
56
from pants.engine.process import Process, ProcessCacheScope, fallible_to_exec_result_or_raise
12✔
57
from pants.engine.rules import collect_rules, implicitly, rule
12✔
58
from pants.engine.target import AllTargets
12✔
59
from pants.engine.unions import UnionMembership, UnionRule
12✔
60
from pants.option.errors import OptionsError
12✔
61
from pants.option.subsystem import _construct_subsystem
12✔
62
from pants.util.docutil import bin_name
12✔
63
from pants.util.logging import LogLevel
12✔
64
from pants.util.ordered_set import FrozenOrderedSet
12✔
65
from pants.util.pip_requirement import PipRequirement
12✔
66
from pants.util.strutil import softwrap
12✔
67

68

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

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

82

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

87

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

94

95
def _strip_named_repo(value: str) -> str:
12✔
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 (
×
103
        maybe_name
104
        and "://" not in maybe_name
105
        and ("://" in maybe_url or maybe_url.startswith("file:"))
106
    ):
NEW
107
        return maybe_url
×
NEW
108
    return value
×
109

110

111
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
12✔
112
    resolve_config = await determine_resolve_pex_config(
×
113
        ResolvePexConfigRequest(resolve_name), **implicitly()
114
    )
115

116
    args = list(resolve_config.pex_args())
×
117
    digests: list[Digest] = []
×
118

119
    if resolve_config.constraints_file:
×
120
        args.append(f"--constraints={resolve_config.constraints_file.path}")
×
121
        digests.append(resolve_config.constraints_file.digest)
×
122

123
    input_digest = await merge_digests(MergeDigests(digests))
×
124
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
×
125

126

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

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

NEW
144
    use_uv = python_setup.lockfile_resolver == LockfileResolver.uv
×
NEW
145
    uv_compile_output_digest: Digest | None = None
×
NEW
146
    uv_compiled_requirements_path = "__uv_compiled_requirements.txt"
×
NEW
147
    resolve_config = pip_args_setup.resolve_config
×
148

NEW
149
    if use_uv:
×
NEW
150
        if req.lock_style == "universal":
×
NEW
151
            raise OptionsError(
×
152
                softwrap(
153
                    f"""
154
                    `[python].lockfile_resolver = "uv"` does not yet support `lock_style="universal"`.
155

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

167
                    Either remove `[python].resolves_to_complete_platforms` for `{req.resolve_name}`,
168
                    or set `[python].lockfile_resolver = "pip"`.
169
                    """
170
                )
171
            )
172

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

180
                    Either remove these settings for the resolve, or set `[python].lockfile_resolver = "pip"`.
181
                    """
182
                )
183
            )
184

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

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

214
                    Either narrow your constraints (e.g. `CPython==3.11.*`) or set
215
                    `[python].lockfile_resolver = "pip"`.
216
                    """
217
                )
218
            )
219

UNCOV
220
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
×
221

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

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

NEW
260
    if use_uv:
×
NEW
261
        downloaded_uv = await download_external_tool(uv.get_request(platform))
×
262

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

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

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

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

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

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

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

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

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

468
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
×
469

470

471
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
12✔
472
    pass
12✔
473

474

475
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
12✔
476
    pass
12✔
477

478

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

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

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

503

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

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

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

528
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
529

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

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

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

577
    return UserGenerateLockfiles(out)
×
578

579

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

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

589
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
12✔
590

591

592
def synthetic_lockfile_target_name(resolve: str) -> str:
12✔
593
    return f"_{resolve}_lockfile"
×
594

595

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

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

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

628

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