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

pantsbuild / pants / 26260209689

21 May 2026 11:59PM UTC coverage: 75.453% (-15.7%) from 91.156%
26260209689

Pull #23365

github

web-flow
Merge 5fe873b58 into 7ea655ba0
Pull Request #23365: uv.lock -> pex optimization

5 of 16 new or added lines in 1 file covered. (31.25%)

10118 existing lines in 378 files now uncovered.

54669 of 72454 relevant lines covered (75.45%)

2.31 hits per line

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

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

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

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

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

116
logger = logging.getLogger(__name__)
5✔
117

118

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

124
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
125
    """
126

127
    interpreter_constraints: InterpreterConstraints
5✔
128

129

130
@rule(polymorphic=True)
5✔
131
async def get_python_executable(
5✔
132
    provider: PythonProvider, env_name: EnvironmentName
133
) -> PythonExecutable:
UNCOV
134
    raise NotImplementedError()
×
135

136

137
class PexPlatforms(DeduplicatedCollection[str]):
5✔
138
    sort_input = True
5✔
139

140
    def generate_pex_arg_list(self) -> list[str]:
5✔
141
        args = []
1✔
142
        for platform in self:
1✔
143
            args.extend(["--platform", platform])
1✔
144
        return args
1✔
145

146

147
class CompletePlatforms(DeduplicatedCollection[str]):
5✔
148
    sort_input = True
5✔
149

150
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
5✔
151
        super().__init__(iterable)
5✔
152
        self._digest = digest
5✔
153

154
    @classmethod
5✔
155
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
5✔
156
        return cls(snapshot.files, digest=snapshot.digest)
3✔
157

158
    @property
5✔
159
    def digest(self) -> Digest:
5✔
160
        return self._digest
5✔
161

162
    def generate_pex_arg_list(self) -> Iterator[str]:
5✔
163
        for path in self:
1✔
164
            yield "--complete-platform"
1✔
165
            yield path
1✔
166

167

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

192

193
@rule
5✔
194
async def digest_complete_platforms(
5✔
195
    complete_platforms: PexCompletePlatformsField,
196
) -> CompletePlatforms:
197
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
3✔
198

199

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

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

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

300
        self.__post_init__()
5✔
301

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

351
    def debug_hint(self) -> str:
5✔
UNCOV
352
        return self.output_filename
×
353

354

355
@dataclass(frozen=True)
5✔
356
class OptionalPexRequest:
5✔
357
    maybe_pex_request: PexRequest | None
5✔
358

359

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

364
    digest: Digest
5✔
365
    name: str
5✔
366
    python: PythonExecutable | None
5✔
367

368

369
@dataclass(frozen=True)
5✔
370
class OptionalPex:
5✔
371
    maybe_pex: Pex | None
5✔
372

373

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

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

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

425
                    python = os.path.realpath(sys.executable)
426
                    print(python)
427

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

443
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
5✔
444

445
    return PythonExecutable(path=path, fingerprint=fingerprint)
5✔
446

447

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

455
    def create_pex(self) -> Pex:
5✔
456
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
5✔
457

458

459
@dataclass
5✔
460
class _BuildPexPythonSetup:
5✔
461
    python: PythonExecutable | None
5✔
462
    argv: list[str]
5✔
463

464

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

482
    if request.python:
5✔
483
        python = request.python
1✔
484
    else:
485
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
5✔
486

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

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

509

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

519

520
@dataclass(frozen=True)
5✔
521
class PexRequirementsInfo:
5✔
522
    req_strings: tuple[str, ...]
5✔
523
    find_links: tuple[str, ...]
5✔
524

525

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

572

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

593

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

612
    pex_lock_resolver_args = list(resolve_config.pex_args())
5✔
613
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
5✔
614

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

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

652
        return _BuildPexRequirementsSetup(
5✔
653
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
654
        )
655

656
    assert isinstance(request.requirements, PexRequirements)
5✔
657
    reqs_info = await get_req_strings(request.requirements)
5✔
658

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

664
    if isinstance(request.requirements.from_superset, Pex):
5✔
UNCOV
665
        repository_pex = request.requirements.from_superset
×
UNCOV
666
        return _BuildPexRequirementsSetup(
×
667
            [repository_pex.digest],
668
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
669
            concurrency_available,
670
        )
671

672
    elif isinstance(request.requirements.from_superset, Resolve):
5✔
UNCOV
673
        lockfile = await get_lockfile_for_resolve(
×
674
            request.requirements.from_superset, **implicitly()
675
        )
UNCOV
676
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
677

UNCOV
678
        if not reqs_info.req_strings:
×
UNCOV
679
            return _BuildPexRequirementsSetup([], [], concurrency_available)
×
680

UNCOV
681
        if loaded_lockfile.lockfile_format == LockfileFormat.UV:
×
UNCOV
682
            return _BuildPexRequirementsSetup(
×
683
                [], [], concurrency_available, uv_lockfile=loaded_lockfile
684
            )
685

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

UNCOV
702
        return _BuildPexRequirementsSetup(
×
703
            [loaded_lockfile.lockfile_digest],
704
            [
705
                *reqs_info.req_strings,
706
                "--lock",
707
                loaded_lockfile.lockfile_path,
708
                *pex_lock_resolver_args,
709
            ],
710
            concurrency_available,
711
        )
712

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

731

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

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

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

763
    source_dir_name = "source_files"
5✔
764

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

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

804
        if req_strings:
×
805
            # PexRequirements case: export only the needed subset via find-links, avoiding
806
            # materializing the full resolve into a venv (which can exhaust disk on cold CI).
UNCOV
807
            find_links_digest = await create_find_links_from_uv_lockfile(
×
808
                FindLinksFromUvLockfileRequest(
809
                    lockfile=requirements_setup.uv_lockfile,
810
                    req_strings=req_strings,
811
                ),
812
                **implicitly(),
813
            )
UNCOV
814
            requirements_setup = dataclasses.replace(
×
815
                requirements_setup,
816
                digests=[*requirements_setup.digests, find_links_digest],
817
                argv=[
818
                    *req_strings,
819
                    "--no-transitive",
820
                    "--no-index",
821
                    "--find-links=find_links.html",
822
                    "--pre",
823
                ],
824
            )
825
        else:
826
            # EntireLockfile case: materialize the full resolve into a shared venv.
UNCOV
827
            if pex_python_setup.python is None:
×
828
                # Should never happen.
UNCOV
829
                raise ValueError(
×
830
                    softwrap(
831
                        f"""
