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

pantsbuild / pants / 25600929628

09 May 2026 12:18PM UTC coverage: 91.154% (-1.6%) from 92.787%
25600929628

Pull #23341

github

web-flow
Merge 0787d1df4 into 60371862f
Pull Request #23341: Restore missing-entry guard in CoursierResolvedLockfile.dependencies() (regression from #22906)

87247 of 95714 relevant lines covered (91.15%)

3.87 hits per line

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

96.67
/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
11✔
5

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

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

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

114
logger = logging.getLogger(__name__)
11✔
115

116

117
@union(in_scope_types=[EnvironmentName])
11✔
118
@dataclass(frozen=True)
11✔
119
class PythonProvider:
11✔
120
    """Union which should have 0 or 1 implementations registered which provide Python.
121

122
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
123
    """
124

125
    interpreter_constraints: InterpreterConstraints
11✔
126

127

128
@rule(polymorphic=True)
11✔
129
async def get_python_executable(
11✔
130
    provider: PythonProvider, env_name: EnvironmentName
131
) -> PythonExecutable:
132
    raise NotImplementedError()
×
133

134

135
class PexPlatforms(DeduplicatedCollection[str]):
11✔
136
    sort_input = True
11✔
137

138
    def generate_pex_arg_list(self) -> list[str]:
11✔
139
        args = []
4✔
140
        for platform in self:
4✔
141
            args.extend(["--platform", platform])
2✔
142
        return args
4✔
143

144

145
class CompletePlatforms(DeduplicatedCollection[str]):
11✔
146
    sort_input = True
11✔
147

148
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
11✔
149
        super().__init__(iterable)
11✔
150
        self._digest = digest
11✔
151

152
    @classmethod
11✔
153
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
11✔
154
        return cls(snapshot.files, digest=snapshot.digest)
6✔
155

156
    @property
11✔
157
    def digest(self) -> Digest:
11✔
158
        return self._digest
11✔
159

160
    def generate_pex_arg_list(self) -> Iterator[str]:
11✔
161
        for path in self:
4✔
162
            yield "--complete-platform"
4✔
163
            yield path
4✔
164

165

166
@rule
11✔
167
async def digest_complete_platform_addresses(
11✔
168
    addresses: UnparsedAddressInputs,
169
) -> CompletePlatforms:
170
    original_file_targets = await resolve_targets(**implicitly(addresses))
6✔
171
    original_files_sources = await concurrently(
6✔
172
        hydrate_sources(
173
            HydrateSourcesRequest(
174
                tgt.get(SourcesField),
175
                for_sources_types=(
176
                    FileSourceField,
177
                    ResourceSourceField,
178
                ),
179
                enable_codegen=True,
180
            ),
181
            **implicitly(),
182
        )
183
        for tgt in original_file_targets
184
    )
185
    snapshot = await digest_to_snapshot(
6✔
186
        **implicitly(MergeDigests(sources.snapshot.digest for sources in original_files_sources))
187
    )
188
    return CompletePlatforms.from_snapshot(snapshot)
6✔
189

190

191
@rule
11✔
192
async def digest_complete_platforms(
11✔
193
    complete_platforms: PexCompletePlatformsField,
194
) -> CompletePlatforms:
195
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
6✔
196

197

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

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

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

298
        self.__post_init__()
11✔
299

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

349
    def debug_hint(self) -> str:
11✔
350
        return self.output_filename
×
351

352

353
@dataclass(frozen=True)
11✔
354
class OptionalPexRequest:
11✔
355
    maybe_pex_request: PexRequest | None
11✔
356

357

358
@dataclass(frozen=True)
11✔
359
class Pex:
11✔
360
    """Wrapper for a digest containing a pex file created with some filename."""
361

362
    digest: Digest
11✔
363
    name: str
11✔
364
    python: PythonExecutable | None
11✔
365

366

367
@dataclass(frozen=True)
11✔
368
class OptionalPex:
11✔
369
    maybe_pex: Pex | None
11✔
370

371

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

387
                {bullet_list(repr(provider.__class__) for provider in python_providers)}
388
                """
389
            )
390
        )
391
    if python_providers:
11✔
392
        python_provider = next(iter(python_providers))
5✔
393
        python = await get_python_executable(
5✔
394
            **implicitly({python_provider(interpreter_constraints): PythonProvider})
395
        )
396
        return python
5✔
397

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

423
                    python = os.path.realpath(sys.executable)
424
                    print(python)
425

426
                    hasher = hashlib.sha256()
427
                    with open(python, "rb") as fp:
428
                      for chunk in iter(lambda: fp.read(8192), b""):
429
                          hasher.update(chunk)
430
                    print(hasher.hexdigest())
431
                    """
432
                    ),
433
                ),
434
                level=LogLevel.DEBUG,
435
                cache_scope=env_target.executable_search_path_cache_scope(),
436
            )
437
        )
438
    )
439
    path, fingerprint = result.stdout.decode().strip().splitlines()
11✔
440

441
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
11✔
442

443
    return PythonExecutable(path=path, fingerprint=fingerprint)
11✔
444

445

446
@dataclass(frozen=True)
11✔
447
class BuildPexResult:
11✔
448
    result: ProcessResult
11✔
449
    pex_filename: str
11✔
450
    digest: Digest
11✔
451
    python: PythonExecutable | None
11✔
452

453
    def create_pex(self) -> Pex:
11✔
454
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
11✔
455

456

457
@dataclass
11✔
458
class _BuildPexPythonSetup:
11✔
459
    python: PythonExecutable | None
11✔
460
    argv: list[str]
11✔
461

462

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

480
    if request.python:
11✔
481
        python = request.python
3✔
482
    else:
483
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
11✔
484

485
    if request.python or request.internal_only:
11✔
486
        # Sometimes we want to build and run with a specific interpreter (either because request
487
        # demanded it, or because it's an internal-only PEX). We will have already validated that
488
        # there were no platforms.
