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

pantsbuild / pants / 24069121099

07 Apr 2026 07:10AM UTC coverage: 92.923% (+0.02%) from 92.908%
24069121099

Pull #23227

github

web-flow
Merge 15592134b into 542ca048d
Pull Request #23227: Fix uv PEX builder to use pex3 lock export

83 of 84 new or added lines in 2 files covered. (98.81%)

1 existing line in 1 file now uncovered.

91623 of 98601 relevant lines covered (92.92%)

4.04 hits per line

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

96.79
/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
    merge_digests,
89
    remove_prefix,
90
)
91
from pants.engine.process import (
12✔
92
    Process,
93
    ProcessCacheScope,
94
    ProcessExecutionFailure,
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✔
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✔
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✔
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 export the lockfile via `pex3 lock export` so we can pass pinned
564
    # versions with --no-deps (reproducible).  This uses Pex's stable CLI rather
565
    # than parsing the internal lockfile JSON directly.
566
    # Fall back to letting uv resolve transitively if no lockfile.
567
    exported_reqs_digest: Digest | None = None
1✔
568
    reqs_file = "pylock.toml"
1✔
569

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

605
    use_exported_lockfile = exported_reqs_digest is not None
1✔
606

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

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

639
    python_path = uv_request.python_path
1✔
640

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

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

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

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

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

695
    return _UvVenvResult(
1✔
696
        venv_digest=uv_install_result.output_digest,
697
    )
698

699

700
@dataclass
12✔
701
class _BuildPexRequirementsSetup:
12✔
702
    digests: list[Digest]
12✔
703
    argv: list[str]
12✔
704
    concurrency_available: int
12✔
705

706

707
@dataclass(frozen=True)
12✔
708
class PexRequirementsInfo:
12✔
709
    req_strings: tuple[str, ...]
12✔
710
    find_links: tuple[str, ...]
12✔
711

712

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

759

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

780

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

799
    pex_lock_resolver_args = list(resolve_config.pex_args())
12✔
800
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
12✔
801

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

831
        return _BuildPexRequirementsSetup(
12✔
832
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
833
        )
834

835
    assert isinstance(request.requirements, PexRequirements)
11✔
836
    reqs_info = await get_req_strings(request.requirements)
11✔
837

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

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

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

857
        # NB: This is also validated in the constructor.
858
        assert loaded_lockfile.is_pex_native
2✔
859
        if not reqs_info.req_strings:
2✔
860
            return _BuildPexRequirementsSetup([], [], concurrency_available)
1✔
861

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

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

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

907

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

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

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

939
    source_dir_name = "source_files"
12✔
940

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

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

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

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

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

1020
    argv = [
12✔
1021
        "--output-file",
1022
        output_file,
1023
        *request.additional_args,
1024
    ]
1025

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

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

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

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

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

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

1064
    # Include any additional arguments and input digests required by the requirements.
1065
    argv.extend(requirements_setup.argv)
12✔
1066

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

1080
    argv.extend(["--layout", request.layout.value])
12✔
1081

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

1097
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
1098

1099
    if strip_output_chroot:
12✔
1100
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
12✔
1101
    else:
1102
        output_digest = result.output_digest
5✔
1103

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

1112
    return BuildPexResult(
12✔
1113
        result=result,
1114
        pex_filename=request.output_filename,
1115
        digest=digest,
1116
        python=pex_python_setup.python,
1117
    )
1118

1119

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

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

1162

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

1168

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

1176

1177
@dataclass(frozen=True)
12✔
1178
class Script:
12✔
1179
    path: PurePath
12✔
1180

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

1185

1186
@dataclass(frozen=True)
12✔
1187
class VenvScript:
12✔
1188
    script: Script
12✔
1189
    content: FileContent
12✔
1190

1191

1192
@dataclass(frozen=True)
12✔
1193
class VenvScriptWriter:
12✔
1194
    complete_pex_env: CompletePexEnvironment
12✔
1195
    pex: Pex
12✔
1196
    venv_dir: PurePath
12✔
1197

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

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

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

1230
        script = dedent(
12✔
1231
            f"""\
1232
            #!{bash.path}
1233
            set -euo pipefail
1234

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

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

1265
            export {" ".join(env_vars)}
1266
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1267

1268
            execute_pex_args="{execute_pex_args}"
1269
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1270
            venv_dir="$(adjust_relative_paths {venv_dir})"
1271

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

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

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

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

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

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

1312

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

1323

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

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

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

1353

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

1361

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

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

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

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

1449

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

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

1499

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

1536

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

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

1589

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

1629

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

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

1642

1643
DefaultT = TypeVar("DefaultT")
12✔
1644

1645

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

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

1659

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

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

1678

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

1695

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

1712

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