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

pantsbuild / pants / 21042790249

15 Jan 2026 06:57PM UTC coverage: 43.263% (-35.4%) from 78.666%
21042790249

Pull #23021

github

web-flow
Merge cc03ad8de into d250c80fe
Pull Request #23021: WIP gh workflow scie pex

23 of 33 new or added lines in 3 files covered. (69.7%)

16147 existing lines in 521 files now uncovered.

26164 of 60477 relevant lines covered (43.26%)

0.87 hits per line

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

44.42
/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
2✔
5

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

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

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

101
logger = logging.getLogger(__name__)
2✔
102

103

104
@union(in_scope_types=[EnvironmentName])
2✔
105
@dataclass(frozen=True)
2✔
106
class PythonProvider:
2✔
107
    """Union which should have 0 or 1 implementations registered which provide Python.
108

109
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
110
    """
111

112
    interpreter_constraints: InterpreterConstraints
2✔
113

114

115
@rule(polymorphic=True)
2✔
116
async def get_python_executable(
2✔
117
    provider: PythonProvider, env_name: EnvironmentName
118
) -> PythonExecutable:
119
    raise NotImplementedError()
×
120

121

122
class PexPlatforms(DeduplicatedCollection[str]):
2✔
123
    sort_input = True
2✔
124

125
    def generate_pex_arg_list(self) -> list[str]:
2✔
UNCOV
126
        args = []
×
UNCOV
127
        for platform in self:
×
UNCOV
128
            args.extend(["--platform", platform])
×
UNCOV
129
        return args
×
130

131

132
class CompletePlatforms(DeduplicatedCollection[str]):
2✔
133
    sort_input = True
2✔
134

135
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
2✔
136
        super().__init__(iterable)
2✔
137
        self._digest = digest
2✔
138

139
    @classmethod
2✔
140
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
2✔
UNCOV
141
        return cls(snapshot.files, digest=snapshot.digest)
×
142

143
    @property
2✔
144
    def digest(self) -> Digest:
2✔
UNCOV
145
        return self._digest
×
146

147
    def generate_pex_arg_list(self) -> Iterator[str]:
2✔
UNCOV
148
        for path in self:
×
UNCOV
149
            yield "--complete-platform"
×
UNCOV
150
            yield path
×
151

152

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

177

178
@rule
2✔
179
async def digest_complete_platforms(
2✔
180
    complete_platforms: PexCompletePlatformsField,
181
) -> CompletePlatforms:
182
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
×
183

184

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

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

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

UNCOV
285
        self.__post_init__()
×
286

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

336
    def debug_hint(self) -> str:
2✔
337
        return self.output_filename
×
338

339

340
@dataclass(frozen=True)
2✔
341
class OptionalPexRequest:
2✔
342
    maybe_pex_request: PexRequest | None
2✔
343

344

345
@dataclass(frozen=True)
2✔
346
class Pex:
2✔
347
    """Wrapper for a digest containing a pex file created with some filename."""
348

349
    digest: Digest
2✔
350
    name: str
2✔
351
    python: PythonExecutable | None
2✔
352

353

354
@dataclass(frozen=True)
2✔
355
class OptionalPex:
2✔
356
    maybe_pex: Pex | None
2✔
357

358

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

374
                {bullet_list(repr(provider.__class__) for provider in python_providers)}
375
                """
376
            )
377
        )
378
    if python_providers:
×
379
        python_provider = next(iter(python_providers))
×
380
        python = await get_python_executable(
×
381
            **implicitly({python_provider(interpreter_constraints): PythonProvider})
382
        )
383
        return python
×
384

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

410
                    python = os.path.realpath(sys.executable)
411
                    print(python)
412

413
                    hasher = hashlib.sha256()
414
                    with open(python, "rb") as fp:
415
                      for chunk in iter(lambda: fp.read(8192), b""):
416
                          hasher.update(chunk)
417
                    print(hasher.hexdigest())
418
                    """
419
                    ),
420
                ),
421
                level=LogLevel.DEBUG,
422
                cache_scope=env_target.executable_search_path_cache_scope(),
423
            )
424
        )
425
    )
426
    path, fingerprint = result.stdout.decode().strip().splitlines()
×
427

428
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
429

430
    return PythonExecutable(path=path, fingerprint=fingerprint)
×
431

432

433
@dataclass(frozen=True)
2✔
434
class BuildPexResult:
2✔
435
    result: ProcessResult
2✔
436
    pex_filename: str
2✔
437
    digest: Digest
2✔
438
    python: PythonExecutable | None
2✔
439

440
    def create_pex(self) -> Pex:
2✔
441
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
×
442

443

444
@dataclass
2✔
445
class _BuildPexPythonSetup:
2✔
446
    python: PythonExecutable | None
2✔
447
    argv: list[str]
2✔
448

449

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

UNCOV
467
    if request.python:
×
UNCOV
468
        python = request.python
×
469
    else:
UNCOV
470
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
×
471

UNCOV
472
    if request.python or request.internal_only:
×
473
        # Sometimes we want to build and run with a specific interpreter (either because request
474
        # demanded it, or because it's an internal-only PEX). We will have already validated that
475
        # there were no platforms.