489
        return _BuildPexPythonSetup(python, ["--python", python.path])
11✔
490

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

507

508
@dataclass
11✔
509
class _BuildPexRequirementsSetup:
11✔
510
    digests: list[Digest]
11✔
511
    argv: list[str]
11✔
512
    concurrency_available: int
11✔
513
    # Set when the requirements come from a uv lockfile; signals build_pex to
514
    # use create_venv_repository_from_uv_lockfile instead of pex's resolver.
515
    uv_lockfile: LoadedLockfile | None = None
11✔
516

517

518
@dataclass(frozen=True)
11✔
519
class PexRequirementsInfo:
11✔
520
    req_strings: tuple[str, ...]
11✔
521
    find_links: tuple[str, ...]
11✔
522

523

524
@rule
11✔
525
async def get_req_strings(pex_reqs: PexRequirements) -> PexRequirementsInfo:
11✔
526
    addrs: list[Address] = []
11✔
527
    specs: list[str] = []
11✔
528
    req_strings: list[str] = []
11✔
529
    find_links: set[str] = set()
11✔
530
    for req_str_or_addr in pex_reqs.req_strings_or_addrs:
11✔
531
        if isinstance(req_str_or_addr, Address):
10✔
532
            addrs.append(req_str_or_addr)
10✔
533
        else:
534
            assert isinstance(req_str_or_addr, str)
5✔
535
            # Require a `//` prefix, to distinguish address specs from
536
            # local or VCS requirements.
537
            if req_str_or_addr.startswith(os.path.sep * 2):
5✔
538
                specs.append(req_str_or_addr)
×
539
            else:
540
                req_strings.append(req_str_or_addr)
5✔
541
    if specs:
11✔
542
        addrs_from_specs = await resolve_unparsed_address_inputs(
×
543
            UnparsedAddressInputs(
544
                specs,
545
                owning_address=None,
546
                description_of_origin=pex_reqs.description_of_origin,
547
            ),
548
            **implicitly(),
549
        )
550
        addrs.extend(addrs_from_specs)
×
551
    if addrs:
11✔
552
        transitive_targets = await transitive_targets_get(
10✔
553
            TransitiveTargetsRequest(addrs), **implicitly()
554
        )
555
        req_strings.extend(
10✔
556
            PexRequirements.req_strings_from_requirement_fields(
557
                tgt[PythonRequirementsField]
558
                for tgt in transitive_targets.closure
559
                if tgt.has_field(PythonRequirementsField)
560
            )
561
        )
562
        find_links.update(
10✔
563
            find_links
564
            for tgt in transitive_targets.closure
565
            if tgt.has_field(PythonRequirementFindLinksField)
566
            for find_links in tgt[PythonRequirementFindLinksField].value or ()
567
        )
568
    return PexRequirementsInfo(tuple(sorted(req_strings)), tuple(sorted(find_links)))
11✔
569

570

571
async def _get_entire_lockfile_and_requirements(
11✔
572
    requirements: EntireLockfile | PexRequirements,
573
) -> tuple[LoadedLockfile | None, tuple[str, ...]]:
574
    lockfile: Lockfile | None = None
11✔
575
    complete_req_strings: tuple[str, ...] = tuple()
11✔
576
    # TODO: This is clunky, but can be simplified once we get rid of old-style tool
577
    #  lockfiles, because we can unify EntireLockfile and Resolve.
578
    if isinstance(requirements, EntireLockfile):
11✔
579
        complete_req_strings = requirements.complete_req_strings or tuple()
11✔
580
        lockfile = requirements.lockfile
11✔
581
    elif (
11✔
582
        isinstance(requirements.from_superset, Resolve)
583
        and requirements.from_superset.use_entire_lockfile
584
    ):
585
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
4✔
586
    if not lockfile:
11✔
587
        return None, complete_req_strings
10✔
588
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
11✔
589
    return loaded_lockfile, complete_req_strings
11✔
590

591

592
@rule
11✔
593
async def _setup_pex_requirements(
11✔
594
    request: PexRequest, python_setup: PythonSetup
595
) -> _BuildPexRequirementsSetup:
596
    resolve_name: str | None
597
    if isinstance(request.requirements, EntireLockfile):
11✔
598
        resolve_name = request.requirements.lockfile.resolve_name
11✔
599
    elif isinstance(request.requirements.from_superset, Resolve):
11✔
600
        resolve_name = request.requirements.from_superset.name
5✔
601
    else:
602
        # This implies that, currently, per-resolve options are only configurable for resolves.
603
        # However, if no resolve is specified, we will still load options that apply to every
604
        # resolve, like `[python-repos].indexes`.
605
        resolve_name = None
10✔
606
    resolve_config = await determine_resolve_config(
11✔
607
        ResolveConfigRequest(resolve_name), **implicitly()
608
    )
609

610
    pex_lock_resolver_args = list(resolve_config.pex_args())
11✔
611
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
11✔
612

613
    loaded_lockfile, complete_req_strings = await _get_entire_lockfile_and_requirements(
11✔
614
        request.requirements
615
    )
616
    if loaded_lockfile:
11✔
617
        if loaded_lockfile.lockfile_format == LockfileFormat.UV:
11✔
618
            # The caller will need to set the argv. This is slightly awkward, and due to
619
            # uv support being tacked on later. Fixing this will require a bit more of a
620
            # refactor than we want to do right now.
621
            return _BuildPexRequirementsSetup(
1✔
622
                [], [], loaded_lockfile.requirement_estimate, uv_lockfile=loaded_lockfile
623
            )
624

625
        argv = (
11✔
626
            ["--lock", loaded_lockfile.lockfile_path, *pex_lock_resolver_args]
627
            if loaded_lockfile.lockfile_format == LockfileFormat.PEX
628
            # We use pip to resolve a requirements.txt pseudo-lockfile, possibly with hashes.
629
            else [
630
                "--requirement",
631
                loaded_lockfile.lockfile_path,
632
                "--no-transitive",
633
                *pip_resolver_args,
634
            ]
635
        )
