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

pantsbuild / pants / 23805124382

31 Mar 2026 03:19PM UTC coverage: 92.903% (-0.02%) from 92.918%
23805124382

push

github

web-flow
An experimental uv-based PEX builder (#23197)

Add an experimental `[python].pex_builder` option that allows using
[uv](https://github.com/astral-sh/uv) to install dependencies when
building PEX files via `pants package`.

When set to `"uv"`, Pants:
1. Downloads the `uv` binary (as an `ExternalTool`).
2. Creates a virtual environment with `uv venv`.
3. Installs dependencies with `uv pip install`.
4. Passes the pre-populated venv to PEX via `--venv-repository`.

When a PEX-native lockfile is available, uv installs the exact pinned
versions with `--no-deps`, preserving reproducibility. Otherwise it
falls back to transitive resolution from requirement strings.

Builds that cannot use uv (internal-only, cross-platform, no local
interpreter) silently fall back to the default pip path.

## Benchmark

Raw `pip install` vs `uv pip install` for 11 packages (requests, boto3,
cryptography, aiohttp, sqlalchemy, pillow, etc.) measured with
[hyperfine](https://github.com/sharkdp/hyperfine):

| Condition | pip | uv | Speedup |
|-----------|-----|-----|---------|
| Cold cache | 6.3s | 4.1s | **1.6x faster** |
| Warm cache | 6.9s | 0.14s | **51x faster** |

Within Pants, the end-to-end improvement is smaller because scheduler
and bootstrap overhead dominates, but the dependency installation step
itself is significantly faster — especially with warm caches on
repeated builds.

## LLM Disclosure

Code was written by the author. Claude was used for code review,
catching edge cases, and verifying test coverage.

122 of 138 new or added lines in 5 files covered. (88.41%)

9 existing lines in 1 file now uncovered.

91642 of 98643 relevant lines covered (92.9%)

4.05 hits per line

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

94.93
/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
    Resolve,
48
    ResolvePexConfigRequest,
49
    determine_resolve_pex_config,
50
    get_lockfile_for_resolve,
51
    load_lockfile,
52
    validate_metadata,
53
)
54
from pants.backend.python.util_rules.pex_requirements import (
12✔
55
    PexRequirements as PexRequirements,  # Explicit re-export.
56
)
57
from pants.build_graph.address import Address
12✔
58
from pants.core.environments.target_types import EnvironmentTarget
12✔
59
from pants.core.target_types import FileSourceField, ResourceSourceField
12✔
60
from pants.core.util_rules.stripped_source_files import StrippedFileNameRequest, strip_file_name
12✔
61
from pants.core.util_rules.stripped_source_files import rules as stripped_source_rules
12✔
62
from pants.core.util_rules.system_binaries import BashBinary
12✔
63
from pants.engine.addresses import UnparsedAddressInputs
12✔
64
from pants.engine.collection import Collection, DeduplicatedCollection
12✔
65
from pants.engine.engine_aware import EngineAwareParameter
12✔
66
from pants.engine.environment import EnvironmentName
12✔
67
from pants.engine.fs import (
12✔
68
    EMPTY_DIGEST,
69
    AddPrefix,
70
    CreateDigest,
71
    Digest,
72
    Directory,
73
    FileContent,
74
    MergeDigests,
75
    RemovePrefix,
76
)
77
from pants.engine.internals.graph import (
12✔
78
    hydrate_sources,
79
    resolve_targets,
80
    resolve_unparsed_address_inputs,
81
)
82
from pants.engine.internals.graph import transitive_targets as transitive_targets_get
12✔
83
from pants.engine.internals.native_engine import Snapshot
12✔
84
from pants.engine.intrinsics import (
12✔
85
    add_prefix,
86
    create_digest,
87
    digest_to_snapshot,
88
    get_digest_contents,
89
    merge_digests,
90
    remove_prefix,
91
)
92
from pants.engine.process import (
12✔
93
    Process,
94
    ProcessCacheScope,
95
    ProcessResult,
96
    execute_process_or_raise,
97
    fallible_to_exec_result_or_raise,
98
)
99
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
12✔
100
from pants.engine.target import HydrateSourcesRequest, SourcesField, TransitiveTargetsRequest
12✔
101
from pants.engine.unions import UnionMembership, union
12✔
102
from pants.util.frozendict import FrozenDict
12✔
103
from pants.util.logging import LogLevel
12✔
104
from pants.util.strutil import bullet_list, pluralize, softwrap
12✔
105

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

108

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

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

117
    interpreter_constraints: InterpreterConstraints
12✔
118

119

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

126

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

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

136

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

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

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

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

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

157

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

182

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

189

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

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

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

290
        self.__post_init__()
12✔
291

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

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

344

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

349

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

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

358

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

363

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

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

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

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

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

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

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

437

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

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

448

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

454

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

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

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

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

499

500
_UV_VENV_DIR = "__uv_venv"
12✔
501

502

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

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

512

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

517
    venv_digest: Digest | None
12✔
518

519

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

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

549

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

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

563
    # Try to extract the full resolved package list from the lockfile
564
    # so we can pass pinned versions with --no-deps (reproducible).
565
    # Fall back to letting uv resolve transitively if no lockfile.
566
    all_resolved_reqs: tuple[str, ...] = ()
1✔
567
    if isinstance(uv_request.requirements, PexRequirements) and isinstance(
1✔
568
        uv_request.requirements.from_superset, Resolve
569
    ):
NEW
570
        lockfile = await get_lockfile_for_resolve(
×
571
            uv_request.requirements.from_superset, **implicitly()
572
        )
NEW
573
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
NEW
574
        if loaded_lockfile.is_pex_native:
×
NEW
575
            try:
×
NEW
576
                digest_contents = await get_digest_contents(loaded_lockfile.lockfile_digest)
×
NEW
577
                lockfile_bytes = next(
×
578
                    c.content for c in digest_contents if c.path == loaded_lockfile.lockfile_path
579
                )
NEW
580
                lockfile_data = json.loads(lockfile_bytes)
×
NEW
581
                all_resolved_reqs = tuple(
×
582
                    f"{req['project_name']}=={req['version']}"
583
                    for resolve in lockfile_data.get("locked_resolves", ())
584
                    for req in resolve.get("locked_requirements", ())
585
                )
NEW
586
            except (json.JSONDecodeError, KeyError, StopIteration) as e:
×
NEW
587
                logger.warning(
×
588
                    "pex_builder=uv: failed to parse lockfile for %s: %s. "
589
                    "Falling back to transitive uv resolution.",
590
                    uv_request.description,
591
                    e,
592
                )
NEW
593
                all_resolved_reqs = ()
×
594

595
    uv_reqs = all_resolved_reqs or uv_request.req_strings
1✔
596

597
    if all_resolved_reqs:
1✔
NEW
598
        logger.debug(
×
599
            "pex_builder=uv: using %d pinned packages from lockfile with --no-deps for %s",
600
            len(all_resolved_reqs),
601
            uv_request.description,
602
        )
603
    else:
604
        logger.debug(
1✔
605
            "pex_builder=uv: no lockfile available, using transitive uv resolution for %s",
606
            uv_request.description,
607
        )
608

609
    reqs_file = "__uv_requirements.txt"
1✔
610
    reqs_content = "\n".join(uv_reqs) + "\n"
1✔
611
    reqs_digest = await create_digest(CreateDigest([FileContent(reqs_file, reqs_content.encode())]))
1✔
612

613
    complete_pex_env = pex_env.in_sandbox(working_directory=None)
1✔
614
    uv_cache_dir = ".cache/uv_cache"
1✔
615
    uv_env = {
1✔
616
        **complete_pex_env.environment_dict(python_configured=True),
617
        "UV_CACHE_DIR": uv_cache_dir,
618
        "UV_NO_CONFIG": "1",
619
    }
620
    uv_caches = {
1✔
621
        **complete_pex_env.append_only_caches,
622
        "uv_cache": uv_cache_dir,
623
    }
624
    uv_tmpdir = "__uv_tmp"
1✔
625
    tmpdir_digest = await create_digest(CreateDigest([Directory(uv_tmpdir)]))
1✔
626

627
    python_path = uv_request.python_path
1✔
628

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

631
    # Step 1: Create venv with uv.
632
    venv_result = await execute_process_or_raise(
1✔
633
        **implicitly(
634
            Process(
635
                argv=(
636
                    downloaded_uv.exe,
637
                    "venv",
638
                    _UV_VENV_DIR,
639
                    "--python",
640
                    python_path,
641
                ),
642
                input_digest=uv_input,
643
                output_directories=(_UV_VENV_DIR,),
644
                env={**uv_env, "TMPDIR": uv_tmpdir},
645
                append_only_caches=uv_caches,
646
                description=f"Create uv venv for {uv_request.description}",
647
                level=LogLevel.DEBUG,
648
                cache_scope=ProcessCacheScope.SUCCESSFUL,
649
            )
650
        )
651
    )
652

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

656
    install_argv: tuple[str, ...] = (
1✔
657
        downloaded_uv.exe,
658
        "pip",
659
        "install",
660
        "--python",
661
        os.path.join(_UV_VENV_DIR, "bin", "python"),
662
        "-r",
663
        reqs_file,
664
        *(("--no-deps",) if all_resolved_reqs else ()),
665
        *downloaded_uv.args_for_uv_pip_install,
666
    )
667

668
    uv_install_result = await execute_process_or_raise(
1✔
669
        **implicitly(
670
            Process(
671
                argv=install_argv,
672
                input_digest=install_input,
673
                output_directories=(_UV_VENV_DIR,),
674
                env={**uv_env, "TMPDIR": uv_tmpdir},
675
                append_only_caches=uv_caches,
676
                description=f"uv pip install for {uv_request.description}",
677
                level=LogLevel.DEBUG,
678
                cache_scope=ProcessCacheScope.SUCCESSFUL,
679
            )
680
        )
681
    )
682

683
    return _UvVenvResult(
1✔
684
        venv_digest=uv_install_result.output_digest,
685
    )
686

687

688
@dataclass
12✔
689
class _BuildPexRequirementsSetup:
12✔
690
    digests: list[Digest]
12✔
691
    argv: list[str]
12✔
692
    concurrency_available: int
12✔
693

694

695
@dataclass(frozen=True)
12✔
696
class PexRequirementsInfo:
12✔
697
    req_strings: tuple[str, ...]
12✔
698
    find_links: tuple[str, ...]
12✔
699

700

701
@rule
12✔
702
async def get_req_strings(pex_reqs: PexRequirements) -> PexRequirementsInfo:
12✔
703
    addrs: list[Address] = []
12✔
704
    specs: list[str] = []
12✔
705
    req_strings: list[str] = []
12✔
706
    find_links: set[str] = set()
12✔
707
    for req_str_or_addr in pex_reqs.req_strings_or_addrs:
12✔
708
        if isinstance(req_str_or_addr, Address):
11✔
709
            addrs.append(req_str_or_addr)
11✔
710
        else:
711
            assert isinstance(req_str_or_addr, str)
6✔
712
            # Require a `//` prefix, to distinguish address specs from
713
            # local or VCS requirements.
714
            if req_str_or_addr.startswith(os.path.sep * 2):
6✔
715
                specs.append(req_str_or_addr)
×
716
            else:
717
                req_strings.append(req_str_or_addr)
6✔
718
    if specs:
12✔
719
        addrs_from_specs = await resolve_unparsed_address_inputs(
×
720
            UnparsedAddressInputs(
721
                specs,
722
                owning_address=None,
723
                description_of_origin=pex_reqs.description_of_origin,
724
            ),
725
            **implicitly(),
726
        )
727
        addrs.extend(addrs_from_specs)
×
728
    if addrs:
12✔
729
        transitive_targets = await transitive_targets_get(
11✔
730
            TransitiveTargetsRequest(addrs), **implicitly()
731
        )
732
        req_strings.extend(
11✔
733
            PexRequirements.req_strings_from_requirement_fields(
734
                tgt[PythonRequirementsField]
735
                for tgt in transitive_targets.closure
736
                if tgt.has_field(PythonRequirementsField)
737
            )
738
        )
739
        find_links.update(
11✔
740
            find_links
741
            for tgt in transitive_targets.closure
742
            if tgt.has_field(PythonRequirementFindLinksField)
743
            for find_links in tgt[PythonRequirementFindLinksField].value or ()
744
        )
745
    return PexRequirementsInfo(tuple(sorted(req_strings)), tuple(sorted(find_links)))
12✔
746

747

748
async def _get_entire_lockfile_and_requirements(
12✔
749
    requirements: EntireLockfile | PexRequirements,
750
) -> tuple[LoadedLockfile | None, tuple[str, ...]]:
751
    lockfile: Lockfile | None = None
12✔
752
    complete_req_strings: tuple[str, ...] = tuple()
12✔
753
    # TODO: This is clunky, but can be simplified once we get rid of old-style tool
754
    #  lockfiles, because we can unify EntireLockfile and Resolve.
755
    if isinstance(requirements, EntireLockfile):
12✔
756
        complete_req_strings = requirements.complete_req_strings or tuple()
12✔
757
        lockfile = requirements.lockfile
12✔
758
    elif (
12✔
759
        isinstance(requirements.from_superset, Resolve)
760
        and requirements.from_superset.use_entire_lockfile
761
    ):
762
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
5✔
763
    if not lockfile:
12✔
764
        return None, complete_req_strings
11✔
765
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
12✔
766
    return loaded_lockfile, complete_req_strings
12✔
767

768

769
@rule
12✔
770
async def _setup_pex_requirements(
12✔
771
    request: PexRequest, python_setup: PythonSetup
772
) -> _BuildPexRequirementsSetup:
773
    resolve_name: str | None
774
    if isinstance(request.requirements, EntireLockfile):
12✔
775
        resolve_name = request.requirements.lockfile.resolve_name
12✔
776
    elif isinstance(request.requirements.from_superset, Resolve):
12✔
777
        resolve_name = request.requirements.from_superset.name
6✔
778
    else:
779
        # This implies that, currently, per-resolve options are only configurable for resolves.
780
        # However, if no resolve is specified, we will still load options that apply to every
781
        # resolve, like `[python-repos].indexes`.
782
        resolve_name = None
11✔
783
    resolve_config = await determine_resolve_pex_config(
12✔
784
        ResolvePexConfigRequest(resolve_name), **implicitly()
785
    )
786

787
    pex_lock_resolver_args = list(resolve_config.pex_args())
12✔
788
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
12✔
789

790
    loaded_lockfile, complete_req_strings = await _get_entire_lockfile_and_requirements(
12✔
791
        request.requirements
792
    )
793
    if loaded_lockfile:
12✔
794
        argv = (
12✔
795
            ["--lock", loaded_lockfile.lockfile_path, *pex_lock_resolver_args]
796
            if loaded_lockfile.is_pex_native
797
            # We use pip to resolve a requirements.txt pseudo-lockfile, possibly with hashes.
798
            else [
799
                "--requirement",
800
                loaded_lockfile.lockfile_path,
801
                "--no-transitive",
802
                *pip_resolver_args,
803
            ]
804
        )
805
        if loaded_lockfile.metadata and complete_req_strings:
12✔
806
            validate_metadata(
1✔
807
                loaded_lockfile.metadata,
808
                request.interpreter_constraints,
809
                loaded_lockfile.original_lockfile,
810
                complete_req_strings,
811
                # We're using the entire lockfile, so there is no Pex subsetting operation we
812
                # can delegate requirement validation to.  So we do our naive string-matching
813
                # validation.
814
                validate_consumed_req_strings=True,
815
                python_setup=python_setup,
816
                resolve_config=resolve_config,
817
            )
818

819
        return _BuildPexRequirementsSetup(
12✔
820
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
821
        )
822

823
    assert isinstance(request.requirements, PexRequirements)
11✔
824
    reqs_info = await get_req_strings(request.requirements)
11✔
825

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

831
    if isinstance(request.requirements.from_superset, Pex):
11✔
832
        repository_pex = request.requirements.from_superset
1✔
833
        return _BuildPexRequirementsSetup(
1✔
834
            [repository_pex.digest],
835
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
836
            concurrency_available,
837
        )
838

839
    elif isinstance(request.requirements.from_superset, Resolve):
11✔
840
        lockfile = await get_lockfile_for_resolve(
2✔
841
            request.requirements.from_superset, **implicitly()
842
        )
843
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
2✔
844

845
        # NB: This is also validated in the constructor.
846
        assert loaded_lockfile.is_pex_native
2✔
847
        if not reqs_info.req_strings:
2✔
848
            return _BuildPexRequirementsSetup([], [], concurrency_available)
1✔
849

850
        if loaded_lockfile.metadata:
1✔
851
            validate_metadata(
×
852
                loaded_lockfile.metadata,
853
                request.interpreter_constraints,
854
                loaded_lockfile.original_lockfile,
855
                consumed_req_strings=reqs_info.req_strings,
856
                # Don't validate user requirements when subsetting a resolve, as Pex's
857
                # validation during the subsetting is far more precise than our naive string
858
                # comparison. For example, if a lockfile was generated with `foo==1.2.3`
859
                # and we want to resolve `foo>=1.0.0` or just `foo` out of it, Pex will do
860
                # so successfully, while our naive validation would fail.
861
                validate_consumed_req_strings=False,
862
                python_setup=python_setup,
863
                resolve_config=resolve_config,
864
            )
865

866
        return _BuildPexRequirementsSetup(
1✔
867
            [loaded_lockfile.lockfile_digest],
868
            [
869
                *reqs_info.req_strings,
870
                "--lock",
871
                loaded_lockfile.lockfile_path,
872
                *pex_lock_resolver_args,
873
            ],
874
            concurrency_available,
875
        )
876

877
    # We use pip to perform a normal resolve.
878
    digests = []
11✔
879
    argv = [
11✔
880
        *reqs_info.req_strings,
881
        *pip_resolver_args,
882
        *(f"--find-links={find_links}" for find_links in reqs_info.find_links),
883
    ]
884
    if request.requirements.constraints_strings:
11✔
885
        constraints_file = "__constraints.txt"
1✔
886
        constraints_content = "\n".join(request.requirements.constraints_strings)
1✔
887
        digests.append(
1✔
888
            await create_digest(
889
                CreateDigest([FileContent(constraints_file, constraints_content.encode())])
890
            )
891
        )
892
        argv.extend(["--constraints", constraints_file])
1✔
893
    return _BuildPexRequirementsSetup(digests, argv, concurrency_available=concurrency_available)
11✔
894

895

896
@rule(level=LogLevel.DEBUG)
12✔
897
async def build_pex(
12✔
898
    request: PexRequest,
899
    python_setup: PythonSetup,
900
    pex_subsystem: PexSubsystem,
901
    pex_env: PexEnvironment,
902
) -> BuildPexResult:
903
    """Returns a PEX with the given settings."""
904

905
    if not request.python and not request.interpreter_constraints:
12✔
906
        # Blank ICs in the request means that the caller wants us to use the ICs configured
907
        # for the resolve (falling back to the global ICs).
908
        resolve_name = ""
12✔
909
        if isinstance(request.requirements, PexRequirements) and isinstance(
12✔
910
            request.requirements.from_superset, Resolve
911
        ):
912
            resolve_name = request.requirements.from_superset.name
1✔
913
        elif isinstance(request.requirements, EntireLockfile):
12✔
914
            resolve_name = request.requirements.lockfile.resolve_name
12✔
915

916
        if resolve_name:
12✔
917
            request = dataclasses.replace(
12✔
918
                request,
919
                interpreter_constraints=InterpreterConstraints(
920
                    python_setup.resolves_to_interpreter_constraints.get(
921
                        resolve_name,
922
                        python_setup.interpreter_constraints,
923
                    )
924
                ),
925
            )
926

927
    source_dir_name = "source_files"
12✔
928

929
    pex_python_setup_req = _determine_pex_python_and_platforms(request)
12✔
930
    requirements_setup_req = _setup_pex_requirements(**implicitly({request: PexRequest}))
12✔
931
    sources_digest_as_subdir_req = add_prefix(
12✔
932
        AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
933
    )
934
    if isinstance(request.requirements, PexRequirements):
12✔
935
        (
12✔
936
            pex_python_setup,
937
            requirements_setup,
938
            sources_digest_as_subdir,
939
            req_info,
940
        ) = await concurrently(
941
            pex_python_setup_req,
942
            requirements_setup_req,
943
            sources_digest_as_subdir_req,
944
            get_req_strings(request.requirements),
945
        )
946
        req_strings = req_info.req_strings
12✔
947
    else:
948
        pex_python_setup, requirements_setup, sources_digest_as_subdir = await concurrently(
12✔
949
            pex_python_setup_req,
950
            requirements_setup_req,
951
            sources_digest_as_subdir_req,
952
        )
953
        req_strings = ()
12✔
954

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

961
    use_uv_builder = python_setup.pex_builder == PexBuilder.uv
12✔
962
    # uv builder only applies to non-internal PEXes with requirements and a
963
    # local interpreter (not cross-platform builds).
964
    if use_uv_builder and not request.internal_only:
12✔
965
        fallback_reason = _check_uv_preconditions(request, req_strings, pex_python_setup)
1✔
966
        if fallback_reason:
1✔
NEW
967
            logger.warning(fallback_reason)
×
968
        else:
969
            assert pex_python_setup.python is not None
1✔
970
            uv_result = await _build_uv_venv(
1✔
971
                _UvVenvRequest(
972
                    req_strings=req_strings,
973
                    requirements=request.requirements,
974
                    python_path=pex_python_setup.python.path,
975
                    description=request.description or request.output_filename,
976
                ),
977
                **implicitly(),
978
            )
979
            uv_venv_digest = uv_result.venv_digest
1✔
980

981
            # Replace requirements_setup: pass requirement strings + --venv-repository
982
            # so PEX subsets from the uv-populated venv instead of resolving with pip.
983
            requirements_setup = _BuildPexRequirementsSetup(
1✔
984
                digests=[],
985
                argv=[*req_strings, f"--venv-repository={_UV_VENV_DIR}"],
986
                concurrency_available=requirements_setup.concurrency_available,
987
            )
988
    elif use_uv_builder and request.internal_only:
12✔
989
        logger.debug(
1✔
990
            "pex_builder=uv: skipping for internal-only PEX %s. Using the default PEX/pip builder.",
991
            request.description or request.output_filename,
992
        )
993

994
    output_chroot = os.path.dirname(request.output_filename)
12✔
995
    if output_chroot:
12✔
996
        output_file = request.output_filename
5✔
997
        strip_output_chroot = False
5✔
998
    else:
999
        # In principle a cache should always be just a cache, but existing
1000
        # tests in this repo make the assumption that they can look into a
1001
        # still intact cache and see the same thing as was there before, which
1002
        # requires this to be deterministic and not random.  adler32, because
1003
        # it is in the stlib, fast, and doesn't need to be cryptographic.
1004
        output_chroot = f"pex-dist-{zlib.adler32(request.output_filename.encode()):08x}"
12✔
1005
        strip_output_chroot = True
12✔
1006
        output_file = os.path.join(output_chroot, request.output_filename)
12✔
1007

1008
    argv = [
12✔
1009
        "--output-file",
1010
        output_file,
1011
        *request.additional_args,
1012
    ]
1013

1014
    if uv_venv_digest is not None:
12✔
1015
        # When using --venv-repository, PEX does not allow any custom target
1016
        # flags (--python, --interpreter-constraint, --platform). The target is
1017
        # implicitly the venv interpreter.
1018
        pass
1✔
1019
    else:
1020
        argv.extend(pex_python_setup.argv)
12✔
1021

1022
    if request.main is not None:
12✔
1023
        argv.extend(request.main.iter_pex_args())
12✔
1024
        if isinstance(request.main, Executable):
12✔
1025
            # Unlike other MainSpecifiecation types (that can pass spec as-is to pex),
1026
            # Executable must be an actual path relative to the sandbox.
1027
            # request.main.spec is a python source file including its spec_path.
1028
            # To make it relative to the sandbox, we strip the source root
1029
            # and add the source_dir_name (sources get prefixed with that below).
1030
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
1✔
1031
            argv.append(os.path.join(source_dir_name, stripped.value))
1✔
1032

1033
    argv.extend(
12✔
1034
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
1035
    )
1036
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
12✔
1037

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

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

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

1052
    # Include any additional arguments and input digests required by the requirements.
1053
    argv.extend(requirements_setup.argv)
12✔
1054

1055
    merged_digest = await merge_digests(
12✔
1056
        MergeDigests(
1057
            (
1058
                request.complete_platforms.digest,
1059
                sources_digest_as_subdir,
1060
                request.additional_inputs,
1061
                *requirements_setup.digests,
1062
                *(pex.digest for pex in request.pex_path),
1063
                *([uv_venv_digest] if uv_venv_digest else []),
1064
            )
1065
        )
1066
    )
1067

1068
    argv.extend(["--layout", request.layout.value])
12✔
1069

1070
    result = await fallible_to_exec_result_or_raise(
12✔
1071
        **implicitly(
1072
            PexCliProcess(
1073
                subcommand=(),
1074
                extra_args=argv,
1075
                additional_input_digest=merged_digest,
1076
                description=_build_pex_description(request, req_strings, python_setup.resolves),
1077
                output_files=None,
1078
                output_directories=[output_chroot],
1079
                concurrency_available=requirements_setup.concurrency_available,
1080
                cache_scope=request.cache_scope,
1081
            )
1082
        )
1083
    )
1084

1085
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
1086

1087
    if strip_output_chroot:
12✔
1088
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
12✔
1089
    else:
1090
        output_digest = result.output_digest
5✔
1091

1092
    digest = (
12✔
1093
        await merge_digests(
1094
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
1095
        )
1096
        if request.pex_path
1097
        else output_digest
1098
    )
1099

1100
    return BuildPexResult(
12✔
1101
        result=result,
1102
        pex_filename=request.output_filename,
1103
        digest=digest,
1104
        python=pex_python_setup.python,
1105
    )
1106

1107

1108
def _build_pex_description(
12✔
1109
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
1110
) -> str:
1111
    if request.description:
12✔
1112
        return request.description
3✔
1113

1114
    if isinstance(request.requirements, EntireLockfile):
12✔
1115
        lockfile = request.requirements.lockfile
12✔
1116
        desc_suffix = f"from {lockfile.url}"
12✔
1117
    else:
1118
        if not req_strings:
12✔
1119
            return f"Building {request.output_filename}"
12✔
1120
        elif isinstance(request.requirements.from_superset, Pex):
6✔
1121
            repo_pex = request.requirements.from_superset.name
1✔
1122
            return softwrap(
1✔
1123
                f"""
1124
                Extracting {pluralize(len(req_strings), "requirement")}
1125
                to build {request.output_filename} from {repo_pex}:
1126
                {", ".join(req_strings)}
1127
                """
1128
            )
1129
        elif isinstance(request.requirements.from_superset, Resolve):
6✔
1130
            # At this point we know this is a valid user resolve, so we can assume
1131
            # it's available in the dict. Nonetheless we use get() so that any weird error
1132
            # here gives a bad message rather than an outright crash.
1133
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
2✔
1134
            return softwrap(
2✔
1135
                f"""
1136
                Building {pluralize(len(req_strings), "requirement")}
1137
                for {request.output_filename} from the {lockfile_path} resolve:
1138
                {", ".join(req_strings)}
1139
                """
1140
            )
1141
        else:
1142
            desc_suffix = softwrap(
6✔
1143
                f"""
1144
                with {pluralize(len(req_strings), "requirement")}:
1145
                {", ".join(req_strings)}
1146
                """
1147
            )
1148
    return f"Building {request.output_filename} {desc_suffix}"
12✔
1149

1150

1151
@rule
12✔
1152
async def create_pex(request: PexRequest) -> Pex:
12✔
1153
    result = await build_pex(request, **implicitly())
11✔
1154
    return result.create_pex()
11✔
1155

1156

1157
@rule
12✔
1158
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
12✔
1159
    if request.maybe_pex_request is None:
11✔
1160
        return OptionalPex(None)
11✔
1161
    result = await create_pex(request.maybe_pex_request)
1✔
1162
    return OptionalPex(result)
1✔
1163

1164

1165
@dataclass(frozen=True)
12✔
1166
class Script:
12✔
1167
    path: PurePath
12✔
1168

1169
    @property
12✔
1170
    def argv0(self) -> str:
12✔
1171
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
12✔
1172

1173

1174
@dataclass(frozen=True)
12✔
1175
class VenvScript:
12✔
1176
    script: Script
12✔
1177
    content: FileContent
12✔
1178

1179

1180
@dataclass(frozen=True)
12✔
1181
class VenvScriptWriter:
12✔
1182
    complete_pex_env: CompletePexEnvironment
12✔
1183
    pex: Pex
12✔
1184
    venv_dir: PurePath
12✔
1185

1186
    @classmethod
12✔
1187
    def create(
12✔
1188
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
1189
    ) -> VenvScriptWriter:
1190
        # N.B.: We don't know the working directory that will be used in any given
1191
        # invocation of the venv scripts; so we deal with working_directory once in an
1192
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
1193
        # CWD offset math in every rule for all the relative paths their process depends on.
1194
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
12✔
1195
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
12✔
1196

1197
    def _create_venv_script(
12✔
1198
        self,
1199
        bash: BashBinary,
1200
        *,
1201
        script_path: PurePath,
1202
        venv_executable: PurePath,
1203
    ) -> VenvScript:
1204
        env_vars = (
12✔
1205
            f"{name}={shlex.quote(value)}"
1206
            for name, value in self.complete_pex_env.environment_dict(
1207
                python_configured=True
1208
            ).items()
1209
        )
1210

1211
        target_venv_executable = shlex.quote(str(venv_executable))
12✔
1212
        venv_dir = shlex.quote(str(self.venv_dir))
12✔
1213
        execute_pex_args = " ".join(
12✔
1214
            f"$(adjust_relative_paths {shlex.quote(arg)})"
1215
            for arg in self.complete_pex_env.create_argv(self.pex.name, python=self.pex.python)
1216
        )
1217

1218
        script = dedent(
12✔
1219
            f"""\
1220
            #!{bash.path}
1221
            set -euo pipefail
1222

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

1229
            function adjust_relative_paths() {{
1230
                local value0="$1"
1231
                shift
1232
                if [ "${{value0:0:1}}" == "/" ]; then
1233
                    # Don't relativize absolute paths.
1234
                    echo "${{value0}}" "$@"
1235
                else
1236
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
1237
                    # this script works when run with a PWD set somewhere else than the sandbox
1238
                    # root.
1239
                    #
1240
                    # There are two cases to consider. For the purposes of example, assume PWD is
1241
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
1242
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
1243
                    #
1244
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
1245
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
1246
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
1247
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
1248
                    #    `../..`, we calculate `../../config/tool.yml`.
1249
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
1250
                fi
1251
            }}
1252

1253
            export {" ".join(env_vars)}
1254
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1255

1256
            execute_pex_args="{execute_pex_args}"
1257
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1258
            venv_dir="$(adjust_relative_paths {venv_dir})"
1259

1260
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1261
            # with tools support.
1262
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1263
              exec ${{execute_pex_args}} "$@"
1264
            fi
1265

1266
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1267
            # `--venv` mode PEX file.
1268
            if [ ! -e "${{venv_dir}}" ]; then
1269
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1270
            fi
1271

1272
            exec "${{target_venv_executable}}" "$@"
1273
            """
1274
        )
1275
        return VenvScript(
12✔
1276
            script=Script(script_path),
1277
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1278
        )
1279

1280
    def exe(self, bash: BashBinary) -> VenvScript:
12✔
1281
        """Writes a safe shim for the venv's executable `pex` script."""
1282
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
12✔
1283
        return self._create_venv_script(
12✔
1284
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1285
        )
1286

1287
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
12✔
1288
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1289
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
12✔
1290
        return self._create_venv_script(
12✔
1291
            bash,
1292
            script_path=script_path,
1293
            venv_executable=self.venv_dir / "bin" / name,
1294
        )
1295

1296
    def python(self, bash: BashBinary) -> VenvScript:
12✔
1297
        """Writes a safe shim for the venv's python binary."""
1298
        return self.bin(bash, "python")
12✔
1299

1300

1301
@dataclass(frozen=True)
12✔
1302
class VenvPex:
12✔
1303
    digest: Digest
12✔
1304
    append_only_caches: FrozenDict[str, str] | None
12✔
1305
    pex_filename: str
12✔
1306
    pex: Script
12✔
1307
    python: Script
12✔
1308
    bin: FrozenDict[str, Script]
12✔
1309
    venv_rel_dir: str
12✔
1310

1311

1312
@dataclass(frozen=True)
12✔
1313
class VenvPexRequest:
12✔
1314
    pex_request: PexRequest
12✔
1315
    complete_pex_env: CompletePexEnvironment
12✔
1316
    bin_names: tuple[str, ...] = ()
12✔
1317
    site_packages_copies: bool = False
12✔
1318

1319
    def __init__(
12✔
1320
        self,
1321
        pex_request: PexRequest,
1322
        complete_pex_env: CompletePexEnvironment,
1323
        bin_names: Iterable[str] = (),
1324
        site_packages_copies: bool = False,
1325
    ) -> None:
1326
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1327

1328
        :param pex_request: The details of the desired PEX.
1329
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1330
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1331
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1332
            dependencies when installing them in the venv site-packages directory. By default this
1333
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1334
            but results in a non-standard venv structure that does trip up some libraries.
1335
        """
1336
        object.__setattr__(self, "pex_request", pex_request)
12✔
1337
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
12✔
1338
        object.__setattr__(self, "bin_names", tuple(bin_names))
12✔
1339
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
12✔
1340

1341

1342
@rule
12✔
1343
async def wrap_venv_prex_request(
12✔
1344
    pex_request: PexRequest, pex_environment: PexEnvironment
1345
) -> VenvPexRequest:
1346
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1347
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
12✔
1348

1349

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

1380
    pex_request = request.pex_request
12✔
1381
    seeded_venv_request = dataclasses.replace(
12✔
1382
        pex_request,
1383
        additional_args=pex_request.additional_args
1384
        + (
1385
            "--venv",
1386
            "prepend",
1387
            "--seed",
1388
            "verbose",
1389
            pex_environment.venv_site_packages_copies_option(
1390
                use_copies=request.site_packages_copies
1391
            ),
1392
        ),
1393
    )
1394
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
12✔
1395
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1396
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1397
    # directory.
1398
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
12✔
1399
    abs_pex_root = PurePath(seed_info["pex_root"])
12✔
1400
    abs_pex_path = PurePath(seed_info["pex"])
12✔
1401
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
12✔
1402

1403
    venv_script_writer = VenvScriptWriter.create(
12✔
1404
        complete_pex_env=request.complete_pex_env,
1405
        pex=venv_pex_result.create_pex(),
1406
        venv_rel_dir=venv_rel_dir,
1407
    )
1408
    pex = venv_script_writer.exe(bash)
12✔
1409
    python = venv_script_writer.python(bash)
12✔
1410
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
12✔
1411
    scripts_digest = await create_digest(
12✔
1412
        CreateDigest(
1413
            (
1414
                pex.content,
1415
                python.content,
1416
                *(venv_script.content for venv_script in scripts.values()),
1417
            )
1418
        )
1419
    )
1420
    input_digest = await merge_digests(
12✔
1421
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1422
    )
1423
    append_only_caches = (
12✔
1424
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1425
    )
1426

1427
    return VenvPex(
12✔
1428
        digest=input_digest,
1429
        append_only_caches=append_only_caches,
1430
        pex_filename=venv_pex_result.pex_filename,
1431
        pex=pex.script,
1432
        python=python.script,
1433
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1434
        venv_rel_dir=venv_rel_dir.as_posix(),
1435
    )
1436

1437

1438
@dataclass(frozen=True)
12✔
1439
class PexProcess:
12✔
1440
    pex: Pex
12✔
1441
    argv: tuple[str, ...]
12✔
1442
    description: str = dataclasses.field(compare=False)
12✔
1443
    level: LogLevel
12✔
1444
    input_digest: Digest | None
12✔
1445
    working_directory: str | None
12✔
1446
    extra_env: FrozenDict[str, str]
12✔
1447
    output_files: tuple[str, ...] | None
12✔
1448
    output_directories: tuple[str, ...] | None
12✔
1449
    timeout_seconds: int | None
12✔
1450
    execution_slot_variable: str | None
12✔
1451
    concurrency_available: int
12✔
1452
    cache_scope: ProcessCacheScope
12✔
1453

1454
    def __init__(
12✔
1455
        self,
1456
        pex: Pex,
1457
        *,
1458
        description: str,
1459
        argv: Iterable[str] = (),
1460
        level: LogLevel = LogLevel.INFO,
1461
        input_digest: Digest | None = None,
1462
        working_directory: str | None = None,
1463
        extra_env: Mapping[str, str] | None = None,
1464
        output_files: Iterable[str] | None = None,
1465
        output_directories: Iterable[str] | None = None,
1466
        timeout_seconds: int | None = None,
1467
        execution_slot_variable: str | None = None,
1468
        concurrency_available: int = 0,
1469
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1470
    ) -> None:
1471
        object.__setattr__(self, "pex", pex)
7✔
1472
        object.__setattr__(self, "argv", tuple(argv))
7✔
1473
        object.__setattr__(self, "description", description)
7✔
1474
        object.__setattr__(self, "level", level)
7✔
1475
        object.__setattr__(self, "input_digest", input_digest)
7✔
1476
        object.__setattr__(self, "working_directory", working_directory)
7✔
1477
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
7✔
1478
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
7✔
1479
        object.__setattr__(
7✔
1480
            self, "output_directories", tuple(output_directories) if output_directories else None
1481
        )
1482
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
7✔
1483
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
7✔
1484
        object.__setattr__(self, "concurrency_available", concurrency_available)
7✔
1485
        object.__setattr__(self, "cache_scope", cache_scope)
7✔
1486

1487

1488
@rule
12✔
1489
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
12✔
1490
    pex = request.pex
7✔
1491
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
7✔
1492
    argv = complete_pex_env.create_argv(pex.name, *request.argv, python=pex.python)
7✔
1493
    env = {
7✔
1494
        **complete_pex_env.environment_dict(python_configured=pex.python is not None),
1495
        **request.extra_env,
1496
    }
1497
    input_digest = (
7✔
1498
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1499
        if request.input_digest
1500
        else pex.digest
1501
    )
1502
    append_only_caches = (
7✔
1503
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1504
    )
1505
    return Process(
7✔
1506
        argv,
1507
        description=request.description,
1508
        level=request.level,
1509
        input_digest=input_digest,
1510
        working_directory=request.working_directory,
1511
        env=env,
1512
        output_files=request.output_files,
1513
        output_directories=request.output_directories,
1514
        append_only_caches={
1515
            **complete_pex_env.append_only_caches,
1516
            **append_only_caches,
1517
        },
1518
        timeout_seconds=request.timeout_seconds,
1519
        execution_slot_variable=request.execution_slot_variable,
1520
        concurrency_available=request.concurrency_available,
1521
        cache_scope=request.cache_scope,
1522
    )
1523

1524

1525
@dataclass(unsafe_hash=True)
12✔
1526
class VenvPexProcess:
12✔
1527
    venv_pex: VenvPex
12✔
1528
    argv: tuple[str, ...]
12✔
1529
    description: str = dataclasses.field(compare=False)
12✔
1530
    level: LogLevel
12✔
1531
    input_digest: Digest | None
12✔
1532
    working_directory: str | None
12✔
1533
    extra_env: FrozenDict[str, str]
12✔
1534
    output_files: tuple[str, ...] | None
12✔
1535
    output_directories: tuple[str, ...] | None
12✔
1536
    timeout_seconds: int | None
12✔
1537
    execution_slot_variable: str | None
12✔
1538
    concurrency_available: int
12✔
1539
    cache_scope: ProcessCacheScope
12✔
1540
    append_only_caches: FrozenDict[str, str]
12✔
1541

1542
    def __init__(
12✔
1543
        self,
1544
        venv_pex: VenvPex,
1545
        *,
1546
        description: str,
1547
        argv: Iterable[str] = (),
1548
        level: LogLevel = LogLevel.INFO,
1549
        input_digest: Digest | None = None,
1550
        working_directory: str | None = None,
1551
        extra_env: Mapping[str, str] | None = None,
1552
        output_files: Iterable[str] | None = None,
1553
        output_directories: Iterable[str] | None = None,
1554
        timeout_seconds: int | None = None,
1555
        execution_slot_variable: str | None = None,
1556
        concurrency_available: int = 0,
1557
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1558
        append_only_caches: Mapping[str, str] | None = None,
1559
    ) -> None:
1560
        object.__setattr__(self, "venv_pex", venv_pex)
12✔
1561
        object.__setattr__(self, "argv", tuple(argv))
12✔
1562
        object.__setattr__(self, "description", description)
12✔
1563
        object.__setattr__(self, "level", level)
12✔
1564
        object.__setattr__(self, "input_digest", input_digest)
12✔
1565
        object.__setattr__(self, "working_directory", working_directory)
12✔
1566
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
12✔
1567
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
12✔
1568
        object.__setattr__(
12✔
1569
            self, "output_directories", tuple(output_directories) if output_directories else None
1570
        )
1571
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
12✔
1572
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
12✔
1573
        object.__setattr__(self, "concurrency_available", concurrency_available)
12✔
1574
        object.__setattr__(self, "cache_scope", cache_scope)
12✔
1575
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
12✔
1576

1577

1578
@rule
12✔
1579
async def setup_venv_pex_process(
12✔
1580
    request: VenvPexProcess, pex_environment: PexEnvironment
1581
) -> Process:
1582
    venv_pex = request.venv_pex
12✔
1583
    pex_bin = (
12✔
1584
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1585
        if request.working_directory
1586
        else venv_pex.pex.argv0
1587
    )
1588
    argv = (pex_bin, *request.argv)
12✔
1589
    input_digest = (
12✔
1590
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1591
        if request.input_digest
1592
        else venv_pex.digest
1593
    )
1594
    append_only_caches: FrozenDict[str, str] = FrozenDict(
12✔
1595
        **pex_environment.in_sandbox(
1596
            working_directory=request.working_directory
1597
        ).append_only_caches,
1598
        **request.append_only_caches,
1599
        **(FrozenDict({}) if venv_pex.append_only_caches is None else venv_pex.append_only_caches),
1600
    )
1601
    return Process(
12✔
1602
        argv=argv,
1603
        description=request.description,
1604
        level=request.level,
1605
        input_digest=input_digest,
1606
        working_directory=request.working_directory,
1607
        env=request.extra_env,
1608
        output_files=request.output_files,
1609
        output_directories=request.output_directories,
1610
        append_only_caches=append_only_caches,
1611
        timeout_seconds=request.timeout_seconds,
1612
        execution_slot_variable=request.execution_slot_variable,
1613
        concurrency_available=request.concurrency_available,
1614
        cache_scope=request.cache_scope,
1615
    )
1616

1617

1618
@dataclass(frozen=True)
12✔
1619
class PexDistributionInfo:
12✔
1620
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1621
    repository info -v`."""
1622

1623
    project_name: str
12✔
1624
    version: packaging.version.Version
12✔
1625
    requires_python: packaging.specifiers.SpecifierSet | None
12✔
1626
    # Note: These are parsed from metadata written by the pex tool, and are always
1627
    #   a valid packaging.requirements.Requirement.
1628
    requires_dists: tuple[Requirement, ...]
12✔
1629

1630

1631
DefaultT = TypeVar("DefaultT")
12✔
1632

1633

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

1638
    def find(
12✔
1639
        self, name: str, default: DefaultT | None = None
1640
    ) -> PexDistributionInfo | DefaultT | None:
1641
        """Returns the PexDistributionInfo with the given name, first one wins."""
1642
        try:
6✔
1643
            return next(info for info in self if info.project_name == name)
6✔
1644
        except StopIteration:
×
1645
            return default
×
1646

1647

1648
def parse_repository_info(repository_info: str) -> PexResolveInfo:
12✔
1649
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
7✔
1650
        for line in repository_info.splitlines():
7✔
1651
            info = json.loads(line)
7✔
1652
            requires_python = info["requires_python"]
7✔
1653
            yield PexDistributionInfo(
7✔
1654
                project_name=info["project_name"],
1655
                version=packaging.version.Version(info["version"]),
1656
                requires_python=(
1657
                    packaging.specifiers.SpecifierSet(requires_python)
1658
                    if requires_python is not None
1659
                    else None
1660
                ),
1661
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1662
            )
1663

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

1666

1667
@rule
12✔
1668
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
12✔
1669
    process_result = await fallible_to_exec_result_or_raise(
6✔
1670
        **implicitly(
1671
            VenvPexProcess(
1672
                venv_pex,
1673
                argv=["repository", "info", "-v"],
1674
                extra_env={"PEX_TOOLS": "1"},
1675
                input_digest=venv_pex.digest,
1676
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1677
                level=LogLevel.DEBUG,
1678
            )
1679
        )
1680
    )
1681
    return parse_repository_info(process_result.stdout.decode())
6✔
1682

1683

1684
@rule
12✔
1685
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
12✔
1686
    process_result = await fallible_to_exec_result_or_raise(
4✔
1687
        **implicitly(
1688
            PexProcess(
1689
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1690
                argv=[pex.name, "repository", "info", "-v"],
1691
                input_digest=pex.digest,
1692
                extra_env={"PEX_MODULE": "pex.tools"},
1693
                description=f"Determine distributions found in {pex.name}",
1694
                level=LogLevel.DEBUG,
1695
            )
1696
        )
1697
    )
1698
    return parse_repository_info(process_result.stdout.decode())
4✔
1699

1700

1701
def rules():
12✔
1702
    return [
12✔
1703
        *collect_rules(),
1704
        *pex_cli.rules(),
1705
        *pex_requirements.rules(),
1706
        *uv_subsystem.rules(),  # Also in register.py; engine deduplicates.
1707
        *stripped_source_rules(),
1708
    ]
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