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

pantsbuild / pants / 23710064453

29 Mar 2026 01:25PM UTC coverage: 52.396% (-40.5%) from 92.917%
23710064453

Pull #23200

github

web-flow
Merge ca376c905 into da60c6486
Pull Request #23200: perf: Port FrozenOrderedSet to rust

18 of 26 new or added lines in 6 files covered. (69.23%)

23006 existing lines in 605 files now uncovered.

31632 of 60371 relevant lines covered (52.4%)

0.52 hits per line

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

87.48
/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
1✔
5

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

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

22
from pants.backend.python.subsystems.setup import PythonSetup
1✔
23
from pants.backend.python.target_types import (
1✔
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
1✔
32
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
1✔
33
from pants.backend.python.util_rules.pex_cli import PexCliProcess, PexPEX, maybe_log_pex_stderr
1✔
34
from pants.backend.python.util_rules.pex_environment import (
1✔
35
    CompletePexEnvironment,
36
    PexEnvironment,
37
    PexSubsystem,
38
    PythonExecutable,
39
)
40
from pants.backend.python.util_rules.pex_requirements import (
1✔
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 (
1✔
53
    PexRequirements as PexRequirements,  # Explicit re-export.
54
)
55
from pants.build_graph.address import Address
1✔
56
from pants.core.environments.target_types import EnvironmentTarget
1✔
57
from pants.core.target_types import FileSourceField, ResourceSourceField
1✔
58
from pants.core.util_rules.stripped_source_files import StrippedFileNameRequest, strip_file_name
1✔
59
from pants.core.util_rules.stripped_source_files import rules as stripped_source_rules
1✔
60
from pants.core.util_rules.system_binaries import BashBinary
1✔
61
from pants.engine.addresses import UnparsedAddressInputs
1✔
62
from pants.engine.collection import Collection, DeduplicatedCollection
1✔
63
from pants.engine.engine_aware import EngineAwareParameter
1✔
64
from pants.engine.environment import EnvironmentName
1✔
65
from pants.engine.fs import (
1✔
66
    EMPTY_DIGEST,
67
    AddPrefix,
68
    CreateDigest,
69
    Digest,
70
    FileContent,
71
    MergeDigests,
72
    RemovePrefix,
73
)
74
from pants.engine.internals.graph import (
1✔
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
1✔
80
from pants.engine.internals.native_engine import Snapshot
1✔
81
from pants.engine.intrinsics import (
1✔
82
    add_prefix,
83
    create_digest,
84
    digest_to_snapshot,
85
    merge_digests,
86
    remove_prefix,
87
)
88
from pants.engine.process import (
1✔
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
1✔
95
from pants.engine.target import HydrateSourcesRequest, SourcesField, TransitiveTargetsRequest
1✔
96
from pants.engine.unions import UnionMembership, union
1✔
97
from pants.util.frozendict import FrozenDict
1✔
98
from pants.util.logging import LogLevel
1✔
99
from pants.util.strutil import bullet_list, pluralize, softwrap
1✔
100

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

103

104
@union(in_scope_types=[EnvironmentName])
1✔
105
@dataclass(frozen=True)
1✔
106
class PythonProvider:
1✔
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
1✔
113

114

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

121

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

125
    def generate_pex_arg_list(self) -> list[str]:
1✔
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]):
1✔
133
    sort_input = True
1✔
134

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

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

142
    @property
1✔
143
    def digest(self) -> Digest:
1✔
144
        return self._digest
1✔
145

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

151

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

176

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

183

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

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

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

284
        self.__post_init__()
1✔
285

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

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

338

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

343

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

348
    digest: Digest
1✔
349
    name: str
1✔
350
    python: PythonExecutable | None
1✔
351

352

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

357

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

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

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

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

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

427
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
1✔
428

429
    return PythonExecutable(path=path, fingerprint=fingerprint)
1✔
430

431

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

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

442

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

448

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

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

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

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

493

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

500

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

506

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

553

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

574

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

593
    pex_lock_resolver_args = list(resolve_config.pex_args())
1✔
594
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
1✔
595

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

625
        return _BuildPexRequirementsSetup(
1✔
626
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
627
        )
628

629
    assert isinstance(request.requirements, PexRequirements)
1✔
630
    reqs_info = await get_req_strings(request.requirements)
1✔
631

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

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

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

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

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

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

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

701

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

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

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

730
    source_dir_name = "source_files"
1✔
731

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

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

772
    argv = [
1✔
773
        "--output-file",
774
        output_file,
775
        *request.additional_args,
776
    ]
777

778
    argv.extend(pex_python_setup.argv)
1✔
779

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

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

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

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

808
    argv.append(f"--sources-directory={source_dir_name}")
1✔
809

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

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

825
    argv.extend(["--layout", request.layout.value])
1✔
826

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

842
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
1✔
843

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

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

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

864

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

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

907

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

913

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

921

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

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

930

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

936

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1057

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

1068

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

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

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

1098

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

1106

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

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

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

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

1194

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

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

1244

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

1281

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

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

1334

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

1374

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

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

1387

1388
DefaultT = TypeVar("DefaultT")
1✔
1389

1390

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

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

1404

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

1421
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
1✔
1422

1423

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

1440

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

1457

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