636
        if loaded_lockfile.metadata and complete_req_strings:
11✔
637
            validate_metadata(
1✔
638
                loaded_lockfile.metadata,
639
                request.interpreter_constraints,
640
                loaded_lockfile.original_lockfile,
641
                complete_req_strings,
642
                # We're using the entire lockfile, so there is no Pex subsetting operation we
643
                # can delegate requirement validation to.  So we do our naive string-matching
644
                # validation.
645
                validate_consumed_req_strings=True,
646
                python_setup=python_setup,
647
                resolve_config=resolve_config,
648
            )
649

650
        return _BuildPexRequirementsSetup(
11✔
651
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
652
        )
653

654
    assert isinstance(request.requirements, PexRequirements)
10✔
655
    reqs_info = await get_req_strings(request.requirements)
10✔
656

657
    # TODO: This is not the best heuristic for available concurrency, since the
658
    # requirements almost certainly have transitive deps which also need building, but it
659
    # is better than using something hardcoded.
660
    concurrency_available = len(reqs_info.req_strings)
10✔
661

662
    if isinstance(request.requirements.from_superset, Pex):
10✔
663
        repository_pex = request.requirements.from_superset
1✔
664
        return _BuildPexRequirementsSetup(
1✔
665
            [repository_pex.digest],
666
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
667
            concurrency_available,
668
        )
669

670
    elif isinstance(request.requirements.from_superset, Resolve):
10✔
671
        lockfile = await get_lockfile_for_resolve(
1✔
672
            request.requirements.from_superset, **implicitly()
673
        )
674
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
1✔
675

676
        if not reqs_info.req_strings:
1✔
677
            return _BuildPexRequirementsSetup([], [], concurrency_available)
×
678

679
        if loaded_lockfile.lockfile_format == LockfileFormat.UV:
1✔
680
            return _BuildPexRequirementsSetup(
×
681
                [], [], concurrency_available, uv_lockfile=loaded_lockfile
682
            )
683

684
        if loaded_lockfile.metadata:
1✔
685
            validate_metadata(
×
686
                loaded_lockfile.metadata,
687
                request.interpreter_constraints,
688
                loaded_lockfile.original_lockfile,
689
                consumed_req_strings=reqs_info.req_strings,
690
                # Don't validate user requirements when subsetting a resolve, as Pex's
691
                # validation during the subsetting is far more precise than our naive string
692
                # comparison. For example, if a lockfile was generated with `foo==1.2.3`
693
                # and we want to resolve `foo>=1.0.0` or just `foo` out of it, Pex will do
694
                # so successfully, while our naive validation would fail.
695
                validate_consumed_req_strings=False,
696
                python_setup=python_setup,
697
                resolve_config=resolve_config,
698
            )
699

700
        return _BuildPexRequirementsSetup(
1✔
701
            [loaded_lockfile.lockfile_digest],
702
            [
703
                *reqs_info.req_strings,
704
                "--lock",
705
                loaded_lockfile.lockfile_path,
706
                *pex_lock_resolver_args,
707
            ],
708
            concurrency_available,
709
        )
710

711
    # We use pip to perform a normal resolve.
712
    digests = []
10✔
713
    argv = [
10✔
714
        *reqs_info.req_strings,
715
        *pip_resolver_args,
716
        *(f"--find-links={find_links}" for find_links in reqs_info.find_links),
717
    ]
718
    if request.requirements.constraints_strings:
10✔
719
        constraints_file = "__constraints.txt"
2✔
720
        constraints_content = "\n".join(request.requirements.constraints_strings)
2✔
721
        digests.append(
2✔
722
            await create_digest(
723
                CreateDigest([FileContent(constraints_file, constraints_content.encode())])
724
            )
725
        )
726
        argv.extend(["--constraints", constraints_file])
2✔
727
    return _BuildPexRequirementsSetup(digests, argv, concurrency_available=concurrency_available)
10✔
728

729

730
@rule(level=LogLevel.DEBUG)
11✔
731
async def build_pex(
11✔
732
    request: PexRequest,
733
    python_setup: PythonSetup,
734
    pex_subsystem: PexSubsystem,
735
    pex_env: PexEnvironment,
736
) -> BuildPexResult:
737
    """Returns a PEX with the given settings."""
738

739
    if not request.python and not request.interpreter_constraints:
11✔
740
        # Blank ICs in the request means that the caller wants us to use the ICs configured
741
        # for the resolve (falling back to the global ICs).
742
        resolve_name = ""
11✔
743
        if isinstance(request.requirements, PexRequirements) and isinstance(
11✔
744
            request.requirements.from_superset, Resolve
745
        ):
746
            resolve_name = request.requirements.from_superset.name
1✔
747
        elif isinstance(request.requirements, EntireLockfile):
11✔
748
            resolve_name = request.requirements.lockfile.resolve_name
11✔
749

750
        if resolve_name:
11✔
751
            request = dataclasses.replace(
11✔
752
                request,
753
                interpreter_constraints=InterpreterConstraints(
754
                    python_setup.resolves_to_interpreter_constraints.get(
755
                        resolve_name,
756
                        python_setup.interpreter_constraints,
757
                    )
758
                ),
759
            )
760

761
    source_dir_name = "source_files"
11✔
762

763
    pex_python_setup_req = _determine_pex_python_and_platforms(request)
11✔
764
    requirements_setup_req = _setup_pex_requirements(**implicitly({request: PexRequest}))
11✔
765
    sources_digest_as_subdir_req = add_prefix(
11✔
766
        AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
767
    )
768
    if isinstance(request.requirements, PexRequirements):
11✔
769
        (
11✔
770
            pex_python_setup,
771
            requirements_setup,
772
            sources_digest_as_subdir,
773
            req_info,
774
        ) = await concurrently(
775
            pex_python_setup_req,
776
            requirements_setup_req,
777
            sources_digest_as_subdir_req,
778
            get_req_strings(request.requirements),
779
        )