832
                        Cannot build a pex from a uv lockfile for {request.output_filename} with no
833
                        local python specified. Please report this error to the Pants team.
834
                        """
835
                    )
836
                )
UNCOV
837
            venv_repo = await create_venv_repository_from_uv_lockfile(
×
838
                VenvFromUvLockfileRequest(
839
                    lockfile=requirements_setup.uv_lockfile,
840
                    python=pex_python_setup.python,
841
                ),
842
                **implicitly(),
843
            )
UNCOV
844
            requirements_setup = dataclasses.replace(
×
845
                requirements_setup,
846
                argv=[
847
                    "--no-transitive",
848
                    f"--venv-repository={venv_repo.relpath()}",
849
                    "--pre",
850
                ],
851
            )
852

853
    output_chroot = os.path.dirname(request.output_filename)
5✔
854
    if output_chroot:
5✔
855
        output_file = request.output_filename
2✔
856
        strip_output_chroot = False
2✔
857
    else:
858
        # In principle a cache should always be just a cache, but existing
859
        # tests in this repo make the assumption that they can look into a
860
        # still intact cache and see the same thing as was there before, which
861
        # requires this to be deterministic and not random.  adler32, because
862
        # it is in the stlib, fast, and doesn't need to be cryptographic.
863
        output_chroot = f"pex-dist-{zlib.adler32(request.output_filename.encode()):08x}"
5✔
864
        strip_output_chroot = True
5✔
865
        output_file = os.path.join(output_chroot, request.output_filename)
5✔
866

867
    argv = [
5✔
868
        "--output-file",
869
        output_file,
870
        *request.additional_args,
871
    ]
872

873
    if venv_repo is None:
5✔
874
        argv.extend(pex_python_setup.argv)
5✔
875
        interpreter = None
5✔
876
    else:
877
        # When using --venv-repository the interpreter is fixed to the venv's Python;
878
        # PEX does not accept --python / --interpreter-constraint in this case.
879
        # TODO: This type name is misleading here: this isn't a PBS (or at least not one we
880
        #  downloaded as such), but PythonBuildStandaloneBinary is just a wrapper for
881
        #  a path to an interpreter, so we use it as such.  But we should probably rename the type.
UNCOV
882
        interpreter = PythonBuildStandaloneBinary(
×
883
            os.path.join(venv_repo.relpath(), "bin", "python")
884
        )
885

886
    if request.main is not None:
5✔
887
        argv.extend(request.main.iter_pex_args())
5✔
888
        if isinstance(request.main, Executable):
5✔
889
            # Unlike other MainSpecification types (that can pass spec as-is to pex),
890
            # Executable must be an actual path relative to the sandbox.
891
            # request.main.spec is a python source file including its spec_path.
892
            # To make it relative to the sandbox, we strip the source root
893
            # and add the source_dir_name (sources get prefixed with that below).
894
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
1✔
895
            argv.append(os.path.join(source_dir_name, stripped.value))
1✔
896

897
    argv.extend(
5✔
898
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
899
    )
900
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
5✔
901

902
    # TODO(John Sirois): Right now any request requirements will shadow corresponding pex path
903
    #  requirements, which could lead to problems. Support shading python binaries.
904
    #  See: https://github.com/pantsbuild/pants/issues/9206
905
    if request.pex_path:
5✔
906
        argv.extend(["--pex-path", ":".join(pex.name for pex in request.pex_path)])
4✔
907

908
    if request.internal_only:
5✔
909
        # An internal-only runs on a single machine, and pre-installing wheels is wasted work in
910
        # that case (see https://github.com/pex-tool/pex/issues/2292#issuecomment-1854582647 for
911
        # analysis).
912
        argv.append("--no-pre-install-wheels")
5✔
913

914
    argv.append(f"--sources-directory={source_dir_name}")
5✔
915

916
    # Include any additional arguments and input digests required by the requirements.
917
    argv.extend(requirements_setup.argv)
5✔
918

919
    merged_digest = await merge_digests(
5✔
920
        MergeDigests(
921
            (
922
                request.complete_platforms.digest,
923
                sources_digest_as_subdir,
924
                request.additional_inputs,
925
                *requirements_setup.digests,
926
                *(pex.digest for pex in request.pex_path),
927
            )
928
        )
929
    )
930

931
    argv.extend(["--layout", request.layout.value])
5✔
932

933
    result = await fallible_to_exec_result_or_raise(
5✔
934
        **implicitly(
935
            PexCliProcess(
936
                interpreter=interpreter,
937
                subcommand=(),
938
                extra_args=argv,
939
                additional_input_digest=merged_digest,
940
                description=_build_pex_description(request, req_strings, python_setup.resolves),
941
                append_only_caches=venv_repo.append_only_caches() if venv_repo else None,
942
                output_files=None,
943
                output_directories=[output_chroot],
944
                concurrency_available=requirements_setup.concurrency_available,
945
                cache_scope=request.cache_scope,
946
            )
947
        )
948
    )
949

950
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
5✔
951

952
    if strip_output_chroot:
5✔
953
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
5✔
954
    else:
955
        output_digest = result.output_digest
2✔
956

957
    digest = (
5✔
958
        await merge_digests(
959
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
960
        )
961
        if request.pex_path
962
        else output_digest
963
    )
964

965
    return BuildPexResult(
5✔
966
        result=result,
967
        pex_filename=request.output_filename,
968
        digest=digest,
969
        python=pex_python_setup.python,
970
    )
971

972

973
def _build_pex_description(
5✔
974
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
975
) -> str:
976
    if request.description:
5✔
977
        return request.description
1✔
978

979
    if isinstance(request.requirements, EntireLockfile):
5✔
980
        lockfile = request.requirements.lockfile
5✔
981
        desc_suffix = f"from {lockfile.url}"
5✔
982
    else:
983
        if not req_strings:
5✔
984
            return f"Building {request.output_filename}"
5✔
985
        elif isinstance(request.requirements.from_superset, Pex):
2✔
UNCOV
986
            repo_pex = request.requirements.from_superset.name
×
UNCOV
987
            return softwrap(
×
988
                f"""