UNCOV
476
        return _BuildPexPythonSetup(python, ["--python", python.path])
×
477

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

494

495
@dataclass
2✔
496
class _BuildPexRequirementsSetup:
2✔
497
    digests: list[Digest]
2✔
498
    argv: list[str]
2✔
499
    concurrency_available: int
2✔
500

501

502
@dataclass(frozen=True)
2✔
503
class PexRequirementsInfo:
2✔
504
    req_strings: tuple[str, ...]
2✔
505
    find_links: tuple[str, ...]
2✔
506

507

508
@rule
2✔
509
async def get_req_strings(pex_reqs: PexRequirements) -> PexRequirementsInfo:
2✔
510
    addrs: list[Address] = []
×
511
    specs: list[str] = []
×
512
    req_strings: list[str] = []
×
513
    find_links: set[str] = set()
×
514
    for req_str_or_addr in pex_reqs.req_strings_or_addrs:
×
515
        if isinstance(req_str_or_addr, Address):
×
516
            addrs.append(req_str_or_addr)
×
517
        else:
518
            assert isinstance(req_str_or_addr, str)
×
519
            # Require a `//` prefix, to distinguish address specs from
520
            # local or VCS requirements.
521
            if req_str_or_addr.startswith(os.path.sep * 2):
×
522
                specs.append(req_str_or_addr)
×
523
            else:
524
                req_strings.append(req_str_or_addr)
×
525
    if specs:
×
526
        addrs_from_specs = await resolve_unparsed_address_inputs(
×
527
            UnparsedAddressInputs(
528
                specs,
529
                owning_address=None,
530
                description_of_origin=pex_reqs.description_of_origin,
531
            ),
532
            **implicitly(),
533
        )
534
        addrs.extend(addrs_from_specs)
×
535
    if addrs:
×
536
        transitive_targets = await transitive_targets_get(
×
537
            TransitiveTargetsRequest(addrs), **implicitly()
538
        )
539
        req_strings.extend(
×
540
            PexRequirements.req_strings_from_requirement_fields(
541
                tgt[PythonRequirementsField]
542
                for tgt in transitive_targets.closure
543
                if tgt.has_field(PythonRequirementsField)
544
            )
545
        )
546
        find_links.update(
×
547
            find_links
548
            for tgt in transitive_targets.closure
549
            if tgt.has_field(PythonRequirementFindLinksField)
550
            for find_links in tgt[PythonRequirementFindLinksField].value or ()
551
        )
552
    return PexRequirementsInfo(tuple(sorted(req_strings)), tuple(sorted(find_links)))
×
553

554

555
async def _get_entire_lockfile_and_requirements(
2✔
556
    requirements: EntireLockfile | PexRequirements,
557
) -> tuple[LoadedLockfile | None, tuple[str, ...]]:
UNCOV
558
    lockfile: Lockfile | None = None
×
UNCOV
559
    complete_req_strings: tuple[str, ...] = tuple()
×
560
    # TODO: This is clunky, but can be simplified once we get rid of old-style tool
561
    #  lockfiles, because we can unify EntireLockfile and Resolve.
UNCOV
562
    if isinstance(requirements, EntireLockfile):
×
UNCOV
563
        complete_req_strings = requirements.complete_req_strings or tuple()
×
UNCOV
564
        lockfile = requirements.lockfile
×
UNCOV
565
    elif (
×
566
        isinstance(requirements.from_superset, Resolve)
567
        and requirements.from_superset.use_entire_lockfile
568
    ):
569
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
×
UNCOV
570
    if not lockfile:
×
UNCOV
571
        return None, complete_req_strings
×
UNCOV
572
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
UNCOV
573
    return loaded_lockfile, complete_req_strings
×
574

575

576
@rule
2✔
577
async def _setup_pex_requirements(
2✔
578
    request: PexRequest, python_setup: PythonSetup
579
) -> _BuildPexRequirementsSetup:
580
    resolve_name: str | None
UNCOV
581
    if isinstance(request.requirements, EntireLockfile):
×
UNCOV
582
        resolve_name = request.requirements.lockfile.resolve_name
×
UNCOV
583
    elif isinstance(request.requirements.from_superset, Resolve):
×
UNCOV
584
        resolve_name = request.requirements.from_superset.name
×
585
    else:
586
        # This implies that, currently, per-resolve options are only configurable for resolves.
587
        # However, if no resolve is specified, we will still load options that apply to every
588
        # resolve, like `[python-repos].indexes`.
UNCOV
589
        resolve_name = None
×
UNCOV
590
    resolve_config = await determine_resolve_pex_config(
×
591
        ResolvePexConfigRequest(resolve_name), **implicitly()
592
    )
593

UNCOV
594
    pex_lock_resolver_args = list(resolve_config.pex_args())
×
UNCOV
595
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
×
596

UNCOV
597
    loaded_lockfile, complete_req_strings = await _get_entire_lockfile_and_requirements(
×
598
        request.requirements
599
    )
UNCOV
600
    if loaded_lockfile:
×
UNCOV
601
        argv = (
×
602
            ["--lock", loaded_lockfile.lockfile_path, *pex_lock_resolver_args]
603
            if loaded_lockfile.is_pex_native
604
            # We use pip to resolve a requirements.txt pseudo-lockfile, possibly with hashes.
605
            else [
606
                "--requirement",
607
                loaded_lockfile.lockfile_path,
608
                "--no-transitive",
609
                *pip_resolver_args,
610
            ]
611
        )
UNCOV
612
        if loaded_lockfile.metadata and complete_req_strings:
×
613
            validate_metadata(
×
614
                loaded_lockfile.metadata,
615
                request.interpreter_constraints,
616
                loaded_lockfile.original_lockfile,
617
                complete_req_strings,
618
                # We're using the entire lockfile, so there is no Pex subsetting operation we
619
                # can delegate requirement validation to.  So we do our naive string-matching
620
                # validation.
621
                validate_consumed_req_strings=True,
622
                python_setup=python_setup,
623
                resolve_config=resolve_config,
624
            )
625

UNCOV
626
        return _BuildPexRequirementsSetup(
×
627
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
628
        )
629

UNCOV
630
    assert isinstance(request.requirements, PexRequirements)
×
UNCOV
631
    reqs_info = await get_req_strings(request.requirements)
×
632

633
    # TODO: This is not the best heuristic for available concurrency, since the
634
    # requirements almost certainly have transitive deps which also need building, but it
635
    # is better than using something hardcoded.
UNCOV
636
    concurrency_available = len(reqs_info.req_strings)
×
637

UNCOV
638
    if isinstance(request.requirements.from_superset, Pex):
×
UNCOV
639
        repository_pex = request.requirements.from_superset
×
UNCOV
640
        return _BuildPexRequirementsSetup(
×
641
            [repository_pex.digest],
642
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
643
            concurrency_available,
644
        )
645

UNCOV
646
    elif isinstance(request.requirements.from_superset, Resolve):
×
UNCOV
647
        lockfile = await get_lockfile_for_resolve(
×
648
            request.requirements.from_superset, **implicitly()
649
        )
UNCOV
650
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
651

652
        # NB: This is also validated in the constructor.
UNCOV
653
        assert loaded_lockfile.is_pex_native
×
UNCOV
654
        if not reqs_info.req_strings:
×
655
            return _BuildPexRequirementsSetup([], [], concurrency_available)
×
656

UNCOV
657
        if loaded_lockfile.metadata:
×
658
            validate_metadata(
×
659
                loaded_lockfile.metadata,
660
                request.interpreter_constraints,
661
                loaded_lockfile.original_lockfile,
662
                consumed_req_strings=reqs_info.req_strings,
663
                # Don't validate user requirements when subsetting a resolve, as Pex's
664
                # validation during the subsetting is far more precise than our naive string
665
                # comparison. For example, if a lockfile was generated with `foo==1.2.3`
666
                # and we want to resolve `foo>=1.0.0` or just `foo` out of it, Pex will do
667
                # so successfully, while our naive validation would fail.
668
                validate_consumed_req_strings=False,
669
                python_setup=python_setup,
670
                resolve_config=resolve_config,
671
            )
672

UNCOV
673
        return _BuildPexRequirementsSetup(
×
674
            [loaded_lockfile.lockfile_digest],
675
            [
676
                *reqs_info.req_strings,
677
                "--lock",
678
                loaded_lockfile.lockfile_path,
679
                *pex_lock_resolver_args,
680
            ],
681
            concurrency_available,
682
        )
683

684
    # We use pip to perform a normal resolve.
UNCOV
685
    digests = []
×
UNCOV
686
    argv = [
×
687
        *reqs_info.req_strings,
688
        *pip_resolver_args,
689
        *(f"--find-links={find_links}" for find_links in reqs_info.find_links),
690
    ]
UNCOV
691
    if request.requirements.constraints_strings:
×
UNCOV
692
        constraints_file = "__constraints.txt"
×
UNCOV
693
        constraints_content = "\n".join(request.requirements.constraints_strings)
×
UNCOV
694
        digests.append(
×
695
            await create_digest(
696
                CreateDigest([FileContent(constraints_file, constraints_content.encode())])
697
            )
698
        )
UNCOV
699
        argv.extend(["--constraints", constraints_file])
×
UNCOV
700
    return _BuildPexRequirementsSetup(digests, argv, concurrency_available=concurrency_available)
×
701

702

703
@rule(level=LogLevel.DEBUG)
2✔
704
async def build_pex(
2✔
705
    request: PexRequest, python_setup: PythonSetup, pex_subsystem: PexSubsystem
706
) -> BuildPexResult:
707
    """Returns a PEX with the given settings."""
708

709
    if not request.python and not request.interpreter_constraints:
×
710
        # Blank ICs in the request means that the caller wants us to use the ICs configured
711
        # for the resolve (falling back to the global ICs).
712
        resolve_name = ""
×
713
        if isinstance(request.requirements, PexRequirements) and isinstance(
×
714
            request.requirements.from_superset, Resolve
715
        ):
716
            resolve_name = request.requirements.from_superset.name
×
717
        elif isinstance(request.requirements, EntireLockfile):
×
718
            resolve_name = request.requirements.lockfile.resolve_name
×
719

720
        if resolve_name:
×
721
            request = dataclasses.replace(
×
722
                request,
723
                interpreter_constraints=InterpreterConstraints(
724
                    python_setup.resolves_to_interpreter_constraints.get(
725
                        resolve_name,
726
                        python_setup.interpreter_constraints,
727
                    )
728
                ),
729
            )
730

731
    source_dir_name = "source_files"
×
732

733
    pex_python_setup_req = _determine_pex_python_and_platforms(request)
×
734
    requirements_setup_req = _setup_pex_requirements(**implicitly({request: PexRequest}))
×
735
    sources_digest_as_subdir_req = add_prefix(
×
736
        AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
737
    )
738
    if isinstance(request.requirements, PexRequirements):
×
739
        (
×
740
            pex_python_setup,
741
            requirements_setup,
742
            sources_digest_as_subdir,
743
            req_info,
744
        ) = await concurrently(
745
            pex_python_setup_req,
746
            requirements_setup_req,
747
            sources_digest_as_subdir_req,
748
            get_req_strings(request.requirements),
749
        )
750
        req_strings = req_info.req_strings
×
751
    else:
752
        pex_python_setup, requirements_setup, sources_digest_as_subdir = await concurrently(
×
753
            pex_python_setup_req,
754
            requirements_setup_req,
755
            sources_digest_as_subdir_req,
756
        )
757
        req_strings = ()
×
758

759
    output_chroot = os.path.dirname(request.output_filename)
×
760
    if output_chroot:
×
761
        output_file = request.output_filename
×
762
        strip_output_chroot = False
×
763
    else:
764
        # In principle a cache should always be just a cache, but existing
765
        # tests in this repo make the assumption that they can look into a
766
        # still intact cache and see the same thing as was there before, which
767
        # requires this to be deterministic and not random.  adler32, because
768
        # it is in the stlib, fast, and doesn't need to be cryptographic.
769
        output_chroot = f"pex-dist-{zlib.adler32(request.output_filename.encode()):08x}"
×
770
        strip_output_chroot = True
×
771
        output_file = os.path.join(output_chroot, request.output_filename)
×
772

773
    argv = [
×
774
        "--output-file",
775
        output_file,
776
        *request.additional_args,
777
    ]
778

779
    argv.extend(pex_python_setup.argv)
×
780

781
    if request.main is not None:
×
782
        argv.extend(request.main.iter_pex_args())
×
783
        if isinstance(request.main, Executable):
×
784
            # Unlike other MainSpecifiecation types (that can pass spec as-is to pex),
785
            # Executable must be an actual path relative to the sandbox.
786
            # request.main.spec is a python source file including its spec_path.
787
            # To make it relative to the sandbox, we strip the source root
788
            # and add the source_dir_name (sources get prefixed with that below).
789
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
×
790
            argv.append(os.path.join(source_dir_name, stripped.value))
×
791

792
    argv.extend(
×
793
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
794
    )
795
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
×
796

797
    # TODO(John Sirois): Right now any request requirements will shadow corresponding pex path
798
    #  requirements, which could lead to problems. Support shading python binaries.
799
    #  See: https://github.com/pantsbuild/pants/issues/9206
800
    if request.pex_path:
×
801
        argv.extend(["--pex-path", ":".join(pex.name for pex in request.pex_path)])
×
802

803
    if request.internal_only:
×
804
        # An internal-only runs on a single machine, and pre-installing wheels is wasted work in
805
        # that case (see https://github.com/pex-tool/pex/issues/2292#issuecomment-1854582647 for
806
        # analysis).
807
        argv.append("--no-pre-install-wheels")
×
808

809
    argv.append(f"--sources-directory={source_dir_name}")
×
810

811
    # Include any additional arguments and input digests required by the requirements.
812
    argv.extend(requirements_setup.argv)
×
813

814
    merged_digest = await merge_digests(
×
815
        MergeDigests(
816
            (
817
                request.complete_platforms.digest,
818
                sources_digest_as_subdir,
819
                request.additional_inputs,
820
                *requirements_setup.digests,
821
                *(pex.digest for pex in request.pex_path),
822
            )
823
        )
824
    )
825

826
    argv.extend(["--layout", request.layout.value])
×
827

828
    result = await fallible_to_exec_result_or_raise(
×
829
        **implicitly(
830
            PexCliProcess(
831
                subcommand=(),
832
                extra_args=argv,
833
                additional_input_digest=merged_digest,
834
                description=_build_pex_description(request, req_strings, python_setup.resolves),
835
                output_files=None,
836
                output_directories=[output_chroot],
837
                concurrency_available=requirements_setup.concurrency_available,
838
                cache_scope=request.cache_scope,
839
            )
840
        )
841
    )
842

843
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
844

845
    if strip_output_chroot:
×
846
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
×
847
    else:
848
        output_digest = result.output_digest
×
849

850
    digest = (
×
851
        await merge_digests(
852
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
853
        )
854
        if request.pex_path
855
        else output_digest
856
    )
857

858
    return BuildPexResult(
×
859
        result=result,
860
        pex_filename=request.output_filename,
861
        digest=digest,
862
        python=pex_python_setup.python,
863
    )
864

865

866
def _build_pex_description(
2✔
867
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
868
) -> str:
UNCOV
869
    if request.description:
×
UNCOV
870
        return request.description
×
871

UNCOV
872
    if isinstance(request.requirements, EntireLockfile):
×
UNCOV
873
        lockfile = request.requirements.lockfile
×
UNCOV
874
        desc_suffix = f"from {lockfile.url}"
×
875
    else:
UNCOV
876
        if not req_strings:
×
UNCOV
877
            return f"Building {request.output_filename}"
×
UNCOV
878
        elif isinstance(request.requirements.from_superset, Pex):
×
UNCOV
879
            repo_pex = request.requirements.from_superset.name
×
UNCOV
880
            return softwrap(
×
881
                f"""
882
                Extracting {pluralize(len(req_strings), "requirement")}
883
                to build {request.output_filename} from {repo_pex}:
884
                {", ".join(req_strings)}
885
                """
886
            )
UNCOV
887
        elif isinstance(request.requirements.from_superset, Resolve):
×
888
            # At this point we know this is a valid user resolve, so we can assume
889
            # it's available in the dict. Nonetheless we use get() so that any weird error
890
            # here gives a bad message rather than an outright crash.
891
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
×
892
            return softwrap(
×
893
                f"""
894
                Building {pluralize(len(req_strings), "requirement")}
895
                for {request.output_filename} from the {lockfile_path} resolve:
896
                {", ".join(req_strings)}
897
                """
898
            )
899
        else:
UNCOV
900
            desc_suffix = softwrap(
×
901
                f"""
902
                with {pluralize(len(req_strings), "requirement")}:
903
                {", ".join(req_strings)}
904
                """
905
            )
UNCOV
906
    return f"Building {request.output_filename} {desc_suffix}"
×
907

908

909
@rule
2✔
910
async def create_pex(request: PexRequest) -> Pex:
2✔
911
    result = await build_pex(request, **implicitly())
×
912
    return result.create_pex()
×
913

914

915
@rule
2✔
916
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
2✔
917
    if request.maybe_pex_request is None:
×
918
        return OptionalPex(None)
×
919
    result = await create_pex(request.maybe_pex_request)
×
920
    return OptionalPex(result)
×
921

922

923
@dataclass(frozen=True)
2✔
924
class Script:
2✔
925
    path: PurePath
2✔
926

927
    @property
2✔
928
    def argv0(self) -> str:
2✔
929
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
×
930

931

932
@dataclass(frozen=True)
2✔
933
class VenvScript:
2✔
934
    script: Script
2✔
935
    content: FileContent
2✔
936

937

938
@dataclass(frozen=True)
2✔
939
class VenvScriptWriter:
2✔
940
    complete_pex_env: CompletePexEnvironment
2✔
941
    pex: Pex
2✔
942
    venv_dir: PurePath
2✔
943

944
    @classmethod
2✔
945
    def create(
2✔
946
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
947
    ) -> VenvScriptWriter:
948
        # N.B.: We don't know the working directory that will be used in any given
949
        # invocation of the venv scripts; so we deal with working_directory once in an
950
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
951
        # CWD offset math in every rule for all the relative paths their process depends on.
952
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
×
953
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
×
954

955
    def _create_venv_script(
2✔
956
        self,
957
        bash: BashBinary,
958
        *,
959
        script_path: PurePath,
960
        venv_executable: PurePath,
961
    ) -> VenvScript:
962
        env_vars = (
×
963
            f"{name}={shlex.quote(value)}"
964
            for name, value in self.complete_pex_env.environment_dict(
965
                python_configured=True
966
            ).items()
967
        )
968

969
        target_venv_executable = shlex.quote(str(venv_executable))
×
970
        venv_dir = shlex.quote(str(self.venv_dir))
×
971
        execute_pex_args = " ".join(
×
972
            f"$(adjust_relative_paths {shlex.quote(arg)})"
973
            for arg in self.complete_pex_env.create_argv(self.pex.name, python=self.pex.python)
974
        )
975

976
        script = dedent(
×
977
            f"""\
978
            #!{bash.path}
979
            set -euo pipefail
980

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

987
            function adjust_relative_paths() {{
988
                local value0="$1"
989
                shift
990
                if [ "${{value0:0:1}}" == "/" ]; then
991
                    # Don't relativize absolute paths.
992
                    echo "${{value0}}" "$@"
993
                else
994
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
995
                    # this script works when run with a PWD set somewhere else than the sandbox
996
                    # root.
997
                    #
998
                    # There are two cases to consider. For the purposes of example, assume PWD is
999
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
1000
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
1001
                    #
1002
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
1003
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
1004
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
1005
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
1006
                    #    `../..`, we calculate `../../config/tool.yml`.
1007
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
1008
                fi
1009
            }}
1010

1011
            export {" ".join(env_vars)}
1012
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1013

1014
            execute_pex_args="{execute_pex_args}"
1015
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1016
            venv_dir="$(adjust_relative_paths {venv_dir})"
1017

1018
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1019
            # with tools support.
1020
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1021
              exec ${{execute_pex_args}} "$@"
1022
            fi
1023

1024
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1025
            # `--venv` mode PEX file.
1026
            if [ ! -e "${{venv_dir}}" ]; then
1027
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1028
            fi
1029

1030
            exec "${{target_venv_executable}}" "$@"
1031
            """
1032
        )