780
        req_strings = req_info.req_strings
11✔
781
    else:
782
        pex_python_setup, requirements_setup, sources_digest_as_subdir = await concurrently(
11✔
783
            pex_python_setup_req,
784
            requirements_setup_req,
785
            sources_digest_as_subdir_req,
786
        )
787
        req_strings = ()
11✔
788

789
    venv_repo: VenvRepository | None = None
11✔
790
    if requirements_setup.uv_lockfile is not None:
11✔
791
        if request.platforms or request.complete_platforms:
1✔
792
            # TODO: Support this via multiple --venv-repository venvs.
793
            raise ValueError(
×
794
                softwrap(
795
                    f"""
796
                    Cannot build a cross-platform PEX from a uv lockfile for
797
                    {request.output_filename}.
798
                    """
799
                )
800
            )
801

802
        if pex_python_setup.python is None:
1✔
803
            # Should never happen.
804
            raise ValueError(
×
805
                softwrap(
806
                    f"""
807
                    Cannot build a pex from a uv lockfile  for {request.output_filename} with no
808
                    local python specified. Please report this error to the Pants team.
809
                    """
810
                )
811
            )
812

813
        venv_repo = await create_venv_repository_from_uv_lockfile(
1✔
814
            VenvFromUvLockfileRequest(
815
                lockfile=requirements_setup.uv_lockfile,
816
                python=pex_python_setup.python,
817
            ),
818
            **implicitly(),
819
        )
820

821
        # This is where we add the argv for the uv case (as explained above).
822
        requirements_setup = dataclasses.replace(
1✔
823
            requirements_setup,
824
            argv=[
825
                # In the EntireLockfile case req_strings will be empty, and pex will
826
                # default to all the distributions in the venv-repository.
827
                *req_strings,
828
                "--no-transitive",
829
                f"--venv-repository={venv_repo.relpath()}",
830
                # If uv decided there should be prereleases in the venv, we
831
                # shouldn't refuse to resolve them.
832
                "--pre",
833
            ],
834
        )
835

836
    output_chroot = os.path.dirname(request.output_filename)
11✔
837
    if output_chroot:
11✔
838
        output_file = request.output_filename
5✔
839
        strip_output_chroot = False
5✔
840
    else:
841
        # In principle a cache should always be just a cache, but existing
842
        # tests in this repo make the assumption that they can look into a
843
        # still intact cache and see the same thing as was there before, which
844
        # requires this to be deterministic and not random.  adler32, because
845
        # it is in the stlib, fast, and doesn't need to be cryptographic.
846
        output_chroot = f"pex-dist-{zlib.adler32(request.output_filename.encode()):08x}"
11✔
847
        strip_output_chroot = True
11✔
848
        output_file = os.path.join(output_chroot, request.output_filename)
11✔
849

850
    argv = [
11✔
851
        "--output-file",
852
        output_file,
853
        *request.additional_args,
854
    ]
855

856
    if venv_repo is None:
11✔
857
        argv.extend(pex_python_setup.argv)
11✔
858
        interpreter = None
11✔
859
    else:
860
        # When using --venv-repository the interpreter is fixed to the venv's Python;
861
        # PEX does not accept --python / --interpreter-constraint in this case.
862
        # TODO: This type name is misleading here: this isn't a PBS (or at least not one we
863
        #  downloaded as such), but PythonBuildStandaloneBinary is just a wrapper for
864
        #  a path to an interpreter, so we use it as such.  But we should probably rename the type.
865
        interpreter = PythonBuildStandaloneBinary(
1✔
866
            os.path.join(venv_repo.relpath(), "bin", "python")
867
        )
868

869
    if request.main is not None:
11✔
870
        argv.extend(request.main.iter_pex_args())
11✔
871
        if isinstance(request.main, Executable):
11✔
872
            # Unlike other MainSpecification types (that can pass spec as-is to pex),
873
            # Executable must be an actual path relative to the sandbox.
874
            # request.main.spec is a python source file including its spec_path.
875
            # To make it relative to the sandbox, we strip the source root
876
            # and add the source_dir_name (sources get prefixed with that below).
877
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
1✔
878
            argv.append(os.path.join(source_dir_name, stripped.value))
1✔
879

880
    argv.extend(
11✔
881
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
882
    )
883
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
11✔
884

885
    # TODO(John Sirois): Right now any request requirements will shadow corresponding pex path
886
    #  requirements, which could lead to problems. Support shading python binaries.
887
    #  See: https://github.com/pantsbuild/pants/issues/9206
888
    if request.pex_path:
11✔
889
        argv.extend(["--pex-path", ":".join(pex.name for pex in request.pex_path)])
6✔
890

891
    if request.internal_only:
11✔
892
        # An internal-only runs on a single machine, and pre-installing wheels is wasted work in
893
        # that case (see https://github.com/pex-tool/pex/issues/2292#issuecomment-1854582647 for
894
        # analysis).
895
        argv.append("--no-pre-install-wheels")
11✔
896

897
    argv.append(f"--sources-directory={source_dir_name}")
11✔
898

899
    # Include any additional arguments and input digests required by the requirements.
900
    argv.extend(requirements_setup.argv)
11✔
901

902
    merged_digest = await merge_digests(
11✔
903
        MergeDigests(
904
            (
905
                request.complete_platforms.digest,
906
                sources_digest_as_subdir,
907
                request.additional_inputs,
908
                *requirements_setup.digests,
909
                *(pex.digest for pex in request.pex_path),
910
            )
911
        )
912
    )
913

914
    argv.extend(["--layout", request.layout.value])
11✔
915