989
                Extracting {pluralize(len(req_strings), "requirement")}
990
                to build {request.output_filename} from {repo_pex}:
991
                {", ".join(req_strings)}
992
                """
993
            )
994
        elif isinstance(request.requirements.from_superset, Resolve):
2✔
995
            # At this point we know this is a valid user resolve, so we can assume
996
            # it's available in the dict. Nonetheless we use get() so that any weird error
997
            # here gives a bad message rather than an outright crash.
998
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
1✔
999
            return softwrap(
1✔
1000
                f"""
1001
                Building {pluralize(len(req_strings), "requirement")}
1002
                for {request.output_filename} from the {lockfile_path} resolve:
1003
                {", ".join(req_strings)}
1004
                """
1005
            )
1006
        else:
1007
            desc_suffix = softwrap(
2✔
1008
                f"""
1009
                with {pluralize(len(req_strings), "requirement")}:
1010
                {", ".join(req_strings)}
1011
                """
1012
            )
1013
    return f"Building {request.output_filename} {desc_suffix}"
5✔
1014

1015

1016
@rule
5✔
1017
async def create_pex(request: PexRequest) -> Pex:
5✔
1018
    result = await build_pex(request, **implicitly())
5✔
1019
    return result.create_pex()
5✔
1020

1021

1022
@rule
5✔
1023
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
5✔
1024
    if request.maybe_pex_request is None:
5✔
1025
        return OptionalPex(None)
5✔
UNCOV
1026
    result = await create_pex(request.maybe_pex_request)
×
UNCOV
1027
    return OptionalPex(result)
×
1028

1029

1030
@dataclass(frozen=True)
5✔
1031
class Script:
5✔
1032
    path: PurePath
5✔
1033

1034
    @property
5✔
1035
    def argv0(self) -> str:
5✔
1036
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
5✔
1037

1038

1039
@dataclass(frozen=True)
5✔
1040
class VenvScript:
5✔
1041
    script: Script
5✔
1042
    content: FileContent
5✔
1043

1044

1045
@dataclass(frozen=True)
5✔
1046
class VenvScriptWriter:
5✔
1047
    complete_pex_env: CompletePexEnvironment
5✔
1048
    pex: Pex
5✔
1049
    venv_dir: PurePath
5✔
1050

1051
    @classmethod
5✔
1052
    def create(
5✔
1053
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
1054
    ) -> VenvScriptWriter:
1055
        # N.B.: We don't know the working directory that will be used in any given
1056
        # invocation of the venv scripts; so we deal with working_directory once in an
1057
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
1058
        # CWD offset math in every rule for all the relative paths their process depends on.
1059
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
5✔
1060
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
5✔
1061

1062
    def _create_venv_script(
5✔
1063
        self,
1064
        bash: BashBinary,
1065
        *,
1066
        script_path: PurePath,
1067
        venv_executable: PurePath,
1068
    ) -> VenvScript:
1069
        env_vars = (
5✔
1070
            f"{name}={shlex.quote(value)}"
1071
            for name, value in self.complete_pex_env.environment_dict(
1072
                python_configured=True
1073
            ).items()
1074
        )
1075

1076
        target_venv_executable = shlex.quote(str(venv_executable))
5✔
1077
        venv_dir = shlex.quote(str(self.venv_dir))
5✔
1078
        execute_pex_args = " ".join(
5✔
1079
            f"$(adjust_relative_paths {shlex.quote(arg)})"
1080
            for arg in self.complete_pex_env.create_argv(self.pex.name, python=self.pex.python)
1081
        )
1082

1083
        script = dedent(
5✔
1084
            f"""\
