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

pantsbuild / pants / 24149123771

08 Apr 2026 05:25PM UTC coverage: 92.925% (+0.02%) from 92.91%
24149123771

Pull #23227

github

web-flow
Merge c74227668 into 9036734c9
Pull Request #23227: Fix uv PEX builder to use pex3 lock export

85 of 86 new or added lines in 2 files covered. (98.84%)

2 existing lines in 2 files now uncovered.

91671 of 98650 relevant lines covered (92.93%)

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 a subset of the lockfile via `pex3 lock export-subset` so we
564
    # can pass only the needed locked requirements with --no-deps (reproducible).
565
    # This uses Pex's stable CLI rather than parsing the internal lockfile JSON
566
    # directly.  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-subset"),
583
                            extra_args=(
584
                                *uv_request.req_strings,
585
                                "--lock",
586
                                loaded_lockfile.lockfile_path,
587
                                "--format",
588
                                "pep-751",
589
                                "-o",
590
                                reqs_file,
591
                            ),
592
                            additional_input_digest=loaded_lockfile.lockfile_digest,
593
                            description=f"Export lockfile subset for {uv_request.description}",
594
                            output_files=(reqs_file,),
595
                        )
596
                    )
597
                )
598
                exported_reqs_digest = export_result.output_digest
1✔
599
            except ProcessExecutionFailure as e:
1✔
UNCOV
600
                logger.warning(
×
601
                    "pex_builder=uv: failed to export lockfile subset for %s: %s. "
602
                    "Falling back to transitive uv resolution.",
603
                    uv_request.description,
604
                    e,
605
                )
606

607
    use_exported_lockfile = exported_reqs_digest is not None
1✔
608

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

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

641
    python_path = uv_request.python_path
1✔
642

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

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

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

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

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

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

701

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

708

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

714

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

761

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

782

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

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

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

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

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

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

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

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

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

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

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

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

909

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

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

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

941
    source_dir_name = "source_files"
12✔
942

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1121

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

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

1164

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

1170

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

1178

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

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

1187

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

1193

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1314

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

1325

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

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

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

1355

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

1363

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

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

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

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

1451

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

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

1501

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

1538

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

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

1591

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

1631

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

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

1644

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

1647

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

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

1661

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

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

1680

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

1697

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

1714

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