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

pantsbuild / pants / 25405422172

05 May 2026 10:18PM UTC coverage: 92.879% (-0.07%) from 92.944%
25405422172

Pull #23319

github

web-flow
Merge c82d0f333 into e8b784f89
Pull Request #23319: [pants_ng] Scaffolding for a pants_ng mode.

25 of 76 new or added lines in 9 files covered. (32.89%)

209 existing lines in 15 files now uncovered.

92234 of 99306 relevant lines covered (92.88%)

4.05 hits per line

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

96.85
/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
12✔
5

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

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

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

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

116

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

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

125
    interpreter_constraints: InterpreterConstraints
12✔
126

127

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

134

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

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

144

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

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

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

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

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

165

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

190

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

197

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

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

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

298
        self.__post_init__()
12✔
299

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

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

352

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

357

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

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

366

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

371

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

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

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

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

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

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

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

445

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

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

456

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

462

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

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

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

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

507

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

517

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

523

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

570

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

591

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

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

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

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

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

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

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

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

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

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

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

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

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

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

729

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

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

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

761
    source_dir_name = "source_files"
12✔
762

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

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

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

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

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

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

847
    argv = [
12✔
848
        "--output-file",
849
        output_file,
850
        *request.additional_args,
851
    ]
852

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

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

877
    argv.extend(
12✔
878
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
879
    )
880
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
12✔
881

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

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

894
    argv.append(f"--sources-directory={source_dir_name}")
12✔
895

896
    # Include any additional arguments and input digests required by the requirements.
897
    argv.extend(requirements_setup.argv)
12✔
898

899
    merged_digest = await merge_digests(
12✔
900
        MergeDigests(
901
            (
902
                request.complete_platforms.digest,
903
                sources_digest_as_subdir,
904
                request.additional_inputs,
905
                *requirements_setup.digests,
906
                *(pex.digest for pex in request.pex_path),
907
            )
908
        )
909
    )
910

911
    argv.extend(["--layout", request.layout.value])
12✔
912

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

930
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
931

932
    if strip_output_chroot:
12✔
933
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
12✔
934
    else:
935
        output_digest = result.output_digest
5✔
936

937
    digest = (
12✔
938
        await merge_digests(
939
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
940
        )
941
        if request.pex_path
942
        else output_digest
943
    )
944

945
    return BuildPexResult(
12✔
946
        result=result,
947
        pex_filename=request.output_filename,
948
        digest=digest,
949
        python=pex_python_setup.python,
950
    )
951

952

953
def _build_pex_description(
12✔
954
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
955
) -> str:
956
    if request.description:
12✔
957
        return request.description
3✔
958

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

995

996
@rule
12✔
997
async def create_pex(request: PexRequest) -> Pex:
12✔
998
    result = await build_pex(request, **implicitly())
11✔
999
    return result.create_pex()
11✔
1000

1001

1002
@rule
12✔
1003
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
12✔
1004
    if request.maybe_pex_request is None:
11✔
1005
        return OptionalPex(None)
11✔
1006
    result = await create_pex(request.maybe_pex_request)
1✔
1007
    return OptionalPex(result)
1✔
1008

1009

1010
@dataclass(frozen=True)
12✔
1011
class Script:
12✔
1012
    path: PurePath
12✔
1013

1014
    @property
12✔
1015
    def argv0(self) -> str:
12✔
1016
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
12✔
1017

1018

1019
@dataclass(frozen=True)
12✔
1020
class VenvScript:
12✔
1021
    script: Script
12✔
1022
    content: FileContent
12✔
1023

1024

1025
@dataclass(frozen=True)
12✔
1026
class VenvScriptWriter:
12✔
1027
    complete_pex_env: CompletePexEnvironment
12✔
1028
    pex: Pex
12✔
1029
    venv_dir: PurePath
12✔
1030

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

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

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

1063
        script = dedent(
12✔
1064
            f"""\
1065
            #!{bash.path}
1066
            set -euo pipefail
1067

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

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

1098
            export {" ".join(env_vars)}
1099
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1100

1101
            execute_pex_args="{execute_pex_args}"
1102
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1103
            venv_dir="$(adjust_relative_paths {venv_dir})"
1104

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

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

1117
            exec "${{target_venv_executable}}" "$@"
1118
            """
1119
        )
1120
        return VenvScript(
12✔
1121
            script=Script(script_path),
1122
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1123
        )
1124

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

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

1141
    def python(self, bash: BashBinary) -> VenvScript:
12✔
1142
        """Writes a safe shim for the venv's python binary."""
1143
        return self.bin(bash, "python")
12✔
1144

1145

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

1156

1157
@dataclass(frozen=True)
12✔
1158
class VenvPexRequest:
12✔
1159
    pex_request: PexRequest
12✔
1160
    complete_pex_env: CompletePexEnvironment
12✔
1161
    bin_names: tuple[str, ...] = ()
12✔
1162
    site_packages_copies: bool = False
12✔
1163

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

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

1186

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

1194

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

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

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

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

1282

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

1299
    def __init__(
12✔
1300
        self,
1301
        pex: Pex,
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
    ) -> None:
1316
        object.__setattr__(self, "pex", pex)
7✔
1317
        object.__setattr__(self, "argv", tuple(argv))
7✔
1318
        object.__setattr__(self, "description", description)
7✔
1319
        object.__setattr__(self, "level", level)
7✔
1320
        object.__setattr__(self, "input_digest", input_digest)
7✔
1321
        object.__setattr__(self, "working_directory", working_directory)
7✔
1322
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
7✔
1323
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
7✔
1324
        object.__setattr__(
7✔
1325
            self, "output_directories", tuple(output_directories) if output_directories else None
1326
        )
1327
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
7✔
1328
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
7✔
1329
        object.__setattr__(self, "concurrency_available", concurrency_available)
7✔
1330
        object.__setattr__(self, "cache_scope", cache_scope)
7✔
1331

1332

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

1369

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

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

1422

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

1462

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

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

1475

1476
DefaultT = TypeVar("DefaultT")
12✔
1477

1478

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

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

1492

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

1509
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
7✔
1510

1511

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

1528

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

1545

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