1085
            #!{bash.path}
1086
            set -euo pipefail
1087

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

1094
            function adjust_relative_paths() {{
1095
                local value0="$1"
1096
                shift
1097
                if [ "${{value0:0:1}}" == "/" ]; then
1098
                    # Don't relativize absolute paths.
1099
                    echo "${{value0}}" "$@"
1100
                else
1101
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
1102
                    # this script works when run with a PWD set somewhere else than the sandbox
1103
                    # root.
1104
                    #
1105
                    # There are two cases to consider. For the purposes of example, assume PWD is
1106
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
1107
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
1108
                    #
1109
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
1110
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
1111
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
1112
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
1113
                    #    `../..`, we calculate `../../config/tool.yml`.
1114
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
1115
                fi
1116
            }}
1117

1118
            export {" ".join(env_vars)}
1119
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1120

1121
            execute_pex_args="{execute_pex_args}"
1122
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1123
            venv_dir="$(adjust_relative_paths {venv_dir})"
1124

1125
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1126
            # with tools support.
1127
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1128
              exec ${{execute_pex_args}} "$@"
1129
            fi
1130

1131
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1132
            # `--venv` mode PEX file.
1133
            if [ ! -e "${{venv_dir}}" ]; then
1134
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1135
            fi
1136

1137
            exec "${{target_venv_executable}}" "$@"