1033
        return VenvScript(
×
1034
            script=Script(script_path),
1035
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1036
        )
1037

1038
    def exe(self, bash: BashBinary) -> VenvScript:
2✔
1039
        """Writes a safe shim for the venv's executable `pex` script."""
1040
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
×
1041
        return self._create_venv_script(
×
1042
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1043
        )
1044

1045
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
2✔
1046
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1047
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
×
1048
        return self._create_venv_script(
×
1049
            bash,
1050
            script_path=script_path,
1051
            venv_executable=self.venv_dir / "bin" / name,
1052
        )
1053

1054
    def python(self, bash: BashBinary) -> VenvScript:
2✔
1055
        """Writes a safe shim for the venv's python binary."""
1056
        return self.bin(bash, "python")
×
1057

1058

1059
@dataclass(frozen=True)
2✔
1060
class VenvPex:
2✔
1061
    digest: Digest
2✔
1062
    append_only_caches: FrozenDict[str, str] | None
2✔
1063
    pex_filename: str
2✔
1064
    pex: Script
2✔
1065
    python: Script
2✔
1066
    bin: FrozenDict[str, Script]
2✔
1067
    venv_rel_dir: str
2✔
1068

1069

1070
@dataclass(frozen=True)
2✔
1071
class VenvPexRequest:
2✔
1072
    pex_request: PexRequest
2✔
1073
    complete_pex_env: CompletePexEnvironment
2✔
1074
    bin_names: tuple[str, ...] = ()
2✔
1075
    site_packages_copies: bool = False
2✔
1076

1077
    def __init__(
2✔
1078
        self,
1079
        pex_request: PexRequest,
1080
        complete_pex_env: CompletePexEnvironment,
1081
        bin_names: Iterable[str] = (),
1082
        site_packages_copies: bool = False,
1083
    ) -> None:
1084
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1085

1086
        :param pex_request: The details of the desired PEX.
1087
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1088
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1089
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1090
            dependencies when installing them in the venv site-packages directory. By default this
1091
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1092
            but results in a non-standard venv structure that does trip up some libraries.