916
    result = await fallible_to_exec_result_or_raise(
11✔
917
        **implicitly(
918
            PexCliProcess(
919
                interpreter=interpreter,
920
                subcommand=(),
921
                extra_args=argv,
922
                additional_input_digest=merged_digest,
923
                description=_build_pex_description(request, req_strings, python_setup.resolves),
924
                append_only_caches=venv_repo.append_only_caches() if venv_repo else None,
925
                output_files=None,
926
                output_directories=[output_chroot],
927
                concurrency_available=requirements_setup.concurrency_available,
928
                cache_scope=request.cache_scope,
929
            )
930
        )
931
    )
932

933
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
11✔
934

935
    if strip_output_chroot:
11✔
936
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
11✔
937
    else:
938
        output_digest = result.output_digest
5✔
939

940
    digest = (
11✔
941
        await merge_digests(
942
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
943
        )
944
        if request.pex_path
945
        else output_digest
946
    )
947

948
    return BuildPexResult(
11✔
949
        result=result,
950
        pex_filename=request.output_filename,
951
        digest=digest,
952
        python=pex_python_setup.python,
953
    )
954

955

956
def _build_pex_description(
11✔
957
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
958
) -> str:
959
    if request.description:
11✔
960
        return request.description
3✔
961

962
    if isinstance(request.requirements, EntireLockfile):
11✔
963
        lockfile = request.requirements.lockfile
11✔
964
        desc_suffix = f"from {lockfile.url}"
11✔
965
    else:
966
        if not req_strings:
11✔
967
            return f"Building {request.output_filename}"
11✔
968
        elif isinstance(request.requirements.from_superset, Pex):
5✔
969
            repo_pex = request.requirements.from_superset.name
1✔
970
            return softwrap(
1✔
971
                f"""
972
                Extracting {pluralize(len(req_strings), "requirement")}
973
                to build {request.output_filename} from {repo_pex}:
974
                {", ".join(req_strings)}
975
                """
976
            )
977
        elif isinstance(request.requirements.from_superset, Resolve):
5✔
978
            # At this point we know this is a valid user resolve, so we can assume
979
            # it's available in the dict. Nonetheless we use get() so that any weird error
980
            # here gives a bad message rather than an outright crash.
981
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
1✔
982
            return softwrap(
1✔
983
                f"""
984
                Building {pluralize(len(req_strings), "requirement")}
985
                for {request.output_filename} from the {lockfile_path} resolve:
986
                {", ".join(req_strings)}
987
                """
988
            )
989
        else:
990
            desc_suffix = softwrap(
5✔
991
                f"""
992
                with {pluralize(len(req_strings), "requirement")}:
993
                {", ".join(req_strings)}
994
                """
995
            )
996
    return f"Building {request.output_filename} {desc_suffix}"
11✔
997

998

999
@rule
11✔
1000
async def create_pex(request: PexRequest) -> Pex:
11✔
1001
    result = await build_pex(request, **implicitly())
10✔
1002
    return result.create_pex()
10✔
1003

1004

1005
@rule
11✔
1006
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
11✔
1007
    if request.maybe_pex_request is None:
10✔
1008
        return OptionalPex(None)
10✔
1009
    result = await create_pex(request.maybe_pex_request)
1✔
1010
    return OptionalPex(result)
1✔
1011

1012

1013
@dataclass(frozen=True)
11✔
1014
class Script:
11✔
1015
    path: PurePath
11✔
1016

1017
    @property
11✔
1018
    def argv0(self) -> str:
11✔
1019
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
11✔
1020

1021

1022
@dataclass(frozen=True)
11✔
1023
class VenvScript:
11✔
1024
    script: Script
11✔
1025
    content: FileContent
11✔
1026

1027

1028
@dataclass(frozen=True)
11✔
1029
class VenvScriptWriter:
11✔
1030
    complete_pex_env: CompletePexEnvironment
11✔
1031
    pex: Pex
11✔
1032
    venv_dir: PurePath
11✔
1033

1034
    @classmethod
11✔
1035
    def create(
11✔
1036
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
1037
    ) -> VenvScriptWriter:
1038
        # N.B.: We don't know the working directory that will be used in any given
1039
        # invocation of the venv scripts; so we deal with working_directory once in an
1040
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
1041
        # CWD offset math in every rule for all the relative paths their process depends on.
1042
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
11✔
1043
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
11✔
1044

1045
    def _create_venv_script(
11✔
1046
        self,
1047
        bash: BashBinary,
1048
        *,
1049
        script_path: PurePath,
1050
        venv_executable: PurePath,
1051
    ) -> VenvScript:
1052
        env_vars = (
11✔
1053
            f"{name}={shlex.quote(value)}"
1054
            for name, value in self.complete_pex_env.environment_dict(
1055
                python_configured=True
1056
            ).items()
1057
        )
1058

1059
        target_venv_executable = shlex.quote(str(venv_executable))
11✔
1060
        venv_dir = shlex.quote(str(self.venv_dir))
11✔
1061
        execute_pex_args = " ".join(
11✔
1062
            f"$(adjust_relative_paths {shlex.quote(arg)})"
1063
            for arg in self.complete_pex_env.create_argv(self.pex.name, python=self.pex.python)
1064
        )
1065

1066
        script = dedent(
11✔
1067
            f"""\
1068
            #!{bash.path}
1069
            set -euo pipefail
1070

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

1077
            function adjust_relative_paths() {{
1078
                local value0="$1"
1079
                shift
1080
                if [ "${{value0:0:1}}" == "/" ]; then
1081
                    # Don't relativize absolute paths.
1082
                    echo "${{value0}}" "$@"
1083
                else
1084
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
1085
                    # this script works when run with a PWD set somewhere else than the sandbox
1086
                    # root.
1087
                    #
1088
                    # There are two cases to consider. For the purposes of example, assume PWD is
1089
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
1090
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
1091
                    #
1092
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
1093
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
1094
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
1095
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
1096
                    #    `../..`, we calculate `../../config/tool.yml`.
1097
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
1098
                fi
1099
            }}
1100

1101
            export {" ".join(env_vars)}
1102
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1103

1104
            execute_pex_args="{execute_pex_args}"
1105
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1106
            venv_dir="$(adjust_relative_paths {venv_dir})"
1107

1108
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1109
            # with tools support.
1110
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1111
              exec ${{execute_pex_args}} "$@"
1112
            fi
1113

1114
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1115
            # `--venv` mode PEX file.
1116
            if [ ! -e "${{venv_dir}}" ]; then
1117
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1118
            fi
1119

1120
            exec "${{target_venv_executable}}" "$@"
1121
            """
1122
        )
