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

pantsbuild / pants / 24145945949

08 Apr 2026 04:14PM UTC coverage: 82.077% (-10.8%) from 92.91%
24145945949

Pull #23233

github

web-flow
Merge 089d98e3c into 9036734c9
Pull Request #23233: Introduce a LockfileFormat enum.

8 of 11 new or added lines in 4 files covered. (72.73%)

7635 existing lines in 306 files now uncovered.

63732 of 77649 relevant lines covered (82.08%)

2.96 hits per line

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

85.64
/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
7✔
5

6
import dataclasses
7✔
7
import json
7✔
8
import logging
7✔
9
import os
7✔
10
import shlex
7✔
11
import zlib
7✔
12
from collections.abc import Iterable, Iterator, Mapping, Sequence
7✔
13
from dataclasses import dataclass
7✔
14
from pathlib import PurePath
7✔
15
from textwrap import dedent  # noqa: PNT20
7✔
16
from typing import TypeVar
7✔
17

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

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

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

109

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

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

118
    interpreter_constraints: InterpreterConstraints
7✔
119

120

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

127

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

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

137

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

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

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

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

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

158

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

183

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

190

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

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

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

291
        self.__post_init__()
7✔
292

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

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

345

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

350

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

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

359

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

364

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

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

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

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

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

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

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

438

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

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

449

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

455

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

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

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

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

500

501
_UV_VENV_DIR = "__uv_venv"
7✔
502

503

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

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

513

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

518
    venv_digest: Digest | None
7✔
519

520

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

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

550

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

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

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

UNCOV
596
    uv_reqs = all_resolved_reqs or uv_request.req_strings
×
597

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

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

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

UNCOV
628
    python_path = uv_request.python_path
×
629

UNCOV
630
    uv_input = await merge_digests(MergeDigests([downloaded_uv.digest, reqs_digest, tmpdir_digest]))
×
631

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

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

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

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

UNCOV
684
    return _UvVenvResult(
×
685
        venv_digest=uv_install_result.output_digest,
686
    )
687

688

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

695

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

701

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

748

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

769

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

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

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

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

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

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

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

840
    elif isinstance(request.requirements.from_superset, Resolve):
7✔
UNCOV
841
        lockfile = await get_lockfile_for_resolve(
×
842
            request.requirements.from_superset, **implicitly()
843
        )
UNCOV
844
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
845

846
        # NB: This is also validated in the constructor.
NEW
847
        assert loaded_lockfile.lockfile_format == LockfileFormat.Pex
×
UNCOV
848
        if not reqs_info.req_strings:
×
UNCOV
849
            return _BuildPexRequirementsSetup([], [], concurrency_available)
×
850

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

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

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

896

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

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

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

928
    source_dir_name = "source_files"
7✔
929

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1069
    argv.extend(["--layout", request.layout.value])
7✔
1070

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

1086
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
7✔
1087

1088
    if strip_output_chroot:
7✔
1089
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
7✔
1090
    else:
1091
        output_digest = result.output_digest
3✔
1092

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

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

1108

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

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

1151

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

1157

1158
@rule
7✔
1159
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
7✔
1160
    if request.maybe_pex_request is None:
7✔
1161
        return OptionalPex(None)
7✔
UNCOV
1162
    result = await create_pex(request.maybe_pex_request)
×
UNCOV
1163
    return OptionalPex(result)
×
1164

1165

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

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

1174

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

1180

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1301

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

1312

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

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

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

1342

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

1350

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

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

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

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

1438

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

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

1488

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

1525

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

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

1578

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

1618

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

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

1631

1632
DefaultT = TypeVar("DefaultT")
7✔
1633

1634

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

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

1648

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

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

1667

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

1684

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

1701

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