1093
        """
1094
        object.__setattr__(self, "pex_request", pex_request)
×
1095
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
×
1096
        object.__setattr__(self, "bin_names", tuple(bin_names))
×
1097
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
×
1098

1099

1100
@rule
2✔
1101
async def wrap_venv_prex_request(
2✔
1102
    pex_request: PexRequest, pex_environment: PexEnvironment
1103
) -> VenvPexRequest:
1104
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1105
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
×
1106

1107

1108
@rule
2✔
1109
async def create_venv_pex(
2✔
1110
    request: VenvPexRequest, bash: BashBinary, pex_environment: PexEnvironment
1111
) -> VenvPex:
1112
    # VenvPex is motivated by improving performance of Python tools by eliminating traditional PEX
1113
    # file startup overhead.
1114
    #
1115
    # To achieve the minimal overhead (on the order of 1ms) we discard:
1116
    # 1. Using Pex default mode:
1117
    #    Although this does reduce initial tool execution overhead, it still leaves a minimum
1118
    #    O(100ms) of overhead per subsequent tool invocation. Fundamentally, Pex still needs to
1119
    #    execute its `sys.path` isolation bootstrap code in this case.
1120
    # 2. Using the Pex `venv` tool:
1121
    #    The idea here would be to create a tool venv as a Process output and then use the tool
1122
    #    venv as an input digest for all tool invocations. This was tried and netted ~500ms of
1123
    #    overhead over raw venv use.
1124
    #
1125
    # Instead we use Pex's `--venv` mode. In this mode you can run the Pex file and it will create a
1126
    # venv on the fly in the PEX_ROOT as needed. Since the PEX_ROOT is a named_cache, we avoid the
1127
    # digest materialization overhead present in 2 above. Since the venv is naturally isolated we
1128
    # avoid the `sys.path` isolation overhead of Pex itself present in 1 above.
1129
    #
1130
    # This does leave O(50ms) of overhead though for the PEX bootstrap code to detect an already
1131
    # created venv in the PEX_ROOT and re-exec into it. To eliminate this overhead we execute the
1132
    # `pex` venv script in the PEX_ROOT directly. This is not robust on its own though, since the
1133
    # named caches store might be pruned at any time. To guard against that case we introduce a shim
1134
    # bash script that checks to see if the `pex` venv script exists in the PEX_ROOT and re-creates
1135
    # the PEX_ROOT venv if not. Using the shim script to run Python tools gets us down to the ~1ms
1136
    # of overhead we currently enjoy.
1137

1138
    pex_request = request.pex_request
×
1139
    seeded_venv_request = dataclasses.replace(
×
1140
        pex_request,
1141
        additional_args=pex_request.additional_args
1142
        + (
1143
            "--venv",
1144
            "prepend",
1145
            "--seed",
1146
            "verbose",
1147
            pex_environment.venv_site_packages_copies_option(
1148
                use_copies=request.site_packages_copies
1149
            ),
1150
        ),
1151
    )
1152
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
×
1153
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1154
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1155
    # directory.
1156
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
×
1157
    abs_pex_root = PurePath(seed_info["pex_root"])
×
1158
    abs_pex_path = PurePath(seed_info["pex"])
×
1159
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
×
1160

1161
    venv_script_writer = VenvScriptWriter.create(
×
1162
        complete_pex_env=request.complete_pex_env,
1163
        pex=venv_pex_result.create_pex(),
1164
        venv_rel_dir=venv_rel_dir,
1165
    )
1166
    pex = venv_script_writer.exe(bash)
×
1167
    python = venv_script_writer.python(bash)
×
1168
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
×
1169
    scripts_digest = await create_digest(
×
1170
        CreateDigest(
1171
            (
1172
                pex.content,
1173
                python.content,
1174
                *(venv_script.content for venv_script in scripts.values()),
1175
            )
1176
        )
1177
    )
1178
    input_digest = await merge_digests(
×
1179
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1180
    )
1181
    append_only_caches = (
×
1182
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1183
    )
1184

1185
    return VenvPex(
×
1186
        digest=input_digest,
1187
        append_only_caches=append_only_caches,
1188
        pex_filename=venv_pex_result.pex_filename,
1189
        pex=pex.script,
1190
        python=python.script,
1191
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1192
        venv_rel_dir=venv_rel_dir.as_posix(),
1193
    )
1194

1195

1196
@dataclass(frozen=True)
2✔
1197
class PexProcess:
2✔
1198
    pex: Pex
2✔
1199
    argv: tuple[str, ...]
2✔
1200
    description: str = dataclasses.field(compare=False)
2✔
1201
    level: LogLevel
2✔
1202
    input_digest: Digest | None
2✔
1203
    working_directory: str | None
2✔
1204
    extra_env: FrozenDict[str, str]
2✔
1205
    output_files: tuple[str, ...] | None
2✔
1206
    output_directories: tuple[str, ...] | None
2✔
1207
    timeout_seconds: int | None
2✔
1208
    execution_slot_variable: str | None
2✔
1209
    concurrency_available: int
2✔
1210
    cache_scope: ProcessCacheScope
2✔
1211

1212
    def __init__(
2✔
1213
        self,
1214
        pex: Pex,
1215
        *,
1216
        description: str,
1217
        argv: Iterable[str] = (),
1218
        level: LogLevel = LogLevel.INFO,
1219
        input_digest: Digest | None = None,
1220
        working_directory: str | None = None,
1221
        extra_env: Mapping[str, str] | None = None,
1222
        output_files: Iterable[str] | None = None,
1223
        output_directories: Iterable[str] | None = None,
1224
        timeout_seconds: int | None = None,
1225
        execution_slot_variable: str | None = None,
1226
        concurrency_available: int = 0,
1227
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1228
    ) -> None:
UNCOV
1229
        object.__setattr__(self, "pex", pex)
×
UNCOV
1230
        object.__setattr__(self, "argv", tuple(argv))
×
UNCOV
1231
        object.__setattr__(self, "description", description)
×
UNCOV
1232
        object.__setattr__(self, "level", level)
×
UNCOV
1233
        object.__setattr__(self, "input_digest", input_digest)
×
UNCOV
1234
        object.__setattr__(self, "working_directory", working_directory)
×
UNCOV
1235
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
×
UNCOV
1236
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
×
UNCOV
1237
        object.__setattr__(
×
1238
            self, "output_directories", tuple(output_directories) if output_directories else None
1239
        )
UNCOV
1240
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
×
UNCOV
1241
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
×
UNCOV
1242
        object.__setattr__(self, "concurrency_available", concurrency_available)
×
UNCOV
1243
        object.__setattr__(self, "cache_scope", cache_scope)
×
1244

1245

1246
@rule
2✔
1247
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
2✔
1248
    pex = request.pex
×
1249
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
×
1250
    argv = complete_pex_env.create_argv(pex.name, *request.argv, python=pex.python)
×
1251
    env = {
×
1252
        **complete_pex_env.environment_dict(python_configured=pex.python is not None),
1253
        **request.extra_env,
1254
    }
1255
    input_digest = (
×
1256
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1257
        if request.input_digest
1258
        else pex.digest
1259
    )
1260
    append_only_caches = (
×
1261
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1262
    )
1263
    return Process(
×
1264
        argv,
1265
        description=request.description,
1266
        level=request.level,
1267
        input_digest=input_digest,
1268
        working_directory=request.working_directory,
1269
        env=env,
1270
        output_files=request.output_files,
1271
        output_directories=request.output_directories,
1272
        append_only_caches={
1273
            **complete_pex_env.append_only_caches,
1274
            **append_only_caches,
1275
        },
1276
        timeout_seconds=request.timeout_seconds,
1277
        execution_slot_variable=request.execution_slot_variable,
1278
        concurrency_available=request.concurrency_available,
1279
        cache_scope=request.cache_scope,
1280
    )
1281

1282

1283
@dataclass(unsafe_hash=True)
2✔
1284
class VenvPexProcess:
2✔
1285
    venv_pex: VenvPex
2✔
1286
    argv: tuple[str, ...]
2✔
1287
    description: str = dataclasses.field(compare=False)
2✔
1288
    level: LogLevel
2✔
1289
    input_digest: Digest | None
2✔
1290
    working_directory: str | None
2✔
1291
    extra_env: FrozenDict[str, str]
2✔
1292
    output_files: tuple[str, ...] | None
2✔
1293
    output_directories: tuple[str, ...] | None
2✔
1294
    timeout_seconds: int | None
2✔
1295
    execution_slot_variable: str | None
2✔
1296
    concurrency_available: int
2✔
1297
    cache_scope: ProcessCacheScope
2✔
1298
    append_only_caches: FrozenDict[str, str]
2✔
1299

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

1335

1336
@rule
2✔
1337
async def setup_venv_pex_process(
2✔
1338
    request: VenvPexProcess, pex_environment: PexEnvironment
1339
) -> Process:
1340
    venv_pex = request.venv_pex
×
1341
    pex_bin = (
×
1342
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1343
        if request.working_directory
1344
        else venv_pex.pex.argv0
1345
    )
1346
    argv = (pex_bin, *request.argv)
×
1347
    input_digest = (
×
1348
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1349
        if request.input_digest
1350
        else venv_pex.digest
1351
    )
1352
    append_only_caches: FrozenDict[str, str] = FrozenDict(
×
1353
        **pex_environment.in_sandbox(
1354
            working_directory=request.working_directory
1355
        ).append_only_caches,
1356
        **request.append_only_caches,
1357
        **(FrozenDict({}) if venv_pex.append_only_caches is None else venv_pex.append_only_caches),
1358
    )
1359
    return Process(
×
1360
        argv=argv,
1361
        description=request.description,
1362
        level=request.level,
1363
        input_digest=input_digest,
1364
        working_directory=request.working_directory,
1365
        env=request.extra_env,
1366
        output_files=request.output_files,
1367
        output_directories=request.output_directories,
1368
        append_only_caches=append_only_caches,
1369
        timeout_seconds=request.timeout_seconds,
1370
        execution_slot_variable=request.execution_slot_variable,
1371
        concurrency_available=request.concurrency_available,
1372
        cache_scope=request.cache_scope,
1373
    )
1374

1375

1376
@dataclass(frozen=True)
2✔
1377
class PexDistributionInfo:
2✔
1378
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1379
    repository info -v`."""