1123
        return VenvScript(
11✔
1124
            script=Script(script_path),
1125
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1126
        )
1127

1128
    def exe(self, bash: BashBinary) -> VenvScript:
11✔
1129
        """Writes a safe shim for the venv's executable `pex` script."""
1130
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
11✔
1131
        return self._create_venv_script(
11✔
1132
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1133
        )
1134

1135
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
11✔
1136
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1137
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
11✔
1138
        return self._create_venv_script(
11✔
1139
            bash,
1140
            script_path=script_path,
1141
            venv_executable=self.venv_dir / "bin" / name,
1142
        )
1143

1144
    def python(self, bash: BashBinary) -> VenvScript:
11✔
1145
        """Writes a safe shim for the venv's python binary."""
1146
        return self.bin(bash, "python")
11✔
1147

1148

1149
@dataclass(frozen=True)
11✔
1150
class VenvPex:
11✔
1151
    digest: Digest
11✔
1152
    append_only_caches: FrozenDict[str, str] | None
11✔
1153
    pex_filename: str
11✔
1154
    pex: Script
11✔
1155
    python: Script
11✔
1156
    bin: FrozenDict[str, Script]
11✔
1157
    venv_rel_dir: str
11✔
1158

1159

1160
@dataclass(frozen=True)
11✔
1161
class VenvPexRequest:
11✔
1162
    pex_request: PexRequest
11✔
1163
    complete_pex_env: CompletePexEnvironment
11✔
1164
    bin_names: tuple[str, ...] = ()
11✔
1165
    site_packages_copies: bool = False
11✔
1166

1167
    def __init__(
11✔
1168
        self,
1169
        pex_request: PexRequest,
1170
        complete_pex_env: CompletePexEnvironment,
1171
        bin_names: Iterable[str] = (),
1172
        site_packages_copies: bool = False,
1173
    ) -> None:
1174
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1175

1176
        :param pex_request: The details of the desired PEX.
1177
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1178
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1179
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1180
            dependencies when installing them in the venv site-packages directory. By default this
1181
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1182
            but results in a non-standard venv structure that does trip up some libraries.
