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

pantsbuild / pants / 24420161240

14 Apr 2026 08:01PM UTC coverage: 92.923% (+0.01%) from 92.91%
24420161240

push

github

web-flow
Fix uv PEX builder to use pex3 lock export (#23227)

When the uv PEX builder extracts pinned requirements from
a PEX-native lockfile, it previously parsed the internal JSON
format directly. This broke VCS and direct URL requirements
(formatted as name==version instead of name @ url), and the
PEX maintainer has warned that this internal format is
unsupported and may change without notice.

This PR replaces JSON parsing with `pex3 lock export --format pep-751`
via PexCliProcess. The exported pylock.toml is passed
directly to uv pip install -r, handling VCS refs, direct URLs,
extras, and platform filtering out of the box.

38 of 39 new or added lines in 3 files covered. (97.44%)

1 existing line in 1 file now uncovered.

91660 of 98641 relevant lines covered (92.92%)

4.04 hits per line

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

96.62
/src/python/pants/backend/python/util_rules/pex.py
1
# Copyright 2019 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 dataclasses
12✔
7
import json
12✔
8
import logging
12✔
9
import os
12✔
10
import shlex
12✔
11
import zlib
12✔
12
from collections.abc import Iterable, Iterator, Mapping, Sequence
12✔
13
from dataclasses import dataclass
12✔
14
from pathlib import PurePath
12✔
15
from textwrap import dedent  # noqa: PNT20
12✔
16
from typing import TypeVar
12✔
17

18
import packaging.specifiers
12✔
19
import packaging.version
12✔
20
from packaging.requirements import Requirement
12✔
21

22
from pants.backend.python.subsystems import uv as uv_subsystem
12✔
23
from pants.backend.python.subsystems.setup import PexBuilder, PythonSetup
12✔
24
from pants.backend.python.subsystems.uv import download_uv_binary
12✔
25
from pants.backend.python.target_types import (
12✔
26
    Executable,
27
    MainSpecification,
28
    PexCompletePlatformsField,
29
    PexLayout,
30
    PythonRequirementFindLinksField,
31
    PythonRequirementsField,
32
)
33
from pants.backend.python.util_rules import pex_cli, pex_requirements
12✔
34
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
12✔
35
from pants.backend.python.util_rules.pex_cli import PexCliProcess, PexPEX, maybe_log_pex_stderr
12✔
36
from pants.backend.python.util_rules.pex_environment import (
12✔
37
    CompletePexEnvironment,
38
    PexEnvironment,
39
    PexSubsystem,
40
    PythonExecutable,
41
)
42
from pants.backend.python.util_rules.pex_requirements import (
12✔
43
    EntireLockfile,
44
    LoadedLockfile,
45
    LoadedLockfileRequest,
46
    Lockfile,
47
    LockfileFormat,
48
    Resolve,
49
    ResolvePexConfigRequest,
50
    determine_resolve_pex_config,
51
    get_lockfile_for_resolve,
52
    load_lockfile,
53
    validate_metadata,
54
)
55
from pants.backend.python.util_rules.pex_requirements import (
12✔
56
    PexRequirements as PexRequirements,  # Explicit re-export.
57
)
58
from pants.build_graph.address import Address
12✔
59
from pants.core.environments.target_types import EnvironmentTarget
12✔
60
from pants.core.target_types import FileSourceField, ResourceSourceField
12✔
61
from pants.core.util_rules.stripped_source_files import StrippedFileNameRequest, strip_file_name
12✔
62
from pants.core.util_rules.stripped_source_files import rules as stripped_source_rules
12✔
63
from pants.core.util_rules.system_binaries import BashBinary
12✔
64
from pants.engine.addresses import UnparsedAddressInputs
12✔
65
from pants.engine.collection import Collection, DeduplicatedCollection
12✔
66
from pants.engine.engine_aware import EngineAwareParameter
12✔
67
from pants.engine.environment import EnvironmentName
12✔
68
from pants.engine.fs import (
12✔
69
    EMPTY_DIGEST,
70
    AddPrefix,
71
    CreateDigest,
72
    Digest,
73
    Directory,
74
    FileContent,
75
    MergeDigests,
76
    RemovePrefix,
77
)
78
from pants.engine.internals.graph import (
12✔
79
    hydrate_sources,
80
    resolve_targets,
81
    resolve_unparsed_address_inputs,
82
)
83
from pants.engine.internals.graph import transitive_targets as transitive_targets_get
12✔
84
from pants.engine.internals.native_engine import Snapshot
12✔
85
from pants.engine.intrinsics import (
12✔
86
    add_prefix,
87
    create_digest,
88
    digest_to_snapshot,
89
    merge_digests,
90
    remove_prefix,
91
)
92
from pants.engine.process import (
12✔
93
    Process,
94
    ProcessCacheScope,
95
    ProcessExecutionFailure,
96
    ProcessResult,
97
    execute_process_or_raise,
98
    fallible_to_exec_result_or_raise,
99
)
100
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
12✔
101
from pants.engine.target import HydrateSourcesRequest, SourcesField, TransitiveTargetsRequest
12✔
102
from pants.engine.unions import UnionMembership, union
12✔
103
from pants.util.frozendict import FrozenDict
12✔
104
from pants.util.logging import LogLevel
12✔
105
from pants.util.strutil import bullet_list, pluralize, softwrap
12✔
106

107
logger = logging.getLogger(__name__)
12✔
108

109

110
@union(in_scope_types=[EnvironmentName])
12✔
111
@dataclass(frozen=True)
12✔
112
class PythonProvider:
12✔
113
    """Union which should have 0 or 1 implementations registered which provide Python.
114

115
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
116
    """
117

118
    interpreter_constraints: InterpreterConstraints
12✔
119

120

121
@rule(polymorphic=True)
12✔
122
async def get_python_executable(
12✔
123
    provider: PythonProvider, env_name: EnvironmentName
124
) -> PythonExecutable:
125
    raise NotImplementedError()
×
126

127

128
class PexPlatforms(DeduplicatedCollection[str]):
12✔
129
    sort_input = True
12✔
130

131
    def generate_pex_arg_list(self) -> list[str]:
12✔
132
        args = []
4✔
133
        for platform in self:
4✔
134
            args.extend(["--platform", platform])
2✔
135
        return args
4✔
136

137

138
class CompletePlatforms(DeduplicatedCollection[str]):
12✔
139
    sort_input = True
12✔
140

141
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
12✔
142
        super().__init__(iterable)
12✔
143
        self._digest = digest
12✔
144

145
    @classmethod
12✔
146
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
12✔
147
        return cls(snapshot.files, digest=snapshot.digest)
7✔
148

149
    @property
12✔
150
    def digest(self) -> Digest:
12✔
151
        return self._digest
12✔
152

153
    def generate_pex_arg_list(self) -> Iterator[str]:
12✔
154
        for path in self:
4✔
155
            yield "--complete-platform"
4✔
156
            yield path
4✔
157

158

159
@rule
12✔
160
async def digest_complete_platform_addresses(
12✔
161
    addresses: UnparsedAddressInputs,
162
) -> CompletePlatforms:
163
    original_file_targets = await resolve_targets(**implicitly(addresses))
7✔
164
    original_files_sources = await concurrently(
7✔
165
        hydrate_sources(
166
            HydrateSourcesRequest(
167
                tgt.get(SourcesField),
168
                for_sources_types=(
169
                    FileSourceField,
170
                    ResourceSourceField,
171
                ),
172
                enable_codegen=True,
173
            ),
174
            **implicitly(),
175
        )
176
        for tgt in original_file_targets
177
    )
178
    snapshot = await digest_to_snapshot(
7✔
179
        **implicitly(MergeDigests(sources.snapshot.digest for sources in original_files_sources))
180
    )
181
    return CompletePlatforms.from_snapshot(snapshot)
7✔
182

183

184
@rule
12✔
185
async def digest_complete_platforms(
12✔
186
    complete_platforms: PexCompletePlatformsField,
187
) -> CompletePlatforms:
188
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
7✔
189

190

191
@dataclass(frozen=True)
12✔
192
class PexRequest(EngineAwareParameter):
12✔
193
    output_filename: str
12✔
194
    internal_only: bool
12✔
195
    layout: PexLayout
12✔
196
    python: PythonExecutable | None
12✔
197
    requirements: PexRequirements | EntireLockfile
12✔
198
    interpreter_constraints: InterpreterConstraints
12✔
199
    platforms: PexPlatforms
12✔
200
    complete_platforms: CompletePlatforms
12✔
201
    sources: Digest | None
12✔
202
    additional_inputs: Digest
12✔
203
    main: MainSpecification | None
12✔
204
    inject_args: tuple[str, ...]
12✔
205
    inject_env: FrozenDict[str, str]
12✔
206
    additional_args: tuple[str, ...]
12✔
207
    pex_path: tuple[Pex, ...]
12✔
208
    description: str | None = dataclasses.field(compare=False)
12✔
209
    cache_scope: ProcessCacheScope
12✔
210

211
    def __init__(
12✔
212
        self,
213
        *,
214
        output_filename: str,
215
        internal_only: bool,
216
        layout: PexLayout | None = None,
217
        python: PythonExecutable | None = None,
218
        requirements: PexRequirements | EntireLockfile = PexRequirements(),
219
        interpreter_constraints=InterpreterConstraints(),
220
        platforms=PexPlatforms(),
221
        complete_platforms=CompletePlatforms(),
222
        sources: Digest | None = None,
223
        additional_inputs: Digest | None = None,
224
        main: MainSpecification | None = None,
225
        inject_args: Iterable[str] = (),
226
        inject_env: Mapping[str, str] = FrozenDict(),
227
        additional_args: Iterable[str] = (),
228
        pex_path: Iterable[Pex] = (),
229
        description: str | None = None,
230
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
231
    ) -> None:
232
        """A request to create a PEX from its inputs.
233

234
        :param output_filename: The name of the built Pex file, which typically should end in
235
            `.pex`.
236
        :param internal_only: Whether we ever materialize the Pex and distribute it directly
237
            to end users, such as with the `binary` goal. Typically, instead, the user never
238
            directly uses the Pex, e.g. with `lint` and `test`. If True, we will use a Pex setting
239
            that results in faster build time but compatibility with fewer interpreters at runtime.
240
        :param layout: The filesystem layout to create the PEX with.
241
        :param python: A particular PythonExecutable to use, which must match any relevant
242
            interpreter_constraints.
243
        :param requirements: The requirements that the PEX should contain.
244
        :param interpreter_constraints: Any constraints on which Python versions may be used.
245
        :param platforms: Which abbreviated platforms should be supported. Setting this value will
246
            cause interpreter constraints to not be used at PEX build time because platforms already
247
            constrain the valid Python versions, e.g. by including `cp36m` in the platform string.
248
            Unfortunately this also causes interpreter constraints to not be embedded in the built
249
            PEX for use at runtime which can lead to problems.
250
            See: https://github.com/pantsbuild/pants/issues/13904.
251
        :param complete_platforms: Which complete platforms should be supported. Setting this value
252
            will cause interpreter constraints to not be used at PEX build time because complete
253
            platforms completely constrain the valid Python versions. Unfortunately this also causes
254
            interpreter constraints to not be embedded in the built PEX for use at runtime which can
255
            lead to problems. See: https://github.com/pantsbuild/pants/issues/13904.
256
        :param sources: Any source files that should be included in the Pex.
257
        :param additional_inputs: Any inputs that are not source files and should not be included
258
            directly in the Pex, but should be present in the environment when building the Pex.
259
        :param main: The main for the built Pex, equivalent to Pex's `-e` or '-c' flag. If
260
            left off, the Pex will open up as a REPL.
261
        :param inject_args: Command line arguments to freeze in to the PEX.
262
        :param inject_env: Environment variables to freeze in to the PEX.
263
        :param additional_args: Any additional Pex flags.
264
        :param pex_path: Pex files to add to the PEX_PATH.
265
        :param description: A human-readable description to render in the dynamic UI when building
266
            the Pex.
267
        :param cache_scope: The cache scope for the underlying pex cli invocation process.
268
        """
269
        object.__setattr__(self, "output_filename", output_filename)
12✔
270
        object.__setattr__(self, "internal_only", internal_only)
12✔
271
        # Use any explicitly requested layout, or Packed for internal PEXes (which is a much
272
        # friendlier layout for the CAS than Zipapp.)
273
        object.__setattr__(
12✔
274
            self, "layout", layout or (PexLayout.PACKED if internal_only else PexLayout.ZIPAPP)
275
        )
276
        object.__setattr__(self, "python", python)
12✔
277
        object.__setattr__(self, "requirements", requirements)
12✔
278
        object.__setattr__(self, "interpreter_constraints", interpreter_constraints)
12✔
279
        object.__setattr__(self, "platforms", platforms)
12✔
280
        object.__setattr__(self, "complete_platforms", complete_platforms)
12✔
281
        object.__setattr__(self, "sources", sources)
12✔
282
        object.__setattr__(self, "additional_inputs", additional_inputs or EMPTY_DIGEST)
12✔
283
        object.__setattr__(self, "main", main)
12✔
284
        object.__setattr__(self, "inject_args", tuple(inject_args))
12✔
285
        object.__setattr__(self, "inject_env", FrozenDict(inject_env))
12✔
286
        object.__setattr__(self, "additional_args", tuple(additional_args))
12✔
287
        object.__setattr__(self, "pex_path", tuple(pex_path))
12✔
288
        object.__setattr__(self, "description", description)
12✔
289
        object.__setattr__(self, "cache_scope", cache_scope)
12✔
290

291
        self.__post_init__()
12✔
292

293
    def __post_init__(self):
12✔
294
        if self.internal_only and self.platforms:
12✔
295
            raise ValueError(
×
296
                softwrap(
297
                    f"""
298
                    Internal only PEXes can only constrain interpreters with interpreter_constraints.
299
                    Given platform constraints {self.platforms} for internal only pex request:
300
                    {self}.
301
                    """
302
                )
303
            )
304
        if self.internal_only and self.complete_platforms:
12✔
305
            raise ValueError(
×
306
                softwrap(
307
                    f"""
308
                    Internal only PEXes can only constrain interpreters with interpreter_constraints.
309
                    Given complete_platform constraints {self.complete_platforms} for internal only
310
                    pex request: {self}.
311
                    """
312
                )
313
            )
314
        if self.python and self.platforms:
12✔
315
            raise ValueError(
×
316
                softwrap(
317
                    f"""
318
                    Only one of platforms or a specific interpreter may be set. Got
319
                    both {self.platforms} and {self.python}.
320
                    """
321
                )
322
            )
323
        if self.python and self.complete_platforms:
12✔
324
            raise ValueError(
×
325
                softwrap(
326
                    f"""
327
                    Only one of complete_platforms or a specific interpreter may be set. Got
328
                    both {self.complete_platforms} and {self.python}.
329
                    """
330
                )
331
            )
332
        if self.python and self.interpreter_constraints:
12✔
333
            raise ValueError(
×
334
                softwrap(
335
                    f"""
336
                    Only one of interpreter_constraints or a specific interpreter may be set. Got
337
                    both {self.interpreter_constraints} and {self.python}.
338
                    """
339
                )
340
            )
341

342
    def debug_hint(self) -> str:
12✔
343
        return self.output_filename
×
344

345

346
@dataclass(frozen=True)
12✔
347
class OptionalPexRequest:
12✔
348
    maybe_pex_request: PexRequest | None
12✔
349

350

351
@dataclass(frozen=True)
12✔
352
class Pex:
12✔
353
    """Wrapper for a digest containing a pex file created with some filename."""
354

355
    digest: Digest
12✔
356
    name: str
12✔
357
    python: PythonExecutable | None
12✔
358

359

360
@dataclass(frozen=True)
12✔
361
class OptionalPex:
12✔
362
    maybe_pex: Pex | None
12✔
363

364

365
@rule(desc="Find Python interpreter for constraints", level=LogLevel.DEBUG)
12✔
366
async def find_interpreter(
12✔
367
    interpreter_constraints: InterpreterConstraints,
368
    pex_subsystem: PexSubsystem,
369
    env_target: EnvironmentTarget,
370
    union_membership: UnionMembership,
371
) -> PythonExecutable:
372
    python_providers = union_membership.get(PythonProvider)
12✔
373
    if len(python_providers) > 1:
12✔
374
        raise ValueError(
×
375
            softwrap(
376
                f"""
377
                Too many Python provider plugins were registered. We expected 0 or 1, but found
378
                {len(python_providers)}. Providers were:
379

380
                {bullet_list(repr(provider.__class__) for provider in python_providers)}
381
                """
382
            )
383
        )
384
    if python_providers:
12✔
385
        python_provider = next(iter(python_providers))
5✔
386
        python = await get_python_executable(
5✔
387
            **implicitly({python_provider(interpreter_constraints): PythonProvider})
388
        )
389
        return python
5✔
390

391
    formatted_constraints = " OR ".join(str(constraint) for constraint in interpreter_constraints)
12✔
392
    result = await fallible_to_exec_result_or_raise(
12✔
393
        **implicitly(
394
            PexCliProcess(
395
                description=f"Find interpreter for constraints: {formatted_constraints}",
396
                subcommand=(),
397
                # Here, we run the Pex CLI with no requirements, which just selects an interpreter.
398
                # Normally, this would start an isolated repl. By passing `--`, we force the repl to
399
                # instead act as an interpreter (the selected one) and tell us about itself. The upshot
400
                # is we run the Pex interpreter selection logic unperturbed but without resolving any
401
                # distributions.
402
                extra_args=(
403
                    *interpreter_constraints.generate_pex_arg_list(),
404
                    "--",
405
                    "-c",
406
                    # N.B.: The following code snippet must be compatible with Python 2.7 and
407
                    # Python 3.5+.
408
                    #
409
                    # When hashing, we pick 8192 for efficiency of reads and fingerprint updates
410
                    # (writes) since it's a common OS buffer size and an even multiple of the
411
                    # hash block size.
412
                    dedent(
413
                        """\
414
                    import hashlib, os, sys
415

416
                    python = os.path.realpath(sys.executable)
417
                    print(python)
418

419
                    hasher = hashlib.sha256()
420
                    with open(python, "rb") as fp:
421
                      for chunk in iter(lambda: fp.read(8192), b""):
422
                          hasher.update(chunk)
423
                    print(hasher.hexdigest())
424
                    """
425
                    ),
426
                ),
427
                level=LogLevel.DEBUG,
428
                cache_scope=env_target.executable_search_path_cache_scope(),
429
            )
430
        )
431
    )
432
    path, fingerprint = result.stdout.decode().strip().splitlines()
12✔
433

434
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
435

436
    return PythonExecutable(path=path, fingerprint=fingerprint)
12✔
437

438

439
@dataclass(frozen=True)
12✔
440
class BuildPexResult:
12✔
441
    result: ProcessResult
12✔
442
    pex_filename: str
12✔
443
    digest: Digest
12✔
444
    python: PythonExecutable | None
12✔
445

446
    def create_pex(self) -> Pex:
12✔
447
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
12✔
448

449

450
@dataclass
12✔
451
class _BuildPexPythonSetup:
12✔
452
    python: PythonExecutable | None
12✔
453
    argv: list[str]
12✔
454

455

456
@rule
12✔
457
async def _determine_pex_python_and_platforms(request: PexRequest) -> _BuildPexPythonSetup:
12✔
458
    # NB: If `--platform` is specified, this signals that the PEX should not be built locally.
459
    # `--interpreter-constraint` only makes sense in the context of building locally. These two
460
    # flags are mutually exclusive. See https://github.com/pex-tool/pex/issues/957.
461
    if request.platforms or request.complete_platforms:
12✔
462
        # Note that this means that this is not an internal-only pex.
463
        # TODO(#9560): consider validating that these platforms are valid with the interpreter
464
        #  constraints.
465
        return _BuildPexPythonSetup(
4✔
466
            None,
467
            [
468
                *request.platforms.generate_pex_arg_list(),
469
                *request.complete_platforms.generate_pex_arg_list(),
470
            ],
471
        )
472

473
    if request.python:
12✔
474
        python = request.python
3✔
475
    else:
476
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
12✔
477

478
    if request.python or request.internal_only:
12✔
479
        # Sometimes we want to build and run with a specific interpreter (either because request
480
        # demanded it, or because it's an internal-only PEX). We will have already validated that
481
        # there were no platforms.
482
        return _BuildPexPythonSetup(python, ["--python", python.path])
12✔
483

484
    else:
485
        # Otherwise, we don't want to force compatibility with a particular interpreter (as in, the
486
        # resulting PEX should follow the ICs), but we _do_ want to tell PEX about at least one
487
        # interpreter that is compatible, to ensure that an interpreter installed/managed by
488
        # provider backends are visible (in the extreme case, a machine may have no Python
489
        # interpreters installed at all, and just rely on Pants' provider backends to install them,
490
        # and thus pex searching $PATH will find nothing).
491
        return _BuildPexPythonSetup(
7✔
492
            python,
493
            [
494
                *request.interpreter_constraints.generate_pex_arg_list(),
495
                "--python-path",
496
                python.path,
497
            ],
498
        )
499

500

501
_UV_VENV_DIR = "__uv_venv"
12✔
502

503

504
@dataclass(frozen=True)
12✔
505
class _UvVenvRequest:
12✔
506
    """Request to build a pre-populated venv using uv for PEX --venv-repository."""
507

508
    req_strings: tuple[str, ...]
12✔
509
    requirements: PexRequirements | EntireLockfile
12✔
510
    python_path: str
12✔
511
    description: str
12✔
512

513

514
@dataclass(frozen=True)
12✔
515
class _UvVenvResult:
12✔
516
    """Result of building a uv venv."""
517

518
    venv_digest: Digest | None
12✔
519

520

521
def _check_uv_preconditions(
12✔
522
    request: PexRequest,
523
    req_strings: tuple[str, ...],
524
    pex_python_setup: _BuildPexPythonSetup,
525
) -> str | None:
526
    """Check whether the uv builder can be used for this PEX request.
527

528
    Returns None if all preconditions are met, or a warning message describing
529
    why uv cannot be used.
530
    """
531
    label = request.description or request.output_filename
1✔
532
    if not req_strings:
1✔
533
        return (
×
534
            f"pex_builder=uv: no individual requirement strings for {label} "
535
            "(e.g. using a whole-lockfile resolve or no third-party deps). "
536
            "Falling back to the default PEX/pip builder."
537
        )
538
    if request.platforms or request.complete_platforms:
1✔
539
        return (
×
540
            f"pex_builder=uv: cross-platform build detected for {label}. "
541
            "Falling back to the default PEX/pip builder."
542
        )
543
    if pex_python_setup.python is None:
1✔
544
        return (
×
545
            f"pex_builder=uv: no local Python interpreter available for {label}. "
546
            "Falling back to the default PEX/pip builder."
547
        )
548
    return None
1✔
549

550

551
@rule
12✔
552
async def _build_uv_venv(
12✔
553
    uv_request: _UvVenvRequest,
554
    pex_env: PexEnvironment,
555
) -> _UvVenvResult:
556
    """Build a pre-populated venv using uv for use with PEX --venv-repository."""
557
    downloaded_uv = await download_uv_binary(**implicitly())
1✔
558

559
    logger.debug(
1✔
560
        "pex_builder=uv: using uv builder for %s",
561
        uv_request.description,
562
    )
563

564
    # Try to export a subset of the lockfile via `pex3 lock export-subset` so we
565
    # can pass only the needed locked requirements with --no-deps (reproducible).
566
    # This uses Pex's stable CLI rather than parsing the internal lockfile JSON
567
    # directly.  Fall back to letting uv resolve transitively if no lockfile.
568
    exported_reqs_digest: Digest | None = None
1✔
569
    reqs_file = "pylock.toml"
1✔
570

571
    if isinstance(uv_request.requirements, PexRequirements) and isinstance(
1✔
572
        uv_request.requirements.from_superset, Resolve
573
    ):
574
        lockfile = await get_lockfile_for_resolve(
1✔
575
            uv_request.requirements.from_superset, **implicitly()
576
        )
577
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
1✔
578
        if loaded_lockfile.lockfile_format == LockfileFormat.Pex:
1✔
579
            try:
1✔
580
                export_result = await fallible_to_exec_result_or_raise(
1✔
581
                    **implicitly(
582
                        PexCliProcess(
583
                            subcommand=("lock", "export-subset"),
584
                            extra_args=(
585
                                *uv_request.req_strings,
586
                                "--lock",
587
                                loaded_lockfile.lockfile_path,
588
                                "--format",
589
                                "pep-751",
590
                                "-o",
591
                                reqs_file,
592
                            ),
593
                            additional_input_digest=loaded_lockfile.lockfile_digest,
594
                            description=f"Export lockfile subset for {uv_request.description}",
595
                            output_files=(reqs_file,),
596
                        )
597
                    )
598
                )
599
                exported_reqs_digest = export_result.output_digest
1✔
NEW
600
            except ProcessExecutionFailure as e:
×
UNCOV
601
                logger.warning(
×
602
                    "pex_builder=uv: failed to export lockfile subset for %s: %s. "
603
                    "Falling back to transitive uv resolution.",
604
                    uv_request.description,
605
                    e,
606
                )
607

608
    use_exported_lockfile = exported_reqs_digest is not None
1✔
609

610
    if use_exported_lockfile:
1✔
611
        logger.debug(
1✔
612
            "pex_builder=uv: using exported lockfile subset with --no-deps for %s",
613
            uv_request.description,
614
        )
615
        assert exported_reqs_digest is not None
1✔
616
        reqs_digest = exported_reqs_digest
1✔
617
    else:
618
        logger.debug(
1✔
619
            "pex_builder=uv: no lockfile available, using transitive uv resolution for %s",
620
            uv_request.description,
621
        )
622
        reqs_file = "__uv_requirements.txt"
1✔
623
        reqs_content = "\n".join(uv_request.req_strings) + "\n"
1✔
624
        reqs_digest = await create_digest(
1✔
625
            CreateDigest([FileContent(reqs_file, reqs_content.encode())])
626
        )
627

628
    complete_pex_env = pex_env.in_sandbox(working_directory=None)
1✔
629
    uv_cache_dir = ".cache/uv_cache"
1✔
630
    uv_env = {
1✔
631
        **complete_pex_env.environment_dict(python_configured=True),
632
        "UV_CACHE_DIR": uv_cache_dir,
633
        "UV_NO_CONFIG": "1",
634
    }
635
    uv_caches = {
1✔
636
        **complete_pex_env.append_only_caches,
637
        "uv_cache": uv_cache_dir,
638
    }
639
    uv_tmpdir = "__uv_tmp"
1✔
640
    tmpdir_digest = await create_digest(CreateDigest([Directory(uv_tmpdir)]))
1✔
641

642
    python_path = uv_request.python_path
1✔
643

644
    uv_input = await merge_digests(MergeDigests([downloaded_uv.digest, reqs_digest, tmpdir_digest]))
1✔
645

646
    # Step 1: Create venv with uv.
647
    venv_result = await execute_process_or_raise(
1✔
648
        **implicitly(
649
            Process(
650
                argv=(
651
                    downloaded_uv.exe,
652
                    "venv",
653
                    _UV_VENV_DIR,
654
                    "--python",
655
                    python_path,
656
                ),
657
                input_digest=uv_input,
658
                output_directories=(_UV_VENV_DIR,),
659
                env={**uv_env, "TMPDIR": uv_tmpdir},
660
                append_only_caches=uv_caches,
661
                description=f"Create uv venv for {uv_request.description}",
662
                level=LogLevel.DEBUG,
663
                cache_scope=ProcessCacheScope.SUCCESSFUL,
664
            )
665
        )
666
    )
667

668
    # Step 2: Install dependencies into the venv.
669
    install_input = await merge_digests(MergeDigests([uv_input, venv_result.output_digest]))
1✔
670

671
    install_argv: tuple[str, ...] = (
1✔
672
        downloaded_uv.exe,
673
        "pip",
674
        "install",
675
        "--python",
676
        os.path.join(_UV_VENV_DIR, "bin", "python"),
677
        "-r",
678
        reqs_file,
679
        *(("--no-deps",) if use_exported_lockfile else ()),
680
        *downloaded_uv.args_for_uv_pip_install,
681
    )
682

683
    uv_install_result = await execute_process_or_raise(
1✔
684
        **implicitly(
685
            Process(
686
                argv=install_argv,
687
                input_digest=install_input,
688
                output_directories=(_UV_VENV_DIR,),
689
                env={**uv_env, "TMPDIR": uv_tmpdir},
690
                append_only_caches=uv_caches,
691
                description=f"uv pip install for {uv_request.description}",
692
                level=LogLevel.DEBUG,
693
                cache_scope=ProcessCacheScope.SUCCESSFUL,
694
            )
695
        )
696
    )
697

698
    return _UvVenvResult(
1✔
699
        venv_digest=uv_install_result.output_digest,
700
    )
701

702

703
@dataclass
12✔
704
class _BuildPexRequirementsSetup:
12✔
705
    digests: list[Digest]
12✔
706
    argv: list[str]
12✔
707
    concurrency_available: int
12✔
708

709

710
@dataclass(frozen=True)
12✔
711
class PexRequirementsInfo:
12✔
712
    req_strings: tuple[str, ...]
12✔
713
    find_links: tuple[str, ...]
12✔
714

715

716
@rule
12✔
717
async def get_req_strings(pex_reqs: PexRequirements) -> PexRequirementsInfo:
12✔
718
    addrs: list[Address] = []
12✔
719
    specs: list[str] = []
12✔
720
    req_strings: list[str] = []
12✔
721
    find_links: set[str] = set()
12✔
722
    for req_str_or_addr in pex_reqs.req_strings_or_addrs:
12✔
723
        if isinstance(req_str_or_addr, Address):
11✔
724
            addrs.append(req_str_or_addr)
11✔
725
        else:
726
            assert isinstance(req_str_or_addr, str)
6✔
727
            # Require a `//` prefix, to distinguish address specs from
728
            # local or VCS requirements.
729
            if req_str_or_addr.startswith(os.path.sep * 2):
6✔
730
                specs.append(req_str_or_addr)
×
731
            else:
732
                req_strings.append(req_str_or_addr)
6✔
733
    if specs:
12✔
734
        addrs_from_specs = await resolve_unparsed_address_inputs(
×
735
            UnparsedAddressInputs(
736
                specs,
737
                owning_address=None,
738
                description_of_origin=pex_reqs.description_of_origin,
739
            ),
740
            **implicitly(),
741
        )
742
        addrs.extend(addrs_from_specs)
×
743
    if addrs:
12✔
744
        transitive_targets = await transitive_targets_get(
11✔
745
            TransitiveTargetsRequest(addrs), **implicitly()
746
        )
747
        req_strings.extend(
11✔
748
            PexRequirements.req_strings_from_requirement_fields(
749
                tgt[PythonRequirementsField]
750
                for tgt in transitive_targets.closure
751
                if tgt.has_field(PythonRequirementsField)
752
            )
753
        )
754
        find_links.update(
11✔
755
            find_links
756
            for tgt in transitive_targets.closure
757
            if tgt.has_field(PythonRequirementFindLinksField)
758
            for find_links in tgt[PythonRequirementFindLinksField].value or ()
759
        )
760
    return PexRequirementsInfo(tuple(sorted(req_strings)), tuple(sorted(find_links)))
12✔
761

762

763
async def _get_entire_lockfile_and_requirements(
12✔
764
    requirements: EntireLockfile | PexRequirements,
765
) -> tuple[LoadedLockfile | None, tuple[str, ...]]:
766
    lockfile: Lockfile | None = None
12✔
767
    complete_req_strings: tuple[str, ...] = tuple()
12✔
768
    # TODO: This is clunky, but can be simplified once we get rid of old-style tool
769
    #  lockfiles, because we can unify EntireLockfile and Resolve.
770
    if isinstance(requirements, EntireLockfile):
12✔
771
        complete_req_strings = requirements.complete_req_strings or tuple()
12✔
772
        lockfile = requirements.lockfile
12✔
773
    elif (
12✔
774
        isinstance(requirements.from_superset, Resolve)
775
        and requirements.from_superset.use_entire_lockfile
776
    ):
777
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
5✔
778
    if not lockfile:
12✔
779
        return None, complete_req_strings
11✔
780
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
12✔
781
    return loaded_lockfile, complete_req_strings
12✔
782

783

784
@rule
12✔
785
async def _setup_pex_requirements(
12✔
786
    request: PexRequest, python_setup: PythonSetup
787
) -> _BuildPexRequirementsSetup:
788
    resolve_name: str | None
789
    if isinstance(request.requirements, EntireLockfile):
12✔
790
        resolve_name = request.requirements.lockfile.resolve_name
12✔
791
    elif isinstance(request.requirements.from_superset, Resolve):
12✔
792
        resolve_name = request.requirements.from_superset.name
6✔
793
    else:
794
        # This implies that, currently, per-resolve options are only configurable for resolves.
795
        # However, if no resolve is specified, we will still load options that apply to every
796
        # resolve, like `[python-repos].indexes`.
797
        resolve_name = None
11✔
798
    resolve_config = await determine_resolve_pex_config(
12✔
799
        ResolvePexConfigRequest(resolve_name), **implicitly()
800
    )
801

802
    pex_lock_resolver_args = list(resolve_config.pex_args())
12✔
803
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
12✔
804

805
    loaded_lockfile, complete_req_strings = await _get_entire_lockfile_and_requirements(
12✔
806
        request.requirements
807
    )
808
    if loaded_lockfile:
12✔
809
        argv = (
12✔
810
            ["--lock", loaded_lockfile.lockfile_path, *pex_lock_resolver_args]
811
            if loaded_lockfile.lockfile_format == LockfileFormat.Pex
812
            # We use pip to resolve a requirements.txt pseudo-lockfile, possibly with hashes.
813
            else [
814
                "--requirement",
815
                loaded_lockfile.lockfile_path,
816
                "--no-transitive",
817
                *pip_resolver_args,
818
            ]
819
        )
820
        if loaded_lockfile.metadata and complete_req_strings:
12✔
821
            validate_metadata(
1✔
822
                loaded_lockfile.metadata,
823
                request.interpreter_constraints,
824
                loaded_lockfile.original_lockfile,
825
                complete_req_strings,
826
                # We're using the entire lockfile, so there is no Pex subsetting operation we
827
                # can delegate requirement validation to.  So we do our naive string-matching
828
                # validation.
829
                validate_consumed_req_strings=True,
830
                python_setup=python_setup,
831
                resolve_config=resolve_config,
832
            )
833

834
        return _BuildPexRequirementsSetup(
12✔
835
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
836
        )
837

838
    assert isinstance(request.requirements, PexRequirements)
11✔
839
    reqs_info = await get_req_strings(request.requirements)
11✔
840

841
    # TODO: This is not the best heuristic for available concurrency, since the
842
    # requirements almost certainly have transitive deps which also need building, but it
843
    # is better than using something hardcoded.
844
    concurrency_available = len(reqs_info.req_strings)
11✔
845

846
    if isinstance(request.requirements.from_superset, Pex):
11✔
847
        repository_pex = request.requirements.from_superset
1✔
848
        return _BuildPexRequirementsSetup(
1✔
849
            [repository_pex.digest],
850
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
851
            concurrency_available,
852
        )
853

854
    elif isinstance(request.requirements.from_superset, Resolve):
11✔
855
        lockfile = await get_lockfile_for_resolve(
2✔
856
            request.requirements.from_superset, **implicitly()
857
        )
858
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
2✔
859

860
        # NB: This is also validated in the constructor.
861
        assert loaded_lockfile.lockfile_format == LockfileFormat.Pex
2✔
862
        if not reqs_info.req_strings:
2✔
863
            return _BuildPexRequirementsSetup([], [], concurrency_available)
1✔
864

865
        if loaded_lockfile.metadata:
1✔
866
            validate_metadata(
×
867
                loaded_lockfile.metadata,
868
                request.interpreter_constraints,
869
                loaded_lockfile.original_lockfile,
870
                consumed_req_strings=reqs_info.req_strings,
871
                # Don't validate user requirements when subsetting a resolve, as Pex's
872
                # validation during the subsetting is far more precise than our naive string
873
                # comparison. For example, if a lockfile was generated with `foo==1.2.3`
874
                # and we want to resolve `foo>=1.0.0` or just `foo` out of it, Pex will do
875
                # so successfully, while our naive validation would fail.
876
                validate_consumed_req_strings=False,
877
                python_setup=python_setup,
878
                resolve_config=resolve_config,
879
            )
880

881
        return _BuildPexRequirementsSetup(
1✔
882
            [loaded_lockfile.lockfile_digest],
883
            [
884
                *reqs_info.req_strings,
885
                "--lock",
886
                loaded_lockfile.lockfile_path,
887
                *pex_lock_resolver_args,
888
            ],
889
            concurrency_available,
890
        )
891

892
    # We use pip to perform a normal resolve.
893
    digests = []
11✔
894
    argv = [
11✔
895
        *reqs_info.req_strings,
896
        *pip_resolver_args,
897
        *(f"--find-links={find_links}" for find_links in reqs_info.find_links),
898
    ]
899
    if request.requirements.constraints_strings:
11✔
900
        constraints_file = "__constraints.txt"
1✔
901
        constraints_content = "\n".join(request.requirements.constraints_strings)
1✔
902
        digests.append(
1✔
903
            await create_digest(
904
                CreateDigest([FileContent(constraints_file, constraints_content.encode())])
905
            )
906
        )
907
        argv.extend(["--constraints", constraints_file])
1✔
908
    return _BuildPexRequirementsSetup(digests, argv, concurrency_available=concurrency_available)
11✔
909

910

911
@rule(level=LogLevel.DEBUG)
12✔
912
async def build_pex(
12✔
913
    request: PexRequest,
914
    python_setup: PythonSetup,
915
    pex_subsystem: PexSubsystem,
916
    pex_env: PexEnvironment,
917
) -> BuildPexResult:
918
    """Returns a PEX with the given settings."""
919

920
    if not request.python and not request.interpreter_constraints:
12✔
921
        # Blank ICs in the request means that the caller wants us to use the ICs configured
922
        # for the resolve (falling back to the global ICs).
923
        resolve_name = ""
12✔
924
        if isinstance(request.requirements, PexRequirements) and isinstance(
12✔
925
            request.requirements.from_superset, Resolve
926
        ):
927
            resolve_name = request.requirements.from_superset.name
2✔
928
        elif isinstance(request.requirements, EntireLockfile):
12✔
929
            resolve_name = request.requirements.lockfile.resolve_name
12✔
930

931
        if resolve_name:
12✔
932
            request = dataclasses.replace(
12✔
933
                request,
934
                interpreter_constraints=InterpreterConstraints(
935
                    python_setup.resolves_to_interpreter_constraints.get(
936
                        resolve_name,
937
                        python_setup.interpreter_constraints,
938
                    )
939
                ),
940
            )
941

942
    source_dir_name = "source_files"
12✔
943

944
    pex_python_setup_req = _determine_pex_python_and_platforms(request)
12✔
945
    requirements_setup_req = _setup_pex_requirements(**implicitly({request: PexRequest}))
12✔
946
    sources_digest_as_subdir_req = add_prefix(
12✔
947
        AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
948
    )
949
    if isinstance(request.requirements, PexRequirements):
12✔
950
        (
12✔
951
            pex_python_setup,
952
            requirements_setup,
953
            sources_digest_as_subdir,
954
            req_info,
955
        ) = await concurrently(
956
            pex_python_setup_req,
957
            requirements_setup_req,
958
            sources_digest_as_subdir_req,
959
            get_req_strings(request.requirements),
960
        )
961
        req_strings = req_info.req_strings
12✔
962
    else:
963
        pex_python_setup, requirements_setup, sources_digest_as_subdir = await concurrently(
12✔
964
            pex_python_setup_req,
965
            requirements_setup_req,
966
            sources_digest_as_subdir_req,
967
        )
968
        req_strings = ()
12✔
969

970
    # Experimental: build PEX via uv + --venv-repository.
971
    # When opted in, we use uv to create a pre-populated venv and let PEX
972
    # package from it instead of resolving with pip.
973
    # See: https://github.com/pantsbuild/pants/issues/20679
974
    uv_venv_digest: Digest | None = None
12✔
975

976
    use_uv_builder = python_setup.pex_builder == PexBuilder.uv
12✔
977
    # uv builder only applies to non-internal PEXes with requirements and a
978
    # local interpreter (not cross-platform builds).
979
    if use_uv_builder and not request.internal_only:
12✔
980
        fallback_reason = _check_uv_preconditions(request, req_strings, pex_python_setup)
1✔
981
        if fallback_reason:
1✔
982
            logger.warning(fallback_reason)
×
983
        else:
984
            assert pex_python_setup.python is not None
1✔
985
            uv_result = await _build_uv_venv(
1✔
986
                _UvVenvRequest(
987
                    req_strings=req_strings,
988
                    requirements=request.requirements,
989
                    python_path=pex_python_setup.python.path,
990
                    description=request.description or request.output_filename,
991
                ),
992
                **implicitly(),
993
            )
994
            uv_venv_digest = uv_result.venv_digest
1✔
995

996
            # Replace requirements_setup: pass requirement strings + --venv-repository
997
            # so PEX subsets from the uv-populated venv instead of resolving with pip.
998
            requirements_setup = _BuildPexRequirementsSetup(
1✔
999
                digests=[],
1000
                argv=[*req_strings, f"--venv-repository={_UV_VENV_DIR}"],
1001
                concurrency_available=requirements_setup.concurrency_available,
1002
            )
1003
    elif use_uv_builder and request.internal_only:
12✔
1004
        logger.debug(
1✔
1005
            "pex_builder=uv: skipping for internal-only PEX %s. Using the default PEX/pip builder.",
1006
            request.description or request.output_filename,
1007
        )
1008

1009
    output_chroot = os.path.dirname(request.output_filename)
12✔
1010
    if output_chroot:
12✔
1011
        output_file = request.output_filename
5✔
1012
        strip_output_chroot = False
5✔
1013
    else:
1014
        # In principle a cache should always be just a cache, but existing
1015
        # tests in this repo make the assumption that they can look into a
1016
        # still intact cache and see the same thing as was there before, which
1017
        # requires this to be deterministic and not random.  adler32, because
1018
        # it is in the stlib, fast, and doesn't need to be cryptographic.
1019
        output_chroot = f"pex-dist-{zlib.adler32(request.output_filename.encode()):08x}"
12✔
1020
        strip_output_chroot = True
12✔
1021
        output_file = os.path.join(output_chroot, request.output_filename)
12✔
1022

1023
    argv = [
12✔
1024
        "--output-file",
1025
        output_file,
1026
        *request.additional_args,
1027
    ]
1028

1029
    if uv_venv_digest is not None:
12✔
1030
        # When using --venv-repository, PEX does not allow any custom target
1031
        # flags (--python, --interpreter-constraint, --platform). The target is
1032
        # implicitly the venv interpreter.
1033
        pass
1✔
1034
    else:
1035
        argv.extend(pex_python_setup.argv)
12✔
1036

1037
    if request.main is not None:
12✔
1038
        argv.extend(request.main.iter_pex_args())
12✔
1039
        if isinstance(request.main, Executable):
12✔
1040
            # Unlike other MainSpecifiecation types (that can pass spec as-is to pex),
1041
            # Executable must be an actual path relative to the sandbox.
1042
            # request.main.spec is a python source file including its spec_path.
1043
            # To make it relative to the sandbox, we strip the source root
1044
            # and add the source_dir_name (sources get prefixed with that below).
1045
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
1✔
1046
            argv.append(os.path.join(source_dir_name, stripped.value))
1✔
1047

1048
    argv.extend(
12✔
1049
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
1050
    )
1051
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
12✔
1052

1053
    # TODO(John Sirois): Right now any request requirements will shadow corresponding pex path
1054
    #  requirements, which could lead to problems. Support shading python binaries.
1055
    #  See: https://github.com/pantsbuild/pants/issues/9206
1056
    if request.pex_path:
12✔
1057
        argv.extend(["--pex-path", ":".join(pex.name for pex in request.pex_path)])
7✔
1058

1059
    if request.internal_only:
12✔
1060
        # An internal-only runs on a single machine, and pre-installing wheels is wasted work in
1061
        # that case (see https://github.com/pex-tool/pex/issues/2292#issuecomment-1854582647 for
1062
        # analysis).
1063
        argv.append("--no-pre-install-wheels")
12✔
1064

1065
    argv.append(f"--sources-directory={source_dir_name}")
12✔
1066

1067
    # Include any additional arguments and input digests required by the requirements.
1068
    argv.extend(requirements_setup.argv)
12✔
1069

1070
    merged_digest = await merge_digests(
12✔
1071
        MergeDigests(
1072
            (
1073
                request.complete_platforms.digest,
1074
                sources_digest_as_subdir,
1075
                request.additional_inputs,
1076
                *requirements_setup.digests,
1077
                *(pex.digest for pex in request.pex_path),
1078
                *([uv_venv_digest] if uv_venv_digest else []),
1079
            )
1080
        )
1081
    )
1082

1083
    argv.extend(["--layout", request.layout.value])
12✔
1084

1085
    result = await fallible_to_exec_result_or_raise(
12✔
1086
        **implicitly(
1087
            PexCliProcess(
1088
                subcommand=(),
1089
                extra_args=argv,
1090
                additional_input_digest=merged_digest,
1091
                description=_build_pex_description(request, req_strings, python_setup.resolves),
1092
                output_files=None,
1093
                output_directories=[output_chroot],
1094
                concurrency_available=requirements_setup.concurrency_available,
1095
                cache_scope=request.cache_scope,
1096
            )
1097
        )
1098
    )
1099

1100
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
1101

1102
    if strip_output_chroot:
12✔
1103
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
12✔
1104
    else:
1105
        output_digest = result.output_digest
5✔
1106

1107
    digest = (
12✔
1108
        await merge_digests(
1109
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
1110
        )
1111
        if request.pex_path
1112
        else output_digest
1113
    )
1114

1115
    return BuildPexResult(
12✔
1116
        result=result,
1117
        pex_filename=request.output_filename,
1118
        digest=digest,
1119
        python=pex_python_setup.python,
1120
    )
1121

1122

1123
def _build_pex_description(
12✔
1124
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
1125
) -> str:
1126
    if request.description:
12✔
1127
        return request.description
3✔
1128

1129
    if isinstance(request.requirements, EntireLockfile):
12✔
1130
        lockfile = request.requirements.lockfile
12✔
1131
        desc_suffix = f"from {lockfile.url}"
12✔
1132
    else:
1133
        if not req_strings:
12✔
1134
            return f"Building {request.output_filename}"
12✔
1135
        elif isinstance(request.requirements.from_superset, Pex):
6✔
1136
            repo_pex = request.requirements.from_superset.name
1✔
1137
            return softwrap(
1✔
1138
                f"""
1139
                Extracting {pluralize(len(req_strings), "requirement")}
1140
                to build {request.output_filename} from {repo_pex}:
1141
                {", ".join(req_strings)}
1142
                """
1143
            )
1144
        elif isinstance(request.requirements.from_superset, Resolve):
6✔
1145
            # At this point we know this is a valid user resolve, so we can assume
1146
            # it's available in the dict. Nonetheless we use get() so that any weird error
1147
            # here gives a bad message rather than an outright crash.
1148
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
3✔
1149
            return softwrap(
3✔
1150
                f"""
1151
                Building {pluralize(len(req_strings), "requirement")}
1152
                for {request.output_filename} from the {lockfile_path} resolve:
1153
                {", ".join(req_strings)}
1154
                """
1155
            )
1156
        else:
1157
            desc_suffix = softwrap(
6✔
1158
                f"""
1159
                with {pluralize(len(req_strings), "requirement")}:
1160
                {", ".join(req_strings)}
1161
                """
1162
            )
1163
    return f"Building {request.output_filename} {desc_suffix}"
12✔
1164

1165

1166
@rule
12✔
1167
async def create_pex(request: PexRequest) -> Pex:
12✔
1168
    result = await build_pex(request, **implicitly())
11✔
1169
    return result.create_pex()
11✔
1170

1171

1172
@rule
12✔
1173
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
12✔
1174
    if request.maybe_pex_request is None:
11✔
1175
        return OptionalPex(None)
11✔
1176
    result = await create_pex(request.maybe_pex_request)
1✔
1177
    return OptionalPex(result)
1✔
1178

1179

1180
@dataclass(frozen=True)
12✔
1181
class Script:
12✔
1182
    path: PurePath
12✔
1183

1184
    @property
12✔
1185
    def argv0(self) -> str:
12✔
1186
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
12✔
1187

1188

1189
@dataclass(frozen=True)
12✔
1190
class VenvScript:
12✔
1191
    script: Script
12✔
1192
    content: FileContent
12✔
1193

1194

1195
@dataclass(frozen=True)
12✔
1196
class VenvScriptWriter:
12✔
1197
    complete_pex_env: CompletePexEnvironment
12✔
1198
    pex: Pex
12✔
1199
    venv_dir: PurePath
12✔
1200

1201
    @classmethod
12✔
1202
    def create(
12✔
1203
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
1204
    ) -> VenvScriptWriter:
1205
        # N.B.: We don't know the working directory that will be used in any given
1206
        # invocation of the venv scripts; so we deal with working_directory once in an
1207
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
1208
        # CWD offset math in every rule for all the relative paths their process depends on.
1209
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
12✔
1210
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
12✔
1211

1212
    def _create_venv_script(
12✔
1213
        self,
1214
        bash: BashBinary,
1215
        *,
1216
        script_path: PurePath,
1217
        venv_executable: PurePath,
1218
    ) -> VenvScript:
1219
        env_vars = (
12✔
1220
            f"{name}={shlex.quote(value)}"
1221
            for name, value in self.complete_pex_env.environment_dict(
1222
                python_configured=True
1223
            ).items()
1224
        )
1225

1226
        target_venv_executable = shlex.quote(str(venv_executable))
12✔
1227
        venv_dir = shlex.quote(str(self.venv_dir))
12✔
1228
        execute_pex_args = " ".join(
12✔
1229
            f"$(adjust_relative_paths {shlex.quote(arg)})"
1230
            for arg in self.complete_pex_env.create_argv(self.pex.name, python=self.pex.python)
1231
        )
1232

1233
        script = dedent(
12✔
1234
            f"""\
1235
            #!{bash.path}
1236
            set -euo pipefail
1237

1238
            # N.B.: This relies on BASH_SOURCE which has been available since bash-3.0, released in
1239
            # 2004. It will either contain the absolute path of the venv script or it will contain
1240
            # the relative path from the CWD to the venv script. Either way, we know the venv script
1241
            # parent directory is the sandbox root directory.
1242
            SANDBOX_ROOT="${{BASH_SOURCE%/*}}"
1243

1244
            function adjust_relative_paths() {{
1245
                local value0="$1"
1246
                shift
1247
                if [ "${{value0:0:1}}" == "/" ]; then
1248
                    # Don't relativize absolute paths.
1249
                    echo "${{value0}}" "$@"
1250
                else
1251
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
1252
                    # this script works when run with a PWD set somewhere else than the sandbox
1253
                    # root.
1254
                    #
1255
                    # There are two cases to consider. For the purposes of example, assume PWD is
1256
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
1257
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
1258
                    #
1259
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
1260
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
1261
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
1262
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
1263
                    #    `../..`, we calculate `../../config/tool.yml`.
1264
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
1265
                fi
1266
            }}
1267

1268
            export {" ".join(env_vars)}
1269
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1270

1271
            execute_pex_args="{execute_pex_args}"
1272
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1273
            venv_dir="$(adjust_relative_paths {venv_dir})"
1274

1275
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1276
            # with tools support.
1277
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1278
              exec ${{execute_pex_args}} "$@"
1279
            fi
1280

1281
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1282
            # `--venv` mode PEX file.
1283
            if [ ! -e "${{venv_dir}}" ]; then
1284
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1285
            fi
1286

1287
            exec "${{target_venv_executable}}" "$@"
1288
            """
1289
        )
1290
        return VenvScript(
12✔
1291
            script=Script(script_path),
1292
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1293
        )
1294

1295
    def exe(self, bash: BashBinary) -> VenvScript:
12✔
1296
        """Writes a safe shim for the venv's executable `pex` script."""
1297
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
12✔
1298
        return self._create_venv_script(
12✔
1299
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1300
        )
1301

1302
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
12✔
1303
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1304
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
12✔
1305
        return self._create_venv_script(
12✔
1306
            bash,
1307
            script_path=script_path,
1308
            venv_executable=self.venv_dir / "bin" / name,
1309
        )
1310

1311
    def python(self, bash: BashBinary) -> VenvScript:
12✔
1312
        """Writes a safe shim for the venv's python binary."""
1313
        return self.bin(bash, "python")
12✔
1314

1315

1316
@dataclass(frozen=True)
12✔
1317
class VenvPex:
12✔
1318
    digest: Digest
12✔
1319
    append_only_caches: FrozenDict[str, str] | None
12✔
1320
    pex_filename: str
12✔
1321
    pex: Script
12✔
1322
    python: Script
12✔
1323
    bin: FrozenDict[str, Script]
12✔
1324
    venv_rel_dir: str
12✔
1325

1326

1327
@dataclass(frozen=True)
12✔
1328
class VenvPexRequest:
12✔
1329
    pex_request: PexRequest
12✔
1330
    complete_pex_env: CompletePexEnvironment
12✔
1331
    bin_names: tuple[str, ...] = ()
12✔
1332
    site_packages_copies: bool = False
12✔
1333

1334
    def __init__(
12✔
1335
        self,
1336
        pex_request: PexRequest,
1337
        complete_pex_env: CompletePexEnvironment,
1338
        bin_names: Iterable[str] = (),
1339
        site_packages_copies: bool = False,
1340
    ) -> None:
1341
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1342

1343
        :param pex_request: The details of the desired PEX.
1344
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1345
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1346
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1347
            dependencies when installing them in the venv site-packages directory. By default this
1348
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1349
            but results in a non-standard venv structure that does trip up some libraries.
1350
        """
1351
        object.__setattr__(self, "pex_request", pex_request)
12✔
1352
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
12✔
1353
        object.__setattr__(self, "bin_names", tuple(bin_names))
12✔
1354
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
12✔
1355

1356

1357
@rule
12✔
1358
async def wrap_venv_prex_request(
12✔
1359
    pex_request: PexRequest, pex_environment: PexEnvironment
1360
) -> VenvPexRequest:
1361
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1362
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
12✔
1363

1364

1365
@rule
12✔
1366
async def create_venv_pex(
12✔
1367
    request: VenvPexRequest, bash: BashBinary, pex_environment: PexEnvironment
1368
) -> VenvPex:
1369
    # VenvPex is motivated by improving performance of Python tools by eliminating traditional PEX
1370
    # file startup overhead.
1371
    #
1372
    # To achieve the minimal overhead (on the order of 1ms) we discard:
1373
    # 1. Using Pex default mode:
1374
    #    Although this does reduce initial tool execution overhead, it still leaves a minimum
1375
    #    O(100ms) of overhead per subsequent tool invocation. Fundamentally, Pex still needs to
1376
    #    execute its `sys.path` isolation bootstrap code in this case.
1377
    # 2. Using the Pex `venv` tool:
1378
    #    The idea here would be to create a tool venv as a Process output and then use the tool
1379
    #    venv as an input digest for all tool invocations. This was tried and netted ~500ms of
1380
    #    overhead over raw venv use.
1381
    #
1382
    # Instead we use Pex's `--venv` mode. In this mode you can run the Pex file and it will create a
1383
    # venv on the fly in the PEX_ROOT as needed. Since the PEX_ROOT is a named_cache, we avoid the
1384
    # digest materialization overhead present in 2 above. Since the venv is naturally isolated we
1385
    # avoid the `sys.path` isolation overhead of Pex itself present in 1 above.
1386
    #
1387
    # This does leave O(50ms) of overhead though for the PEX bootstrap code to detect an already
1388
    # created venv in the PEX_ROOT and re-exec into it. To eliminate this overhead we execute the
1389
    # `pex` venv script in the PEX_ROOT directly. This is not robust on its own though, since the
1390
    # named caches store might be pruned at any time. To guard against that case we introduce a shim
1391
    # bash script that checks to see if the `pex` venv script exists in the PEX_ROOT and re-creates
1392
    # the PEX_ROOT venv if not. Using the shim script to run Python tools gets us down to the ~1ms
1393
    # of overhead we currently enjoy.
1394

1395
    pex_request = request.pex_request
12✔
1396
    seeded_venv_request = dataclasses.replace(
12✔
1397
        pex_request,
1398
        additional_args=pex_request.additional_args
1399
        + (
1400
            "--venv",
1401
            "prepend",
1402
            "--seed",
1403
            "verbose",
1404
            pex_environment.venv_site_packages_copies_option(
1405
                use_copies=request.site_packages_copies
1406
            ),
1407
        ),
1408
    )
1409
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
12✔
1410
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1411
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1412
    # directory.
1413
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
12✔
1414
    abs_pex_root = PurePath(seed_info["pex_root"])
12✔
1415
    abs_pex_path = PurePath(seed_info["pex"])
12✔
1416
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
12✔
1417

1418
    venv_script_writer = VenvScriptWriter.create(
12✔
1419
        complete_pex_env=request.complete_pex_env,
1420
        pex=venv_pex_result.create_pex(),
1421
        venv_rel_dir=venv_rel_dir,
1422
    )
1423
    pex = venv_script_writer.exe(bash)
12✔
1424
    python = venv_script_writer.python(bash)
12✔
1425
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
12✔
1426
    scripts_digest = await create_digest(
12✔
1427
        CreateDigest(
1428
            (
1429
                pex.content,
1430
                python.content,
1431
                *(venv_script.content for venv_script in scripts.values()),
1432
            )
1433
        )
1434
    )
1435
    input_digest = await merge_digests(
12✔
1436
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1437
    )
1438
    append_only_caches = (
12✔
1439
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1440
    )
1441

1442
    return VenvPex(
12✔
1443
        digest=input_digest,
1444
        append_only_caches=append_only_caches,
1445
        pex_filename=venv_pex_result.pex_filename,
1446
        pex=pex.script,
1447
        python=python.script,
1448
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1449
        venv_rel_dir=venv_rel_dir.as_posix(),
1450
    )
1451

1452

1453
@dataclass(frozen=True)
12✔
1454
class PexProcess:
12✔
1455
    pex: Pex
12✔
1456
    argv: tuple[str, ...]
12✔
1457
    description: str = dataclasses.field(compare=False)
12✔
1458
    level: LogLevel
12✔
1459
    input_digest: Digest | None
12✔
1460
    working_directory: str | None
12✔
1461
    extra_env: FrozenDict[str, str]
12✔
1462
    output_files: tuple[str, ...] | None
12✔
1463
    output_directories: tuple[str, ...] | None
12✔
1464
    timeout_seconds: int | None
12✔
1465
    execution_slot_variable: str | None
12✔
1466
    concurrency_available: int
12✔
1467
    cache_scope: ProcessCacheScope
12✔
1468

1469
    def __init__(
12✔
1470
        self,
1471
        pex: Pex,
1472
        *,
1473
        description: str,
1474
        argv: Iterable[str] = (),
1475
        level: LogLevel = LogLevel.INFO,
1476
        input_digest: Digest | None = None,
1477
        working_directory: str | None = None,
1478
        extra_env: Mapping[str, str] | None = None,
1479
        output_files: Iterable[str] | None = None,
1480
        output_directories: Iterable[str] | None = None,
1481
        timeout_seconds: int | None = None,
1482
        execution_slot_variable: str | None = None,
1483
        concurrency_available: int = 0,
1484
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1485
    ) -> None:
1486
        object.__setattr__(self, "pex", pex)
7✔
1487
        object.__setattr__(self, "argv", tuple(argv))
7✔
1488
        object.__setattr__(self, "description", description)
7✔
1489
        object.__setattr__(self, "level", level)
7✔
1490
        object.__setattr__(self, "input_digest", input_digest)
7✔
1491
        object.__setattr__(self, "working_directory", working_directory)
7✔
1492
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
7✔
1493
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
7✔
1494
        object.__setattr__(
7✔
1495
            self, "output_directories", tuple(output_directories) if output_directories else None
1496
        )
1497
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
7✔
1498
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
7✔
1499
        object.__setattr__(self, "concurrency_available", concurrency_available)
7✔
1500
        object.__setattr__(self, "cache_scope", cache_scope)
7✔
1501

1502

1503
@rule
12✔
1504
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
12✔
1505
    pex = request.pex
7✔
1506
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
7✔
1507
    argv = complete_pex_env.create_argv(pex.name, *request.argv, python=pex.python)
7✔
1508
    env = {
7✔
1509
        **complete_pex_env.environment_dict(python_configured=pex.python is not None),
1510
        **request.extra_env,
1511
    }
1512
    input_digest = (
7✔
1513
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1514
        if request.input_digest
1515
        else pex.digest
1516
    )
1517
    append_only_caches = (
7✔
1518
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1519
    )
1520
    return Process(
7✔
1521
        argv,
1522
        description=request.description,
1523
        level=request.level,
1524
        input_digest=input_digest,
1525
        working_directory=request.working_directory,
1526
        env=env,
1527
        output_files=request.output_files,
1528
        output_directories=request.output_directories,
1529
        append_only_caches={
1530
            **complete_pex_env.append_only_caches,
1531
            **append_only_caches,
1532
        },
1533
        timeout_seconds=request.timeout_seconds,
1534
        execution_slot_variable=request.execution_slot_variable,
1535
        concurrency_available=request.concurrency_available,
1536
        cache_scope=request.cache_scope,
1537
    )
1538

1539

1540
@dataclass(unsafe_hash=True)
12✔
1541
class VenvPexProcess:
12✔
1542
    venv_pex: VenvPex
12✔
1543
    argv: tuple[str, ...]
12✔
1544
    description: str = dataclasses.field(compare=False)
12✔
1545
    level: LogLevel
12✔
1546
    input_digest: Digest | None
12✔
1547
    working_directory: str | None
12✔
1548
    extra_env: FrozenDict[str, str]
12✔
1549
    output_files: tuple[str, ...] | None
12✔
1550
    output_directories: tuple[str, ...] | None
12✔
1551
    timeout_seconds: int | None
12✔
1552
    execution_slot_variable: str | None
12✔
1553
    concurrency_available: int
12✔
1554
    cache_scope: ProcessCacheScope
12✔
1555
    append_only_caches: FrozenDict[str, str]
12✔
1556

1557
    def __init__(
12✔
1558
        self,
1559
        venv_pex: VenvPex,
1560
        *,
1561
        description: str,
1562
        argv: Iterable[str] = (),
1563
        level: LogLevel = LogLevel.INFO,
1564
        input_digest: Digest | None = None,
1565
        working_directory: str | None = None,
1566
        extra_env: Mapping[str, str] | None = None,
1567
        output_files: Iterable[str] | None = None,
1568
        output_directories: Iterable[str] | None = None,
1569
        timeout_seconds: int | None = None,
1570
        execution_slot_variable: str | None = None,
1571
        concurrency_available: int = 0,
1572
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1573
        append_only_caches: Mapping[str, str] | None = None,
1574
    ) -> None:
1575
        object.__setattr__(self, "venv_pex", venv_pex)
12✔
1576
        object.__setattr__(self, "argv", tuple(argv))
12✔
1577
        object.__setattr__(self, "description", description)
12✔
1578
        object.__setattr__(self, "level", level)
12✔
1579
        object.__setattr__(self, "input_digest", input_digest)
12✔
1580
        object.__setattr__(self, "working_directory", working_directory)
12✔
1581
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
12✔
1582
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
12✔
1583
        object.__setattr__(
12✔
1584
            self, "output_directories", tuple(output_directories) if output_directories else None
1585
        )
1586
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
12✔
1587
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
12✔
1588
        object.__setattr__(self, "concurrency_available", concurrency_available)
12✔
1589
        object.__setattr__(self, "cache_scope", cache_scope)
12✔
1590
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
12✔
1591

1592

1593
@rule
12✔
1594
async def setup_venv_pex_process(
12✔
1595
    request: VenvPexProcess, pex_environment: PexEnvironment
1596
) -> Process:
1597
    venv_pex = request.venv_pex
12✔
1598
    pex_bin = (
12✔
1599
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1600
        if request.working_directory
1601
        else venv_pex.pex.argv0
1602
    )
1603
    argv = (pex_bin, *request.argv)
12✔
1604
    input_digest = (
12✔
1605
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1606
        if request.input_digest
1607
        else venv_pex.digest
1608
    )
1609
    append_only_caches: FrozenDict[str, str] = FrozenDict(
12✔
1610
        **pex_environment.in_sandbox(
1611
            working_directory=request.working_directory
1612
        ).append_only_caches,
1613
        **request.append_only_caches,
1614
        **(FrozenDict({}) if venv_pex.append_only_caches is None else venv_pex.append_only_caches),
1615
    )
1616
    return Process(
12✔
1617
        argv=argv,
1618
        description=request.description,
1619
        level=request.level,
1620
        input_digest=input_digest,
1621
        working_directory=request.working_directory,
1622
        env=request.extra_env,
1623
        output_files=request.output_files,
1624
        output_directories=request.output_directories,
1625
        append_only_caches=append_only_caches,
1626
        timeout_seconds=request.timeout_seconds,
1627
        execution_slot_variable=request.execution_slot_variable,
1628
        concurrency_available=request.concurrency_available,
1629
        cache_scope=request.cache_scope,
1630
    )
1631

1632

1633
@dataclass(frozen=True)
12✔
1634
class PexDistributionInfo:
12✔
1635
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1636
    repository info -v`."""
1637

1638
    project_name: str
12✔
1639
    version: packaging.version.Version
12✔
1640
    requires_python: packaging.specifiers.SpecifierSet | None
12✔
1641
    # Note: These are parsed from metadata written by the pex tool, and are always
1642
    #   a valid packaging.requirements.Requirement.
1643
    requires_dists: tuple[Requirement, ...]
12✔
1644

1645

1646
DefaultT = TypeVar("DefaultT")
12✔
1647

1648

1649
class PexResolveInfo(Collection[PexDistributionInfo]):
12✔
1650
    """Information about all distributions resolved in a PEX file, as reported by `PEX_TOOLS=1
1651
    repository info -v`."""
1652

1653
    def find(
12✔
1654
        self, name: str, default: DefaultT | None = None
1655
    ) -> PexDistributionInfo | DefaultT | None:
1656
        """Returns the PexDistributionInfo with the given name, first one wins."""
1657
        try:
6✔
1658
            return next(info for info in self if info.project_name == name)
6✔
1659
        except StopIteration:
×
1660
            return default
×
1661

1662

1663
def parse_repository_info(repository_info: str) -> PexResolveInfo:
12✔
1664
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
7✔
1665
        for line in repository_info.splitlines():
7✔
1666
            info = json.loads(line)
7✔
1667
            requires_python = info["requires_python"]
7✔
1668
            yield PexDistributionInfo(
7✔
1669
                project_name=info["project_name"],
1670
                version=packaging.version.Version(info["version"]),
1671
                requires_python=(
1672
                    packaging.specifiers.SpecifierSet(requires_python)
1673
                    if requires_python is not None
1674
                    else None
1675
                ),
1676
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1677
            )
1678

1679
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
7✔
1680

1681

1682
@rule
12✔
1683
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
12✔
1684
    process_result = await fallible_to_exec_result_or_raise(
6✔
1685
        **implicitly(
1686
            VenvPexProcess(
1687
                venv_pex,
1688
                argv=["repository", "info", "-v"],
1689
                extra_env={"PEX_TOOLS": "1"},
1690
                input_digest=venv_pex.digest,
1691
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1692
                level=LogLevel.DEBUG,
1693
            )
1694
        )
1695
    )
1696
    return parse_repository_info(process_result.stdout.decode())
6✔
1697

1698

1699
@rule
12✔
1700
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
12✔
1701
    process_result = await fallible_to_exec_result_or_raise(
4✔
1702
        **implicitly(
1703
            PexProcess(
1704
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1705
                argv=[pex.name, "repository", "info", "-v"],
1706
                input_digest=pex.digest,
1707
                extra_env={"PEX_MODULE": "pex.tools"},
1708
                description=f"Determine distributions found in {pex.name}",
1709
                level=LogLevel.DEBUG,
1710
            )
1711
        )
1712
    )
1713
    return parse_repository_info(process_result.stdout.decode())
4✔
1714

1715

1716
def rules():
12✔
1717
    return [
12✔
1718
        *collect_rules(),
1719
        *pex_cli.rules(),
1720
        *pex_requirements.rules(),
1721
        *uv_subsystem.rules(),  # Also in register.py; engine deduplicates.
1722
        *stripped_source_rules(),
1723
    ]
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