1380

1381
    project_name: str
2✔
1382
    version: packaging.version.Version
2✔
1383
    requires_python: packaging.specifiers.SpecifierSet | None
2✔
1384
    # Note: These are parsed from metadata written by the pex tool, and are always
1385
    #   a valid packaging.requirements.Requirement.
1386
    requires_dists: tuple[Requirement, ...]
2✔
1387

1388

1389
DefaultT = TypeVar("DefaultT")
2✔
1390

1391

1392
class PexResolveInfo(Collection[PexDistributionInfo]):
2✔
1393
    """Information about all distributions resolved in a PEX file, as reported by `PEX_TOOLS=1
1394
    repository info -v`."""
1395

1396
    def find(
2✔
1397
        self, name: str, default: DefaultT | None = None
1398
    ) -> PexDistributionInfo | DefaultT | None:
1399
        """Returns the PexDistributionInfo with the given name, first one wins."""
1400
        try:
×
1401
            return next(info for info in self if info.project_name == name)
×
1402
        except StopIteration:
×
1403
            return default
×
1404

1405

1406
def parse_repository_info(repository_info: str) -> PexResolveInfo:
2✔
1407
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
×
1408
        for line in repository_info.splitlines():
×
1409
            info = json.loads(line)
×
1410
            requires_python = info["requires_python"]
×
1411
            yield PexDistributionInfo(
×
1412
                project_name=info["project_name"],
1413
                version=packaging.version.Version(info["version"]),
1414
                requires_python=(
1415
                    packaging.specifiers.SpecifierSet(requires_python)
1416
                    if requires_python is not None
1417
                    else None
1418
                ),
1419
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1420
            )
1421

1422
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
×
1423

1424

1425
@rule
2✔
1426
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
2✔
1427
    process_result = await fallible_to_exec_result_or_raise(
×
1428
        **implicitly(
1429
            VenvPexProcess(
1430
                venv_pex,
1431
                argv=["repository", "info", "-v"],
1432
                extra_env={"PEX_TOOLS": "1"},
1433
                input_digest=venv_pex.digest,
1434
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1435
                level=LogLevel.DEBUG,
1436
            )
1437
        )
1438
    )
1439
    return parse_repository_info(process_result.stdout.decode())
×
1440

1441

1442
@rule
2✔
1443
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
2✔
1444
    process_result = await fallible_to_exec_result_or_raise(
×
1445
        **implicitly(
1446
            PexProcess(
1447
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1448
                argv=[pex.name, "repository", "info", "-v"],
1449
                input_digest=pex.digest,
1450
                extra_env={"PEX_MODULE": "pex.tools"},
1451
                description=f"Determine distributions found in {pex.name}",
1452
                level=LogLevel.DEBUG,
1453
            )
1454
        )
1455
    )
1456
    return parse_repository_info(process_result.stdout.decode())
×
1457

1458

1459
def rules():
2✔
1460
    return [*collect_rules(), *pex_cli.rules(), *pex_requirements.rules(), *stripped_source_rules()]
2✔
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