1183
        """
1184
        object.__setattr__(self, "pex_request", pex_request)
11✔
1185
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
11✔
1186
        object.__setattr__(self, "bin_names", tuple(bin_names))
11✔
1187
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
11✔
1188

1189

1190
@rule
11✔
1191
async def wrap_venv_prex_request(
11✔
1192
    pex_request: PexRequest, pex_environment: PexEnvironment
1193
) -> VenvPexRequest:
1194
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1195
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
11✔
1196

1197

1198
@rule
11✔
1199
async def create_venv_pex(
11✔
1200
    request: VenvPexRequest, bash: BashBinary, pex_environment: PexEnvironment
1201
) -> VenvPex:
1202
    # VenvPex is motivated by improving performance of Python tools by eliminating traditional PEX
1203
    # file startup overhead.
1204
    #
1205
    # To achieve the minimal overhead (on the order of 1ms) we discard:
1206
    # 1. Using Pex default mode:
1207
    #    Although this does reduce initial tool execution overhead, it still leaves a minimum
1208
    #    O(100ms) of overhead per subsequent tool invocation. Fundamentally, Pex still needs to
1209
    #    execute its `sys.path` isolation bootstrap code in this case.
1210
    # 2. Using the Pex `venv` tool:
1211
    #    The idea here would be to create a tool venv as a Process output and then use the tool
1212
    #    venv as an input digest for all tool invocations. This was tried and netted ~500ms of
1213
    #    overhead over raw venv use.
1214
    #
1215
    # Instead we use Pex's `--venv` mode. In this mode you can run the Pex file and it will create a
1216
    # venv on the fly in the PEX_ROOT as needed. Since the PEX_ROOT is a named_cache, we avoid the
1217
    # digest materialization overhead present in 2 above. Since the venv is naturally isolated we
1218
    # avoid the `sys.path` isolation overhead of Pex itself present in 1 above.
1219
    #
1220
    # This does leave O(50ms) of overhead though for the PEX bootstrap code to detect an already
1221
    # created venv in the PEX_ROOT and re-exec into it. To eliminate this overhead we execute the
1222
    # `pex` venv script in the PEX_ROOT directly. This is not robust on its own though, since the
1223
    # named caches store might be pruned at any time. To guard against that case we introduce a shim
1224
    # bash script that checks to see if the `pex` venv script exists in the PEX_ROOT and re-creates
1225
    # the PEX_ROOT venv if not. Using the shim script to run Python tools gets us down to the ~1ms
1226
    # of overhead we currently enjoy.
1227

1228
    pex_request = request.pex_request
11✔
1229
    seeded_venv_request = dataclasses.replace(
11✔
1230
        pex_request,
1231
        additional_args=pex_request.additional_args
1232
        + (
1233
            "--venv",
1234
            "prepend",
1235
            "--seed",
1236
            "verbose",
1237
            pex_environment.venv_site_packages_copies_option(
1238
                use_copies=request.site_packages_copies
1239
            ),
1240
        ),
1241
    )
1242
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
11✔
1243
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1244
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1245
    # directory.
1246
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
11✔
1247
    abs_pex_root = PurePath(seed_info["pex_root"])
11✔
1248
    abs_pex_path = PurePath(seed_info["pex"])
11✔
1249
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
11✔
1250

1251
    venv_script_writer = VenvScriptWriter.create(
11✔
1252
        complete_pex_env=request.complete_pex_env,
1253
        pex=venv_pex_result.create_pex(),
1254
        venv_rel_dir=venv_rel_dir,
1255
    )
1256
    pex = venv_script_writer.exe(bash)
11✔
1257
    python = venv_script_writer.python(bash)
11✔
1258
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
11✔
1259
    scripts_digest = await create_digest(
11✔
1260
        CreateDigest(
1261
            (
1262
                pex.content,
1263
                python.content,
1264
                *(venv_script.content for venv_script in scripts.values()),
1265
            )
1266
        )
1267
    )
1268
    input_digest = await merge_digests(
11✔
1269
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1270
    )
1271
    append_only_caches = (
11✔
1272
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1273
    )
1274

1275
    return VenvPex(
11✔
1276
        digest=input_digest,
1277
        append_only_caches=append_only_caches,
1278
        pex_filename=venv_pex_result.pex_filename,
1279
        pex=pex.script,
1280
        python=python.script,
1281
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1282
        venv_rel_dir=venv_rel_dir.as_posix(),
1283
    )
1284

1285

1286
@dataclass(frozen=True)
11✔
1287
class PexProcess:
11✔
1288
    pex: Pex
11✔
1289
    argv: tuple[str, ...]
11✔
1290
    description: str = dataclasses.field(compare=False)
11✔
1291
    level: LogLevel
11✔
1292
    input_digest: Digest | None
11✔
1293
    working_directory: str | None
11✔
1294
    extra_env: FrozenDict[str, str]
11✔
1295
    output_files: tuple[str, ...] | None
11✔
1296
    output_directories: tuple[str, ...] | None
11✔
1297
    timeout_seconds: int | None
11✔
1298
    execution_slot_variable: str | None
11✔
1299
    concurrency_available: int
11✔
1300
    cache_scope: ProcessCacheScope
11✔
1301

1302
    def __init__(
11✔
1303
        self,
1304
        pex: Pex,
1305
        *,
1306
        description: str,
1307
        argv: Iterable[str] = (),
1308
        level: LogLevel = LogLevel.INFO,
1309
        input_digest: Digest | None = None,
1310
        working_directory: str | None = None,
1311
        extra_env: Mapping[str, str] | None = None,
1312
        output_files: Iterable[str] | None = None,
1313
        output_directories: Iterable[str] | None = None,
1314
        timeout_seconds: int | None = None,
1315
        execution_slot_variable: str | None = None,
1316
        concurrency_available: int = 0,
1317
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1318
    ) -> None:
1319
        object.__setattr__(self, "pex", pex)
7✔
1320
        object.__setattr__(self, "argv", tuple(argv))
7✔
1321
        object.__setattr__(self, "description", description)
7✔
1322
        object.__setattr__(self, "level", level)
7✔
1323
        object.__setattr__(self, "input_digest", input_digest)
7✔
1324
        object.__setattr__(self, "working_directory", working_directory)
7✔
1325
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
7✔
1326
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
7✔
1327
        object.__setattr__(
7✔
1328
            self, "output_directories", tuple(output_directories) if output_directories else None
1329
        )
1330
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
7✔
1331
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
7✔
1332
        object.__setattr__(self, "concurrency_available", concurrency_available)
7✔
1333
        object.__setattr__(self, "cache_scope", cache_scope)
7✔
1334

1335

1336
@rule
11✔
1337
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
11✔
1338
    pex = request.pex
7✔
1339
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
7✔
1340
    argv = complete_pex_env.create_argv(pex.name, *request.argv, python=pex.python)
7✔
1341
    env = {
7✔
1342
        **complete_pex_env.environment_dict(python_configured=pex.python is not None),
1343
        **request.extra_env,
1344
    }
1345
    input_digest = (
7✔
1346
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1347
        if request.input_digest
1348
        else pex.digest
1349
    )
1350
    append_only_caches = (
7✔
1351
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1352
    )
1353
    return Process(
7✔
1354
        argv,
1355
        description=request.description,
1356
        level=request.level,
1357
        input_digest=input_digest,
1358
        working_directory=request.working_directory,
1359
        env=env,
1360
        output_files=request.output_files,
1361
        output_directories=request.output_directories,
1362
        append_only_caches={
1363
            **complete_pex_env.append_only_caches,
1364
            **append_only_caches,
1365
        },
1366
        timeout_seconds=request.timeout_seconds,
1367
        execution_slot_variable=request.execution_slot_variable,
1368
        concurrency_available=request.concurrency_available,
1369
        cache_scope=request.cache_scope,
1370
    )
1371

1372

1373
@dataclass(unsafe_hash=True)
11✔
1374
class VenvPexProcess:
11✔
1375
    venv_pex: VenvPex
11✔
1376
    argv: tuple[str, ...]
11✔
1377
    description: str = dataclasses.field(compare=False)
11✔
1378
    level: LogLevel
11✔
1379
    input_digest: Digest | None
11✔
1380
    working_directory: str | None
11✔
1381
    extra_env: FrozenDict[str, str]
11✔
1382
    output_files: tuple[str, ...] | None
11✔
1383
    output_directories: tuple[str, ...] | None
11✔
1384
    timeout_seconds: int | None
11✔
1385
    execution_slot_variable: str | None
11✔
1386
    concurrency_available: int
11✔
1387
    cache_scope: ProcessCacheScope
11✔
1388
    append_only_caches: FrozenDict[str, str]
11✔
1389

1390
    def __init__(
11✔
1391
        self,
1392
        venv_pex: VenvPex,
1393
        *,
1394
        description: str,
1395
        argv: Iterable[str] = (),
1396
        level: LogLevel = LogLevel.INFO,
1397
        input_digest: Digest | None = None,
1398
        working_directory: str | None = None,
1399
        extra_env: Mapping[str, str] | None = None,
1400
        output_files: Iterable[str] | None = None,
1401
        output_directories: Iterable[str] | None = None,
1402
        timeout_seconds: int | None = None,
1403
        execution_slot_variable: str | None = None,
1404
        concurrency_available: int = 0,
1405
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1406
        append_only_caches: Mapping[str, str] | None = None,
1407
    ) -> None:
1408
        object.__setattr__(self, "venv_pex", venv_pex)
11✔
1409
        object.__setattr__(self, "argv", tuple(argv))
11✔
1410
        object.__setattr__(self, "description", description)
11✔
1411
        object.__setattr__(self, "level", level)
11✔
1412
        object.__setattr__(self, "input_digest", input_digest)
11✔
1413
        object.__setattr__(self, "working_directory", working_directory)
11✔
1414
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
11✔
1415
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
11✔
1416
        object.__setattr__(
11✔
1417
            self, "output_directories", tuple(output_directories) if output_directories else None
1418
        )
1419
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
11✔
1420
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
11✔
1421
        object.__setattr__(self, "concurrency_available", concurrency_available)
11✔
1422
        object.__setattr__(self, "cache_scope", cache_scope)
11✔
1423
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
11✔
1424

1425

1426
@rule
11✔
1427
async def setup_venv_pex_process(
11✔
1428
    request: VenvPexProcess, pex_environment: PexEnvironment
1429
) -> Process:
1430
    venv_pex = request.venv_pex
11✔
1431
    pex_bin = (
11✔
1432
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1433
        if request.working_directory
1434
        else venv_pex.pex.argv0
1435
    )
1436
    argv = (pex_bin, *request.argv)
11✔
1437
    input_digest = (
11✔
1438
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1439
        if request.input_digest
1440
        else venv_pex.digest
1441
    )
1442
    append_only_caches: FrozenDict[str, str] = FrozenDict(
11✔
1443
        **pex_environment.in_sandbox(
1444
            working_directory=request.working_directory
1445
        ).append_only_caches,
1446
        **(request.append_only_caches or FrozenDict({})),
1447
        **(venv_pex.append_only_caches or FrozenDict({})),
1448
    )
1449
    return Process(
11✔
1450
        argv=argv,
1451
        description=request.description,
1452
        level=request.level,
1453
        input_digest=input_digest,
1454
        working_directory=request.working_directory,
1455
        env=request.extra_env,
1456
        output_files=request.output_files,
1457
        output_directories=request.output_directories,
1458
        append_only_caches=append_only_caches,
1459
        timeout_seconds=request.timeout_seconds,
1460
        execution_slot_variable=request.execution_slot_variable,
1461
        concurrency_available=request.concurrency_available,
1462
        cache_scope=request.cache_scope,
1463
    )
1464

1465

1466
@dataclass(frozen=True)
11✔
1467
class PexDistributionInfo:
11✔
1468
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1469
    repository info -v`."""