1138
            """
1139
        )
1140
        return VenvScript(
5✔
1141
            script=Script(script_path),
1142
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1143
        )
1144

1145
    def exe(self, bash: BashBinary) -> VenvScript:
5✔
1146
        """Writes a safe shim for the venv's executable `pex` script."""
1147
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
5✔
1148
        return self._create_venv_script(
5✔
1149
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1150
        )
1151

1152
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
5✔
1153
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1154
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
5✔
1155
        return self._create_venv_script(
5✔
1156
            bash,
1157
            script_path=script_path,
1158
            venv_executable=self.venv_dir / "bin" / name,
1159
        )
1160

1161
    def python(self, bash: BashBinary) -> VenvScript:
5✔
1162
        """Writes a safe shim for the venv's python binary."""
1163
        return self.bin(bash, "python")
5✔
1164

1165

1166
@dataclass(frozen=True)
5✔
1167
class VenvPex:
5✔
1168
    digest: Digest
5✔
1169
    append_only_caches: FrozenDict[str, str] | None
5✔
1170
    pex_filename: str
5✔
1171
    pex: Script
5✔
1172
    python: Script
5✔
1173
    bin: FrozenDict[str, Script]
5✔
1174
    venv_rel_dir: str
5✔
1175

1176

1177
@dataclass(frozen=True)
5✔
1178
class VenvPexRequest:
5✔
1179
    pex_request: PexRequest
5✔
1180
    complete_pex_env: CompletePexEnvironment
5✔
1181
    bin_names: tuple[str, ...] = ()
5✔
1182
    site_packages_copies: bool = False
5✔
1183

1184
    def __init__(
5✔
1185
        self,
1186
        pex_request: PexRequest,
1187
        complete_pex_env: CompletePexEnvironment,
1188
        bin_names: Iterable[str] = (),
1189
        site_packages_copies: bool = False,
1190
    ) -> None:
1191
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1192

1193
        :param pex_request: The details of the desired PEX.
1194
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1195
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1196
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1197
            dependencies when installing them in the venv site-packages directory. By default this
1198
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1199
            but results in a non-standard venv structure that does trip up some libraries.