1470

1471
    project_name: str
11✔
1472
    version: packaging.version.Version
11✔
1473
    requires_python: packaging.specifiers.SpecifierSet | None
11✔
1474
    # Note: These are parsed from metadata written by the pex tool, and are always
1475
    #   a valid packaging.requirements.Requirement.
1476
    requires_dists: tuple[Requirement, ...]
11✔
1477

1478

1479
DefaultT = TypeVar("DefaultT")
11✔
1480

1481

1482
class PexResolveInfo(Collection[PexDistributionInfo]):
11✔
1483
    """Information about all distributions resolved in a PEX file, as reported by `PEX_TOOLS=1
1484
    repository info -v`."""
1485

1486
    def find(
11✔
1487
        self, name: str, default: DefaultT | None = None
1488
    ) -> PexDistributionInfo | DefaultT | None:
1489
        """Returns the PexDistributionInfo with the given name, first one wins."""
1490
        try:
5✔
1491
            return next(info for info in self if info.project_name == name)
5✔
1492
        except StopIteration:
×
1493
            return default
×
1494

1495

1496
def parse_repository_info(repository_info: str) -> PexResolveInfo:
11✔
1497
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
6✔
1498
        for line in repository_info.splitlines():
6✔
1499
            info = json.loads(line)
6✔
1500
            requires_python = info["requires_python"]
6✔
1501
            yield PexDistributionInfo(
6✔
1502
                project_name=info["project_name"],
1503
                version=packaging.version.Version(info["version"]),
1504
                requires_python=(
1505
                    packaging.specifiers.SpecifierSet(requires_python)
1506
                    if requires_python is not None
1507
                    else None
1508
                ),
1509
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1510
            )
1511

1512
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
6✔
1513

1514

1515
@rule
11✔
1516
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
11✔
1517
    process_result = await fallible_to_exec_result_or_raise(
5✔
1518
        **implicitly(
1519
            VenvPexProcess(
1520
                venv_pex,
1521
                argv=["repository", "info", "-v"],
1522
                extra_env={"PEX_TOOLS": "1"},
1523
                input_digest=venv_pex.digest,
1524
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1525
                level=LogLevel.DEBUG,
1526
            )
1527
        )
1528
    )
1529
    return parse_repository_info(process_result.stdout.decode())
5✔
1530

1531

1532
@rule
11✔
1533
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
11✔
1534
    process_result = await fallible_to_exec_result_or_raise(
4✔
1535
        **implicitly(
1536
            PexProcess(
1537
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1538
                argv=[pex.name, "repository", "info", "-v"],
1539
                input_digest=pex.digest,
1540
                extra_env={"PEX_MODULE": "pex.tools"},
1541
                description=f"Determine distributions found in {pex.name}",
1542
                level=LogLevel.DEBUG,
1543
            )
1544
        )
1545
    )
1546
    return parse_repository_info(process_result.stdout.decode())
4✔
1547

1548

1549
def rules():
11✔
1550
    return [
11✔
1551
        *collect_rules(),
1552
        *pex_cli.rules(),
1553
        *pex_requirements.rules(),
1554
        *uv_subsystem.rules(),
1555
        *uv_rules(),
1556
        *stripped_source_rules(),
1557
    ]
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