1200
        """
1201
        object.__setattr__(self, "pex_request", pex_request)
5✔
1202
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
5✔
1203
        object.__setattr__(self, "bin_names", tuple(bin_names))
5✔
1204
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
5✔
1205

1206

1207
@rule
5✔
1208
async def wrap_venv_prex_request(
5✔
1209
    pex_request: PexRequest, pex_environment: PexEnvironment
1210
) -> VenvPexRequest:
1211
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1212
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
5✔
1213

1214

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

1245
    pex_request = request.pex_request
5✔
1246
    seeded_venv_request = dataclasses.replace(
5✔
1247
        pex_request,
1248
        additional_args=pex_request.additional_args
1249
        + (
1250
            "--venv",
1251
            "prepend",
1252
            "--seed",
1253
            "verbose",
1254
            pex_environment.venv_site_packages_copies_option(
1255
                use_copies=request.site_packages_copies
1256
            ),
1257
        ),
1258
    )
1259
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
5✔
1260
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1261
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1262
    # directory.
1263
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
5✔
1264
    abs_pex_root = PurePath(seed_info["pex_root"])
5✔
1265
    abs_pex_path = PurePath(seed_info["pex"])
5✔
1266
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
5✔
1267

1268
    venv_script_writer = VenvScriptWriter.create(
5✔
1269
        complete_pex_env=request.complete_pex_env,
1270
        pex=venv_pex_result.create_pex(),
1271
        venv_rel_dir=venv_rel_dir,
1272
    )
1273
    pex = venv_script_writer.exe(bash)
5✔
1274
    python = venv_script_writer.python(bash)
5✔
1275
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
5✔
1276
    scripts_digest = await create_digest(
5✔
1277
        CreateDigest(
1278
            (
1279
                pex.content,
1280
                python.content,
1281
                *(venv_script.content for venv_script in scripts.values()),
1282
            )
1283
        )
1284
    )
1285
    input_digest = await merge_digests(
5✔
1286
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1287
    )
1288
    append_only_caches = (
5✔
1289
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1290
    )
1291

1292
    return VenvPex(
5✔
1293
        digest=input_digest,
1294
        append_only_caches=append_only_caches,
1295
        pex_filename=venv_pex_result.pex_filename,
1296
        pex=pex.script,
1297
        python=python.script,
1298
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1299
        venv_rel_dir=venv_rel_dir.as_posix(),
1300
    )
1301

1302

1303
@dataclass(frozen=True)
5✔
1304
class PexProcess:
5✔
1305
    pex: Pex
5✔
1306
    argv: tuple[str, ...]
5✔
1307
    description: str = dataclasses.field(compare=False)
5✔
1308
    level: LogLevel
5✔
1309
    input_digest: Digest | None
5✔
1310
    working_directory: str | None
5✔
1311
    extra_env: FrozenDict[str, str]
5✔
1312
    output_files: tuple[str, ...] | None
5✔
1313
    output_directories: tuple[str, ...] | None
5✔
1314
    timeout_seconds: int | None
5✔
1315
    execution_slot_variable: str | None
5✔
1316
    concurrency_available: int
5✔
1317
    cache_scope: ProcessCacheScope
5✔
1318

1319
    def __init__(
5✔
1320
        self,
1321
        pex: Pex,
1322
        *,
1323
        description: str,
1324
        argv: Iterable[str] = (),
1325
        level: LogLevel = LogLevel.INFO,
1326
        input_digest: Digest | None = None,
1327
        working_directory: str | None = None,
1328
        extra_env: Mapping[str, str] | None = None,
1329
        output_files: Iterable[str] | None = None,
1330
        output_directories: Iterable[str] | None = None,
1331
        timeout_seconds: int | None = None,
1332
        execution_slot_variable: str | None = None,
1333
        concurrency_available: int = 0,
1334
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1335
    ) -> None:
1336
        object.__setattr__(self, "pex", pex)
4✔
1337
        object.__setattr__(self, "argv", tuple(argv))
4✔
1338
        object.__setattr__(self, "description", description)
4✔
1339
        object.__setattr__(self, "level", level)
4✔
1340
        object.__setattr__(self, "input_digest", input_digest)
4✔
1341
        object.__setattr__(self, "working_directory", working_directory)
4✔
1342
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
4✔
1343
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
4✔
1344
        object.__setattr__(
4✔
1345
            self, "output_directories", tuple(output_directories) if output_directories else None
1346
        )
1347
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
4✔
1348
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
4✔
1349
        object.__setattr__(self, "concurrency_available", concurrency_available)
4✔
1350
        object.__setattr__(self, "cache_scope", cache_scope)
4✔
1351

1352

1353
@rule
5✔
1354
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
5✔
1355
    pex = request.pex
4✔
1356
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
4✔
1357
    argv = complete_pex_env.create_argv(pex.name, *request.argv, python=pex.python)
4✔
1358
    env = {
4✔
1359
        **complete_pex_env.environment_dict(python_configured=pex.python is not None),
1360
        **request.extra_env,
1361
    }
1362
    input_digest = (
4✔
1363
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1364
        if request.input_digest
1365
        else pex.digest
1366
    )
1367
    append_only_caches = (
4✔
1368
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1369
    )
1370
    return Process(
4✔
1371
        argv,
1372
        description=request.description,
1373
        level=request.level,
1374
        input_digest=input_digest,
1375
        working_directory=request.working_directory,
1376
        env=env,
1377
        output_files=request.output_files,
1378
        output_directories=request.output_directories,
1379
        append_only_caches={
1380
            **complete_pex_env.append_only_caches,
1381
            **append_only_caches,
1382
        },
1383
        timeout_seconds=request.timeout_seconds,
1384
        execution_slot_variable=request.execution_slot_variable,
1385
        concurrency_available=request.concurrency_available,
1386
        cache_scope=request.cache_scope,
1387
    )
1388

1389

1390
@dataclass(unsafe_hash=True)
5✔
1391
class VenvPexProcess:
5✔
1392
    venv_pex: VenvPex
5✔
1393
    argv: tuple[str, ...]
5✔
1394
    description: str = dataclasses.field(compare=False)
5✔
1395
    level: LogLevel
5✔
1396
    input_digest: Digest | None
5✔
1397
    working_directory: str | None
5✔
1398
    extra_env: FrozenDict[str, str]
5✔
1399
    output_files: tuple[str, ...] | None
5✔
1400
    output_directories: tuple[str, ...] | None
5✔
1401
    timeout_seconds: int | None
5✔
1402
    execution_slot_variable: str | None
5✔
1403
    concurrency_available: int
5✔
1404
    cache_scope: ProcessCacheScope
5✔
1405
    append_only_caches: FrozenDict[str, str]
5✔
1406

1407
    def __init__(
5✔
1408
        self,
1409
        venv_pex: VenvPex,
1410
        *,
1411
        description: str,
1412
        argv: Iterable[str] = (),
1413
        level: LogLevel = LogLevel.INFO,
1414
        input_digest: Digest | None = None,
1415
        working_directory: str | None = None,
1416
        extra_env: Mapping[str, str] | None = None,
1417
        output_files: Iterable[str] | None = None,
1418
        output_directories: Iterable[str] | None = None,
1419
        timeout_seconds: int | None = None,
1420
        execution_slot_variable: str | None = None,
1421
        concurrency_available: int = 0,
1422
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1423
        append_only_caches: Mapping[str, str] | None = None,
1424
    ) -> None:
1425
        object.__setattr__(self, "venv_pex", venv_pex)
5✔
1426
        object.__setattr__(self, "argv", tuple(argv))
5✔
1427
        object.__setattr__(self, "description", description)
5✔
1428
        object.__setattr__(self, "level", level)
5✔
1429
        object.__setattr__(self, "input_digest", input_digest)
5✔
1430
        object.__setattr__(self, "working_directory", working_directory)
5✔
1431
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
5✔
1432
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
5✔
1433
        object.__setattr__(
5✔
1434
            self, "output_directories", tuple(output_directories) if output_directories else None
1435
        )
1436
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
5✔
1437
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
5✔
1438
        object.__setattr__(self, "concurrency_available", concurrency_available)
5✔
1439
        object.__setattr__(self, "cache_scope", cache_scope)
5✔
1440
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
5✔
1441

1442

1443
@rule
5✔
1444
async def setup_venv_pex_process(
5✔
1445
    request: VenvPexProcess, pex_environment: PexEnvironment
1446
) -> Process:
1447
    venv_pex = request.venv_pex
5✔
1448
    pex_bin = (
5✔
1449
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1450
        if request.working_directory
1451
        else venv_pex.pex.argv0
1452
    )
1453
    argv = (pex_bin, *request.argv)
5✔
1454
    input_digest = (
5✔
1455
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1456
        if request.input_digest
1457
        else venv_pex.digest
1458
    )
1459
    append_only_caches: FrozenDict[str, str] = FrozenDict(
5✔
1460
        **pex_environment.in_sandbox(
1461
            working_directory=request.working_directory
1462
        ).append_only_caches,
1463
        **(request.append_only_caches or FrozenDict({})),
1464
        **(venv_pex.append_only_caches or FrozenDict({})),
1465
    )
1466
    return Process(
5✔
1467
        argv=argv,
1468
        description=request.description,
1469
        level=request.level,
1470
        input_digest=input_digest,
1471
        working_directory=request.working_directory,
1472
        env=request.extra_env,
1473
        output_files=request.output_files,
1474
        output_directories=request.output_directories,
1475
        append_only_caches=append_only_caches,
1476
        timeout_seconds=request.timeout_seconds,
1477
        execution_slot_variable=request.execution_slot_variable,
1478
        concurrency_available=request.concurrency_available,
1479
        cache_scope=request.cache_scope,
1480
    )
1481

1482

1483
@dataclass(frozen=True)
5✔
1484
class PexDistributionInfo:
5✔
1485
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1486
    repository info -v`."""
1487

1488
    project_name: str
5✔
1489
    version: packaging.version.Version
5✔
1490
    requires_python: packaging.specifiers.SpecifierSet | None
5✔
1491
    # Note: These are parsed from metadata written by the pex tool, and are always
1492
    #   a valid packaging.requirements.Requirement.
1493
    requires_dists: tuple[Requirement, ...]
5✔
1494

1495

1496
DefaultT = TypeVar("DefaultT")
5✔
1497

1498

1499
class PexResolveInfo(Collection[PexDistributionInfo]):
5✔
1500
    """Information about all distributions resolved in a PEX file, as reported by `PEX_TOOLS=1
1501
    repository info -v`."""
1502

1503
    def find(
5✔
1504
        self, name: str, default: DefaultT | None = None
1505
    ) -> PexDistributionInfo | DefaultT | None:
1506
        """Returns the PexDistributionInfo with the given name, first one wins."""
1507
        try:
3✔
1508
            return next(info for info in self if info.project_name == name)
3✔
UNCOV
1509
        except StopIteration:
×
UNCOV
1510
            return default
×
1511

1512

1513
def parse_repository_info(repository_info: str) -> PexResolveInfo:
5✔
1514
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
3✔
1515
        for line in repository_info.splitlines():
3✔
1516
            info = json.loads(line)
3✔
1517
            requires_python = info["requires_python"]
3✔
1518
            yield PexDistributionInfo(
3✔
1519
                project_name=info["project_name"],
1520
                version=packaging.version.Version(info["version"]),
1521
                requires_python=(
1522
                    packaging.specifiers.SpecifierSet(requires_python)
1523
                    if requires_python is not None
1524
                    else None
1525
                ),
1526
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1527
            )
1528

1529
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
3✔
1530

1531

1532
@rule
5✔
1533
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
5✔
1534
    process_result = await fallible_to_exec_result_or_raise(
2✔
1535
        **implicitly(
1536
            VenvPexProcess(
1537
                venv_pex,
1538
                argv=["repository", "info", "-v"],
1539
                extra_env={"PEX_TOOLS": "1"},
1540
                input_digest=venv_pex.digest,
1541
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1542
                level=LogLevel.DEBUG,
1543
            )
1544
        )
1545
    )
1546
    return parse_repository_info(process_result.stdout.decode())
2✔
1547

1548

1549
@rule
5✔
1550
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
5✔
1551
    process_result = await fallible_to_exec_result_or_raise(
3✔
1552
        **implicitly(
1553
            PexProcess(
1554
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1555
                argv=[pex.name, "repository", "info", "-v"],
1556
                input_digest=pex.digest,
1557
                extra_env={"PEX_MODULE": "pex.tools"},
1558
                description=f"Determine distributions found in {pex.name}",
1559
                level=LogLevel.DEBUG,
1560
            )
1561
        )
1562
    )
1563
    return parse_repository_info(process_result.stdout.decode())
3✔
1564

1565

1566
def rules():
5✔
1567
    return [
5✔
1568
        *collect_rules(),
1569
        *pex_cli.rules(),
1570
        *pex_requirements.rules(),
1571
        *uv_subsystem.rules(),
1572
        *uv_rules(),
1573
        *stripped_source_rules(),
1574
    ]
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