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

pantsbuild / pants / 18812500213

26 Oct 2025 03:42AM UTC coverage: 80.284% (+0.005%) from 80.279%
18812500213

Pull #22804

github

web-flow
Merge 2a56fdb46 into 4834308dc
Pull Request #22804: test_shell_command: use correct default cache scope for a test's environment

29 of 31 new or added lines in 2 files covered. (93.55%)

1314 existing lines in 64 files now uncovered.

77900 of 97030 relevant lines covered (80.28%)

3.35 hits per line

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

68.99
/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
from collections.abc import Iterable, Iterator, Mapping, Sequence
12✔
12
from dataclasses import dataclass
12✔
13
from pathlib import PurePath
12✔
14
from textwrap import dedent  # noqa: PNT20
12✔
15
from typing import TypeVar
12✔
16

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

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

89
logger = logging.getLogger(__name__)
12✔
90

91

92
@union(in_scope_types=[EnvironmentName])
12✔
93
@dataclass(frozen=True)
12✔
94
class PythonProvider:
12✔
95
    """Union which should have 0 or 1 implementations registered which provide Python.
96

97
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
98
    """
99

100
    interpreter_constraints: InterpreterConstraints
12✔
101

102

103
@rule(polymorphic=True)
12✔
104
async def get_python_executable(
12✔
105
    provider: PythonProvider, env_name: EnvironmentName
106
) -> PythonExecutable:
107
    raise NotImplementedError()
×
108

109

110
class PexPlatforms(DeduplicatedCollection[str]):
12✔
111
    sort_input = True
12✔
112

113
    def generate_pex_arg_list(self) -> list[str]:
12✔
114
        args = []
2✔
115
        for platform in self:
2✔
UNCOV
116
            args.extend(["--platform", platform])
1✔
117
        return args
2✔
118

119

120
class CompletePlatforms(DeduplicatedCollection[str]):
12✔
121
    sort_input = True
12✔
122

123
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
12✔
124
        super().__init__(iterable)
12✔
125
        self._digest = digest
12✔
126

127
    @classmethod
12✔
128
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
12✔
129
        return cls(snapshot.files, digest=snapshot.digest)
1✔
130

131
    @property
12✔
132
    def digest(self) -> Digest:
12✔
133
        return self._digest
2✔
134

135
    def generate_pex_arg_list(self) -> Iterator[str]:
12✔
136
        for path in self:
2✔
UNCOV
137
            yield "--complete-platform"
1✔
UNCOV
138
            yield path
1✔
139

140

141
@rule
12✔
142
async def digest_complete_platform_addresses(
12✔
143
    addresses: UnparsedAddressInputs,
144
) -> CompletePlatforms:
145
    original_file_targets = await resolve_targets(**implicitly(addresses))
×
146
    original_files_sources = await concurrently(
×
147
        hydrate_sources(
148
            HydrateSourcesRequest(
149
                tgt.get(SourcesField),
150
                for_sources_types=(
151
                    FileSourceField,
152
                    ResourceSourceField,
153
                ),
154
                enable_codegen=True,
155
            ),
156
            **implicitly(),
157
        )
158
        for tgt in original_file_targets
159
    )
160
    snapshot = await digest_to_snapshot(
×
161
        **implicitly(MergeDigests(sources.snapshot.digest for sources in original_files_sources))
162
    )
163
    return CompletePlatforms.from_snapshot(snapshot)
×
164

165

166
@rule
12✔
167
async def digest_complete_platforms(
12✔
168
    complete_platforms: PexCompletePlatformsField,
169
) -> CompletePlatforms:
170
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
×
171

172

173
@dataclass(frozen=True)
12✔
174
class PexRequest(EngineAwareParameter):
12✔
175
    output_filename: str
12✔
176
    internal_only: bool
12✔
177
    layout: PexLayout
12✔
178
    python: PythonExecutable | None
12✔
179
    requirements: PexRequirements | EntireLockfile
12✔
180
    interpreter_constraints: InterpreterConstraints
12✔
181
    platforms: PexPlatforms
12✔
182
    complete_platforms: CompletePlatforms
12✔
183
    sources: Digest | None
12✔
184
    additional_inputs: Digest
12✔
185
    main: MainSpecification | None
12✔
186
    inject_args: tuple[str, ...]
12✔
187
    inject_env: FrozenDict[str, str]
12✔
188
    additional_args: tuple[str, ...]
12✔
189
    pex_path: tuple[Pex, ...]
12✔
190
    description: str | None = dataclasses.field(compare=False)
12✔
191
    cache_scope: ProcessCacheScope
12✔
192

193
    def __init__(
12✔
194
        self,
195
        *,
196
        output_filename: str,
197
        internal_only: bool,
198
        layout: PexLayout | None = None,
199
        python: PythonExecutable | None = None,
200
        requirements: PexRequirements | EntireLockfile = PexRequirements(),
201
        interpreter_constraints=InterpreterConstraints(),
202
        platforms=PexPlatforms(),
203
        complete_platforms=CompletePlatforms(),
204
        sources: Digest | None = None,
205
        additional_inputs: Digest | None = None,
206
        main: MainSpecification | None = None,
207
        inject_args: Iterable[str] = (),
208
        inject_env: Mapping[str, str] = FrozenDict(),
209
        additional_args: Iterable[str] = (),
210
        pex_path: Iterable[Pex] = (),
211
        description: str | None = None,
212
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
213
    ) -> None:
214
        """A request to create a PEX from its inputs.
215

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

273
        self.__post_init__()
3✔
274

275
    def __post_init__(self):
12✔
276
        if self.internal_only and self.platforms:
3✔
277
            raise ValueError(
×
278
                softwrap(
279
                    f"""
280
                    Internal only PEXes can only constrain interpreters with interpreter_constraints.
281
                    Given platform constraints {self.platforms} for internal only pex request:
282
                    {self}.
283
                    """
284
                )
285
            )
286
        if self.internal_only and self.complete_platforms:
3✔
287
            raise ValueError(
×
288
                softwrap(
289
                    f"""
290
                    Internal only PEXes can only constrain interpreters with interpreter_constraints.
291
                    Given complete_platform constraints {self.complete_platforms} for internal only
292
                    pex request: {self}.
293
                    """
294
                )
295
            )
296
        if self.python and self.platforms:
3✔
297
            raise ValueError(
×
298
                softwrap(
299
                    f"""
300
                    Only one of platforms or a specific interpreter may be set. Got
301
                    both {self.platforms} and {self.python}.
302
                    """
303
                )
304
            )
305
        if self.python and self.complete_platforms:
3✔
306
            raise ValueError(
×
307
                softwrap(
308
                    f"""
309
                    Only one of complete_platforms or a specific interpreter may be set. Got
310
                    both {self.complete_platforms} and {self.python}.
311
                    """
312
                )
313
            )
314
        if self.python and self.interpreter_constraints:
3✔
315
            raise ValueError(
×
316
                softwrap(
317
                    f"""
318
                    Only one of interpreter_constraints or a specific interpreter may be set. Got
319
                    both {self.interpreter_constraints} and {self.python}.
320
                    """
321
                )
322
            )
323

324
    def debug_hint(self) -> str:
12✔
325
        return self.output_filename
×
326

327

328
@dataclass(frozen=True)
12✔
329
class OptionalPexRequest:
12✔
330
    maybe_pex_request: PexRequest | None
12✔
331

332

333
@dataclass(frozen=True)
12✔
334
class Pex:
12✔
335
    """Wrapper for a digest containing a pex file created with some filename."""
336

337
    digest: Digest
12✔
338
    name: str
12✔
339
    python: PythonExecutable | None
12✔
340

341

342
@dataclass(frozen=True)
12✔
343
class OptionalPex:
12✔
344
    maybe_pex: Pex | None
12✔
345

346

347
@rule(desc="Find Python interpreter for constraints", level=LogLevel.DEBUG)
12✔
348
async def find_interpreter(
12✔
349
    interpreter_constraints: InterpreterConstraints,
350
    pex_subsystem: PexSubsystem,
351
    env_target: EnvironmentTarget,
352
    union_membership: UnionMembership,
353
) -> PythonExecutable:
354
    python_providers = union_membership.get(PythonProvider)
×
355
    if len(python_providers) > 1:
×
356
        raise ValueError(
×
357
            softwrap(
358
                f"""
359
                Too many Python provider plugins were registered. We expected 0 or 1, but found
360
                {len(python_providers)}. Providers were:
361

362
                {bullet_list(repr(provider.__class__) for provider in python_providers)}
363
                """
364
            )
365
        )
366
    if python_providers:
×
367
        python_provider = next(iter(python_providers))
×
368
        python = await get_python_executable(
×
369
            **implicitly({python_provider(interpreter_constraints): PythonProvider})
370
        )
371
        return python
×
372

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

398
                    python = os.path.realpath(sys.executable)
399
                    print(python)
400

401
                    hasher = hashlib.sha256()
402
                    with open(python, "rb") as fp:
403
                      for chunk in iter(lambda: fp.read(8192), b""):
404
                          hasher.update(chunk)
405
                    print(hasher.hexdigest())
406
                    """
407
                    ),
408
                ),
409
                level=LogLevel.DEBUG,
410
                cache_scope=env_target.executable_search_path_cache_scope(),
411
            )
412
        )
413
    )
414
    path, fingerprint = result.stdout.decode().strip().splitlines()
×
415

416
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
417

418
    return PythonExecutable(path=path, fingerprint=fingerprint)
×
419

420

421
@dataclass(frozen=True)
12✔
422
class BuildPexResult:
12✔
423
    result: ProcessResult
12✔
424
    pex_filename: str
12✔
425
    digest: Digest
12✔
426
    python: PythonExecutable | None
12✔
427

428
    def create_pex(self) -> Pex:
12✔
429
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
×
430

431

432
@dataclass
12✔
433
class _BuildPexPythonSetup:
12✔
434
    python: PythonExecutable | None
12✔
435
    argv: list[str]
12✔
436

437

438
@rule
12✔
439
async def _determine_pex_python_and_platforms(request: PexRequest) -> _BuildPexPythonSetup:
12✔
440
    # NB: If `--platform` is specified, this signals that the PEX should not be built locally.
441
    # `--interpreter-constraint` only makes sense in the context of building locally. These two
442
    # flags are mutually exclusive. See https://github.com/pex-tool/pex/issues/957.
UNCOV
443
    if request.platforms or request.complete_platforms:
1✔
444
        # Note that this means that this is not an internal-only pex.
445
        # TODO(#9560): consider validating that these platforms are valid with the interpreter
446
        #  constraints.
UNCOV
447
        return _BuildPexPythonSetup(
1✔
448
            None,
449
            [
450
                *request.platforms.generate_pex_arg_list(),
451
                *request.complete_platforms.generate_pex_arg_list(),
452
            ],
453
        )
454

UNCOV
455
    if request.python:
1✔
UNCOV
456
        python = request.python
1✔
457
    else:
UNCOV
458
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
1✔
459

UNCOV
460
    if request.python or request.internal_only:
1✔
461
        # Sometimes we want to build and run with a specific interpreter (either because request
462
        # demanded it, or because it's an internal-only PEX). We will have already validated that
463
        # there were no platforms.
UNCOV
464
        return _BuildPexPythonSetup(python, ["--python", python.path])
1✔
465

466
    else:
467
        # Otherwise, we don't want to force compatibility with a particular interpreter (as in, the
468
        # resulting PEX should follow the ICs), but we _do_ want to tell PEX about at least one
469
        # interpreter that is compatible, to ensure that an interpreter installed/managed by
470
        # provider backends are visible (in the extreme case, a machine may have no Python
471
        # interpreters installed at all, and just rely on Pants' provider backends to install them,
472
        # and thus pex searching $PATH will find nothing).
UNCOV
473
        return _BuildPexPythonSetup(
1✔
474
            python,
475
            [
476
                *request.interpreter_constraints.generate_pex_arg_list(),
477
                "--python-path",
478
                python.path,
479
            ],
480
        )
481

482

483
@dataclass
12✔
484
class _BuildPexRequirementsSetup:
12✔
485
    digests: list[Digest]
12✔
486
    argv: list[str]
12✔
487
    concurrency_available: int
12✔
488

489

490
@dataclass(frozen=True)
12✔
491
class PexRequirementsInfo:
12✔
492
    req_strings: tuple[str, ...]
12✔
493
    find_links: tuple[str, ...]
12✔
494

495

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

542

543
async def _get_entire_lockfile_and_requirements(
12✔
544
    requirements: EntireLockfile | PexRequirements,
545
) -> tuple[LoadedLockfile | None, tuple[str, ...]]:
UNCOV
546
    lockfile: Lockfile | None = None
1✔
UNCOV
547
    complete_req_strings: tuple[str, ...] = tuple()
1✔
548
    # TODO: This is clunky, but can be simplified once we get rid of old-style tool
549
    #  lockfiles, because we can unify EntireLockfile and Resolve.
UNCOV
550
    if isinstance(requirements, EntireLockfile):
1✔
UNCOV
551
        complete_req_strings = requirements.complete_req_strings or tuple()
1✔
UNCOV
552
        lockfile = requirements.lockfile
1✔
UNCOV
553
    elif (
1✔
554
        isinstance(requirements.from_superset, Resolve)
555
        and requirements.from_superset.use_entire_lockfile
556
    ):
557
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
×
UNCOV
558
    if not lockfile:
1✔
UNCOV
559
        return None, complete_req_strings
1✔
UNCOV
560
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
1✔
UNCOV
561
    return loaded_lockfile, complete_req_strings
1✔
562

563

564
@rule
12✔
565
async def _setup_pex_requirements(
12✔
566
    request: PexRequest, python_setup: PythonSetup
567
) -> _BuildPexRequirementsSetup:
568
    resolve_name: str | None
UNCOV
569
    if isinstance(request.requirements, EntireLockfile):
1✔
UNCOV
570
        resolve_name = request.requirements.lockfile.resolve_name
1✔
UNCOV
571
    elif isinstance(request.requirements.from_superset, Resolve):
1✔
UNCOV
572
        resolve_name = request.requirements.from_superset.name
1✔
573
    else:
574
        # This implies that, currently, per-resolve options are only configurable for resolves.
575
        # However, if no resolve is specified, we will still load options that apply to every
576
        # resolve, like `[python-repos].indexes`.
UNCOV
577
        resolve_name = None
1✔
UNCOV
578
    resolve_config = await determine_resolve_pex_config(
1✔
579
        ResolvePexConfigRequest(resolve_name), **implicitly()
580
    )
581

UNCOV
582
    pex_lock_resolver_args = list(resolve_config.pex_args())
1✔
UNCOV
583
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
1✔
584

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

UNCOV
614
        return _BuildPexRequirementsSetup(
1✔
615
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
616
        )
617

UNCOV
618
    assert isinstance(request.requirements, PexRequirements)
1✔
UNCOV
619
    reqs_info = await get_req_strings(request.requirements)
1✔
620

621
    # TODO: This is not the best heuristic for available concurrency, since the
622
    # requirements almost certainly have transitive deps which also need building, but it
623
    # is better than using something hardcoded.
UNCOV
624
    concurrency_available = len(reqs_info.req_strings)
1✔
625

UNCOV
626
    if isinstance(request.requirements.from_superset, Pex):
1✔
UNCOV
627
        repository_pex = request.requirements.from_superset
1✔
UNCOV
628
        return _BuildPexRequirementsSetup(
1✔
629
            [repository_pex.digest],
630
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
631
            concurrency_available,
632
        )
633

UNCOV
634
    elif isinstance(request.requirements.from_superset, Resolve):
1✔
UNCOV
635
        lockfile = await get_lockfile_for_resolve(
1✔
636
            request.requirements.from_superset, **implicitly()
637
        )
UNCOV
638
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
1✔
639

640
        # NB: This is also validated in the constructor.
UNCOV
641
        assert loaded_lockfile.is_pex_native
1✔
UNCOV
642
        if not reqs_info.req_strings:
1✔
643
            return _BuildPexRequirementsSetup([], [], concurrency_available)
×
644

UNCOV
645
        if loaded_lockfile.metadata:
1✔
646
            validate_metadata(
×
647
                loaded_lockfile.metadata,
648
                request.interpreter_constraints,
649
                loaded_lockfile.original_lockfile,
650
                consumed_req_strings=reqs_info.req_strings,
651
                # Don't validate user requirements when subsetting a resolve, as Pex's
652
                # validation during the subsetting is far more precise than our naive string
653
                # comparison. For example, if a lockfile was generated with `foo==1.2.3`
654
                # and we want to resolve `foo>=1.0.0` or just `foo` out of it, Pex will do
655
                # so successfully, while our naive validation would fail.
656
                validate_consumed_req_strings=False,
657
                python_setup=python_setup,
658
                resolve_config=resolve_config,
659
            )
660

UNCOV
661
        return _BuildPexRequirementsSetup(
1✔
662
            [loaded_lockfile.lockfile_digest],
663
            [
664
                *reqs_info.req_strings,
665
                "--lock",
666
                loaded_lockfile.lockfile_path,
667
                *pex_lock_resolver_args,
668
            ],
669
            concurrency_available,
670
        )
671

672
    # We use pip to perform a normal resolve.
UNCOV
673
    digests = []
1✔
UNCOV
674
    argv = [
1✔
675
        *reqs_info.req_strings,
676
        *pip_resolver_args,
677
        *(f"--find-links={find_links}" for find_links in reqs_info.find_links),
678
    ]
UNCOV
679
    if request.requirements.constraints_strings:
1✔
UNCOV
680
        constraints_file = "__constraints.txt"
1✔
UNCOV
681
        constraints_content = "\n".join(request.requirements.constraints_strings)
1✔
UNCOV
682
        digests.append(
1✔
683
            await create_digest(
684
                CreateDigest([FileContent(constraints_file, constraints_content.encode())])
685
            )
686
        )
UNCOV
687
        argv.extend(["--constraints", constraints_file])
1✔
UNCOV
688
    return _BuildPexRequirementsSetup(digests, argv, concurrency_available=concurrency_available)
1✔
689

690

691
@rule(level=LogLevel.DEBUG)
12✔
692
async def build_pex(
12✔
693
    request: PexRequest, python_setup: PythonSetup, pex_subsystem: PexSubsystem
694
) -> BuildPexResult:
695
    """Returns a PEX with the given settings."""
696

697
    if not request.python and not request.interpreter_constraints:
×
698
        # Blank ICs in the request means that the caller wants us to use the ICs configured
699
        # for the resolve (falling back to the global ICs).
700
        resolve_name = ""
×
701
        if isinstance(request.requirements, PexRequirements) and isinstance(
×
702
            request.requirements.from_superset, Resolve
703
        ):
704
            resolve_name = request.requirements.from_superset.name
×
705
        elif isinstance(request.requirements, EntireLockfile):
×
706
            resolve_name = request.requirements.lockfile.resolve_name
×
707

708
        if resolve_name:
×
709
            request = dataclasses.replace(
×
710
                request,
711
                interpreter_constraints=InterpreterConstraints(
712
                    python_setup.resolves_to_interpreter_constraints.get(
713
                        resolve_name,
714
                        python_setup.interpreter_constraints,
715
                    )
716
                ),
717
            )
718

719
    source_dir_name = "source_files"
×
720

721
    pex_python_setup_req = _determine_pex_python_and_platforms(request)
×
722
    requirements_setup_req = _setup_pex_requirements(**implicitly({request: PexRequest}))
×
723
    sources_digest_as_subdir_req = add_prefix(
×
724
        AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
725
    )
726
    if isinstance(request.requirements, PexRequirements):
×
727
        (
×
728
            pex_python_setup,
729
            requirements_setup,
730
            sources_digest_as_subdir,
731
            req_info,
732
        ) = await concurrently(
733
            pex_python_setup_req,
734
            requirements_setup_req,
735
            sources_digest_as_subdir_req,
736
            get_req_strings(request.requirements),
737
        )
738
        req_strings = req_info.req_strings
×
739
    else:
740
        pex_python_setup, requirements_setup, sources_digest_as_subdir = await concurrently(
×
741
            pex_python_setup_req,
742
            requirements_setup_req,
743
            sources_digest_as_subdir_req,
744
        )
745
        req_strings = ()
×
746

747
    argv = [
×
748
        "--output-file",
749
        request.output_filename,
750
        *request.additional_args,
751
    ]
752

753
    argv.extend(pex_python_setup.argv)
×
754

755
    if request.main is not None:
×
756
        argv.extend(request.main.iter_pex_args())
×
757
        if isinstance(request.main, Executable):
×
758
            # Unlike other MainSpecifiecation types (that can pass spec as-is to pex),
759
            # Executable must be an actual path relative to the sandbox.
760
            # request.main.spec is a python source file including its spec_path.
761
            # To make it relative to the sandbox, we strip the source root
762
            # and add the source_dir_name (sources get prefixed with that below).
763
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
×
764
            argv.append(os.path.join(source_dir_name, stripped.value))
×
765

766
    argv.extend(
×
767
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
768
    )
769
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
×
770

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

777
    if request.internal_only:
×
778
        # An internal-only runs on a single machine, and pre-installing wheels is wasted work in
779
        # that case (see https://github.com/pex-tool/pex/issues/2292#issuecomment-1854582647 for
780
        # analysis).
781
        argv.append("--no-pre-install-wheels")
×
782

783
    argv.append(f"--sources-directory={source_dir_name}")
×
784

785
    # Include any additional arguments and input digests required by the requirements.
786
    argv.extend(requirements_setup.argv)
×
787

788
    merged_digest = await merge_digests(
×
789
        MergeDigests(
790
            (
791
                request.complete_platforms.digest,
792
                sources_digest_as_subdir,
793
                request.additional_inputs,
794
                *requirements_setup.digests,
795
                *(pex.digest for pex in request.pex_path),
796
            )
797
        )
798
    )
799

800
    argv.extend(["--layout", request.layout.value])
×
801
    output_files: Iterable[str] | None = None
×
802
    output_directories: Iterable[str] | None = None
×
803
    if PexLayout.ZIPAPP == request.layout:
×
804
        output_files = [request.output_filename]
×
805
    else:
806
        output_directories = [request.output_filename]
×
807

808
    result = await fallible_to_exec_result_or_raise(
×
809
        **implicitly(
810
            PexCliProcess(
811
                subcommand=(),
812
                extra_args=argv,
813
                additional_input_digest=merged_digest,
814
                description=_build_pex_description(request, req_strings, python_setup.resolves),
815
                output_files=output_files,
816
                output_directories=output_directories,
817
                concurrency_available=requirements_setup.concurrency_available,
818
                cache_scope=request.cache_scope,
819
            )
820
        )
821
    )
822

823
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
824

825
    digest = (
×
826
        await merge_digests(
827
            MergeDigests((result.output_digest, *(pex.digest for pex in request.pex_path)))
828
        )
829
        if request.pex_path
830
        else result.output_digest
831
    )
832

833
    return BuildPexResult(
×
834
        result=result,
835
        pex_filename=request.output_filename,
836
        digest=digest,
837
        python=pex_python_setup.python,
838
    )
839

840

841
def _build_pex_description(
12✔
842
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
843
) -> str:
UNCOV
844
    if request.description:
1✔
UNCOV
845
        return request.description
1✔
846

UNCOV
847
    if isinstance(request.requirements, EntireLockfile):
1✔
UNCOV
848
        lockfile = request.requirements.lockfile
1✔
UNCOV
849
        desc_suffix = f"from {lockfile.url}"
1✔
850
    else:
UNCOV
851
        if not req_strings:
1✔
UNCOV
852
            return f"Building {request.output_filename}"
1✔
UNCOV
853
        elif isinstance(request.requirements.from_superset, Pex):
1✔
UNCOV
854
            repo_pex = request.requirements.from_superset.name
1✔
UNCOV
855
            return softwrap(
1✔
856
                f"""
857
                Extracting {pluralize(len(req_strings), "requirement")}
858
                to build {request.output_filename} from {repo_pex}:
859
                {", ".join(req_strings)}
860
                """
861
            )
UNCOV
862
        elif isinstance(request.requirements.from_superset, Resolve):
1✔
863
            # At this point we know this is a valid user resolve, so we can assume
864
            # it's available in the dict. Nonetheless we use get() so that any weird error
865
            # here gives a bad message rather than an outright crash.
866
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
×
867
            return softwrap(
×
868
                f"""
869
                Building {pluralize(len(req_strings), "requirement")}
870
                for {request.output_filename} from the {lockfile_path} resolve:
871
                {", ".join(req_strings)}
872
                """
873
            )
874
        else:
UNCOV
875
            desc_suffix = softwrap(
1✔
876
                f"""
877
                with {pluralize(len(req_strings), "requirement")}:
878
                {", ".join(req_strings)}
879
                """
880
            )
UNCOV
881
    return f"Building {request.output_filename} {desc_suffix}"
1✔
882

883

884
@rule
12✔
885
async def create_pex(request: PexRequest) -> Pex:
12✔
886
    result = await build_pex(request, **implicitly())
×
887
    return result.create_pex()
×
888

889

890
@rule
12✔
891
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
12✔
892
    if request.maybe_pex_request is None:
×
893
        return OptionalPex(None)
×
894
    result = await create_pex(request.maybe_pex_request)
×
895
    return OptionalPex(result)
×
896

897

898
@dataclass(frozen=True)
12✔
899
class Script:
12✔
900
    path: PurePath
12✔
901

902
    @property
12✔
903
    def argv0(self) -> str:
12✔
904
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
×
905

906

907
@dataclass(frozen=True)
12✔
908
class VenvScript:
12✔
909
    script: Script
12✔
910
    content: FileContent
12✔
911

912

913
@dataclass(frozen=True)
12✔
914
class VenvScriptWriter:
12✔
915
    complete_pex_env: CompletePexEnvironment
12✔
916
    pex: Pex
12✔
917
    venv_dir: PurePath
12✔
918

919
    @classmethod
12✔
920
    def create(
12✔
921
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
922
    ) -> VenvScriptWriter:
923
        # N.B.: We don't know the working directory that will be used in any given
924
        # invocation of the venv scripts; so we deal with working_directory once in an
925
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
926
        # CWD offset math in every rule for all the relative paths their process depends on.
927
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
×
928
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
×
929

930
    def _create_venv_script(
12✔
931
        self,
932
        bash: BashBinary,
933
        *,
934
        script_path: PurePath,
935
        venv_executable: PurePath,
936
    ) -> VenvScript:
937
        env_vars = (
×
938
            f"{name}={shlex.quote(value)}"
939
            for name, value in self.complete_pex_env.environment_dict(
940
                python=self.pex.python
941
            ).items()
942
        )
943

944
        target_venv_executable = shlex.quote(str(venv_executable))
×
945
        venv_dir = shlex.quote(str(self.venv_dir))
×
946
        execute_pex_args = " ".join(
×
947
            f"$(adjust_relative_paths {shlex.quote(arg)})"
948
            for arg in self.complete_pex_env.create_argv(self.pex.name)
949
        )
950

951
        script = dedent(
×
952
            f"""\
953
            #!{bash.path}
954
            set -euo pipefail
955

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

962
            function adjust_relative_paths() {{
963
                local value0="$1"
964
                shift
965
                if [ "${{value0:0:1}}" == "/" ]; then
966
                    # Don't relativize absolute paths.
967
                    echo "${{value0}}" "$@"
968
                else
969
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
970
                    # this script works when run with a PWD set somewhere else than the sandbox
971
                    # root.
972
                    #
973
                    # There are two cases to consider. For the purposes of example, assume PWD is
974
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
975
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
976
                    #
977
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
978
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
979
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
980
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
981
                    #    `../..`, we calculate `../../config/tool.yml`.
982
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
983
                fi
984
            }}
985

986
            export {" ".join(env_vars)}
987
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
988

989
            execute_pex_args="{execute_pex_args}"
990
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
991
            venv_dir="$(adjust_relative_paths {venv_dir})"
992

993
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
994
            # with tools support.
995
            if [ -n "${{PEX_TOOLS:-}}" ]; then
996
              exec ${{execute_pex_args}} "$@"
997
            fi
998

999
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1000
            # `--venv` mode PEX file.
1001
            if [ ! -e "${{venv_dir}}" ]; then
1002
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1003
            fi
1004

1005
            exec "${{target_venv_executable}}" "$@"
1006
            """
1007
        )
1008
        return VenvScript(
×
1009
            script=Script(script_path),
1010
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1011
        )
1012

1013
    def exe(self, bash: BashBinary) -> VenvScript:
12✔
1014
        """Writes a safe shim for the venv's executable `pex` script."""
1015
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
×
1016
        return self._create_venv_script(
×
1017
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1018
        )
1019

1020
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
12✔
1021
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1022
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
×
1023
        return self._create_venv_script(
×
1024
            bash,
1025
            script_path=script_path,
1026
            venv_executable=self.venv_dir / "bin" / name,
1027
        )
1028

1029
    def python(self, bash: BashBinary) -> VenvScript:
12✔
1030
        """Writes a safe shim for the venv's python binary."""
1031
        return self.bin(bash, "python")
×
1032

1033

1034
@dataclass(frozen=True)
12✔
1035
class VenvPex:
12✔
1036
    digest: Digest
12✔
1037
    append_only_caches: FrozenDict[str, str] | None
12✔
1038
    pex_filename: str
12✔
1039
    pex: Script
12✔
1040
    python: Script
12✔
1041
    bin: FrozenDict[str, Script]
12✔
1042
    venv_rel_dir: str
12✔
1043

1044

1045
@dataclass(frozen=True)
12✔
1046
class VenvPexRequest:
12✔
1047
    pex_request: PexRequest
12✔
1048
    complete_pex_env: CompletePexEnvironment
12✔
1049
    bin_names: tuple[str, ...] = ()
12✔
1050
    site_packages_copies: bool = False
12✔
1051

1052
    def __init__(
12✔
1053
        self,
1054
        pex_request: PexRequest,
1055
        complete_pex_env: CompletePexEnvironment,
1056
        bin_names: Iterable[str] = (),
1057
        site_packages_copies: bool = False,
1058
    ) -> None:
1059
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1060

1061
        :param pex_request: The details of the desired PEX.
1062
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1063
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1064
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1065
            dependencies when installing them in the venv site-packages directory. By default this
1066
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1067
            but results in a non-standard venv structure that does trip up some libraries.
1068
        """
1069
        object.__setattr__(self, "pex_request", pex_request)
×
1070
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
×
1071
        object.__setattr__(self, "bin_names", tuple(bin_names))
×
1072
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
×
1073

1074

1075
@rule
12✔
1076
async def wrap_venv_prex_request(
12✔
1077
    pex_request: PexRequest, pex_environment: PexEnvironment
1078
) -> VenvPexRequest:
1079
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1080
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
×
1081

1082

1083
@rule
12✔
1084
async def create_venv_pex(
12✔
1085
    request: VenvPexRequest, bash: BashBinary, pex_environment: PexEnvironment
1086
) -> VenvPex:
1087
    # VenvPex is motivated by improving performance of Python tools by eliminating traditional PEX
1088
    # file startup overhead.
1089
    #
1090
    # To achieve the minimal overhead (on the order of 1ms) we discard:
1091
    # 1. Using Pex default mode:
1092
    #    Although this does reduce initial tool execution overhead, it still leaves a minimum
1093
    #    O(100ms) of overhead per subsequent tool invocation. Fundamentally, Pex still needs to
1094
    #    execute its `sys.path` isolation bootstrap code in this case.
1095
    # 2. Using the Pex `venv` tool:
1096
    #    The idea here would be to create a tool venv as a Process output and then use the tool
1097
    #    venv as an input digest for all tool invocations. This was tried and netted ~500ms of
1098
    #    overhead over raw venv use.
1099
    #
1100
    # Instead we use Pex's `--venv` mode. In this mode you can run the Pex file and it will create a
1101
    # venv on the fly in the PEX_ROOT as needed. Since the PEX_ROOT is a named_cache, we avoid the
1102
    # digest materialization overhead present in 2 above. Since the venv is naturally isolated we
1103
    # avoid the `sys.path` isolation overhead of Pex itself present in 1 above.
1104
    #
1105
    # This does leave O(50ms) of overhead though for the PEX bootstrap code to detect an already
1106
    # created venv in the PEX_ROOT and re-exec into it. To eliminate this overhead we execute the
1107
    # `pex` venv script in the PEX_ROOT directly. This is not robust on its own though, since the
1108
    # named caches store might be pruned at any time. To guard against that case we introduce a shim
1109
    # bash script that checks to see if the `pex` venv script exists in the PEX_ROOT and re-creates
1110
    # the PEX_ROOT venv if not. Using the shim script to run Python tools gets us down to the ~1ms
1111
    # of overhead we currently enjoy.
1112

1113
    pex_request = request.pex_request
×
1114
    seeded_venv_request = dataclasses.replace(
×
1115
        pex_request,
1116
        additional_args=pex_request.additional_args
1117
        + (
1118
            "--venv",
1119
            "prepend",
1120
            "--seed",
1121
            "verbose",
1122
            pex_environment.venv_site_packages_copies_option(
1123
                use_copies=request.site_packages_copies
1124
            ),
1125
        ),
1126
    )
1127
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
×
1128
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1129
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1130
    # directory.
1131
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
×
1132
    abs_pex_root = PurePath(seed_info["pex_root"])
×
1133
    abs_pex_path = PurePath(seed_info["pex"])
×
1134
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
×
1135

1136
    venv_script_writer = VenvScriptWriter.create(
×
1137
        complete_pex_env=request.complete_pex_env,
1138
        pex=venv_pex_result.create_pex(),
1139
        venv_rel_dir=venv_rel_dir,
1140
    )
1141
    pex = venv_script_writer.exe(bash)
×
1142
    python = venv_script_writer.python(bash)
×
1143
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
×
1144
    scripts_digest = await create_digest(
×
1145
        CreateDigest(
1146
            (
1147
                pex.content,
1148
                python.content,
1149
                *(venv_script.content for venv_script in scripts.values()),
1150
            )
1151
        )
1152
    )
1153
    input_digest = await merge_digests(
×
1154
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1155
    )
1156
    append_only_caches = (
×
1157
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1158
    )
1159

1160
    return VenvPex(
×
1161
        digest=input_digest,
1162
        append_only_caches=append_only_caches,
1163
        pex_filename=venv_pex_result.pex_filename,
1164
        pex=pex.script,
1165
        python=python.script,
1166
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1167
        venv_rel_dir=venv_rel_dir.as_posix(),
1168
    )
1169

1170

1171
@dataclass(frozen=True)
12✔
1172
class PexProcess:
12✔
1173
    pex: Pex
12✔
1174
    argv: tuple[str, ...]
12✔
1175
    description: str = dataclasses.field(compare=False)
12✔
1176
    level: LogLevel
12✔
1177
    input_digest: Digest | None
12✔
1178
    working_directory: str | None
12✔
1179
    extra_env: FrozenDict[str, str]
12✔
1180
    output_files: tuple[str, ...] | None
12✔
1181
    output_directories: tuple[str, ...] | None
12✔
1182
    timeout_seconds: int | None
12✔
1183
    execution_slot_variable: str | None
12✔
1184
    concurrency_available: int
12✔
1185
    cache_scope: ProcessCacheScope
12✔
1186

1187
    def __init__(
12✔
1188
        self,
1189
        pex: Pex,
1190
        *,
1191
        description: str,
1192
        argv: Iterable[str] = (),
1193
        level: LogLevel = LogLevel.INFO,
1194
        input_digest: Digest | None = None,
1195
        working_directory: str | None = None,
1196
        extra_env: Mapping[str, str] | None = None,
1197
        output_files: Iterable[str] | None = None,
1198
        output_directories: Iterable[str] | None = None,
1199
        timeout_seconds: int | None = None,
1200
        execution_slot_variable: str | None = None,
1201
        concurrency_available: int = 0,
1202
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1203
    ) -> None:
1204
        object.__setattr__(self, "pex", pex)
2✔
1205
        object.__setattr__(self, "argv", tuple(argv))
2✔
1206
        object.__setattr__(self, "description", description)
2✔
1207
        object.__setattr__(self, "level", level)
2✔
1208
        object.__setattr__(self, "input_digest", input_digest)
2✔
1209
        object.__setattr__(self, "working_directory", working_directory)
2✔
1210
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
2✔
1211
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
2✔
1212
        object.__setattr__(
2✔
1213
            self, "output_directories", tuple(output_directories) if output_directories else None
1214
        )
1215
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
2✔
1216
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
2✔
1217
        object.__setattr__(self, "concurrency_available", concurrency_available)
2✔
1218
        object.__setattr__(self, "cache_scope", cache_scope)
2✔
1219

1220

1221
@rule
12✔
1222
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
12✔
1223
    pex = request.pex
×
1224
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
×
1225
    argv = complete_pex_env.create_argv(pex.name, *request.argv)
×
1226
    env = {
×
1227
        **complete_pex_env.environment_dict(python=pex.python),
1228
        **request.extra_env,
1229
    }
1230
    input_digest = (
×
1231
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1232
        if request.input_digest
1233
        else pex.digest
1234
    )
1235
    append_only_caches = (
×
1236
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1237
    )
1238
    return Process(
×
1239
        argv,
1240
        description=request.description,
1241
        level=request.level,
1242
        input_digest=input_digest,
1243
        working_directory=request.working_directory,
1244
        env=env,
1245
        output_files=request.output_files,
1246
        output_directories=request.output_directories,
1247
        append_only_caches={
1248
            **complete_pex_env.append_only_caches,
1249
            **append_only_caches,
1250
        },
1251
        timeout_seconds=request.timeout_seconds,
1252
        execution_slot_variable=request.execution_slot_variable,
1253
        concurrency_available=request.concurrency_available,
1254
        cache_scope=request.cache_scope,
1255
    )
1256

1257

1258
@dataclass(unsafe_hash=True)
12✔
1259
class VenvPexProcess:
12✔
1260
    venv_pex: VenvPex
12✔
1261
    argv: tuple[str, ...]
12✔
1262
    description: str = dataclasses.field(compare=False)
12✔
1263
    level: LogLevel
12✔
1264
    input_digest: Digest | None
12✔
1265
    working_directory: str | None
12✔
1266
    extra_env: FrozenDict[str, str]
12✔
1267
    output_files: tuple[str, ...] | None
12✔
1268
    output_directories: tuple[str, ...] | None
12✔
1269
    timeout_seconds: int | None
12✔
1270
    execution_slot_variable: str | None
12✔
1271
    concurrency_available: int
12✔
1272
    cache_scope: ProcessCacheScope
12✔
1273
    append_only_caches: FrozenDict[str, str]
12✔
1274

1275
    def __init__(
12✔
1276
        self,
1277
        venv_pex: VenvPex,
1278
        *,
1279
        description: str,
1280
        argv: Iterable[str] = (),
1281
        level: LogLevel = LogLevel.INFO,
1282
        input_digest: Digest | None = None,
1283
        working_directory: str | None = None,
1284
        extra_env: Mapping[str, str] | None = None,
1285
        output_files: Iterable[str] | None = None,
1286
        output_directories: Iterable[str] | None = None,
1287
        timeout_seconds: int | None = None,
1288
        execution_slot_variable: str | None = None,
1289
        concurrency_available: int = 0,
1290
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1291
        append_only_caches: Mapping[str, str] | None = None,
1292
    ) -> None:
UNCOV
1293
        object.__setattr__(self, "venv_pex", venv_pex)
1✔
UNCOV
1294
        object.__setattr__(self, "argv", tuple(argv))
1✔
UNCOV
1295
        object.__setattr__(self, "description", description)
1✔
UNCOV
1296
        object.__setattr__(self, "level", level)
1✔
UNCOV
1297
        object.__setattr__(self, "input_digest", input_digest)
1✔
UNCOV
1298
        object.__setattr__(self, "working_directory", working_directory)
1✔
UNCOV
1299
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
1✔
UNCOV
1300
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
1✔
UNCOV
1301
        object.__setattr__(
1✔
1302
            self, "output_directories", tuple(output_directories) if output_directories else None
1303
        )
UNCOV
1304
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
1✔
UNCOV
1305
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
1✔
UNCOV
1306
        object.__setattr__(self, "concurrency_available", concurrency_available)
1✔
UNCOV
1307
        object.__setattr__(self, "cache_scope", cache_scope)
1✔
UNCOV
1308
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
1✔
1309

1310

1311
@rule
12✔
1312
async def setup_venv_pex_process(
12✔
1313
    request: VenvPexProcess, pex_environment: PexEnvironment
1314
) -> Process:
1315
    venv_pex = request.venv_pex
×
1316
    pex_bin = (
×
1317
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1318
        if request.working_directory
1319
        else venv_pex.pex.argv0
1320
    )
1321
    argv = (pex_bin, *request.argv)
×
1322
    input_digest = (
×
1323
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1324
        if request.input_digest
1325
        else venv_pex.digest
1326
    )
1327
    append_only_caches: FrozenDict[str, str] = FrozenDict(
×
1328
        **pex_environment.in_sandbox(
1329
            working_directory=request.working_directory
1330
        ).append_only_caches,
1331
        **request.append_only_caches,
1332
        **(FrozenDict({}) if venv_pex.append_only_caches is None else venv_pex.append_only_caches),
1333
    )
1334
    return Process(
×
1335
        argv=argv,
1336
        description=request.description,
1337
        level=request.level,
1338
        input_digest=input_digest,
1339
        working_directory=request.working_directory,
1340
        env=request.extra_env,
1341
        output_files=request.output_files,
1342
        output_directories=request.output_directories,
1343
        append_only_caches=append_only_caches,
1344
        timeout_seconds=request.timeout_seconds,
1345
        execution_slot_variable=request.execution_slot_variable,
1346
        concurrency_available=request.concurrency_available,
1347
        cache_scope=request.cache_scope,
1348
    )
1349

1350

1351
@dataclass(frozen=True)
12✔
1352
class PexDistributionInfo:
12✔
1353
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1354
    repository info -v`."""
1355

1356
    project_name: str
12✔
1357
    version: packaging.version.Version
12✔
1358
    requires_python: packaging.specifiers.SpecifierSet | None
12✔
1359
    # Note: These are parsed from metadata written by the pex tool, and are always
1360
    #   a valid packaging.requirements.Requirement.
1361
    requires_dists: tuple[Requirement, ...]
12✔
1362

1363

1364
DefaultT = TypeVar("DefaultT")
12✔
1365

1366

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

1371
    def find(
12✔
1372
        self, name: str, default: DefaultT | None = None
1373
    ) -> PexDistributionInfo | DefaultT | None:
1374
        """Returns the PexDistributionInfo with the given name, first one wins."""
1375
        try:
×
1376
            return next(info for info in self if info.project_name == name)
×
1377
        except StopIteration:
×
1378
            return default
×
1379

1380

1381
def parse_repository_info(repository_info: str) -> PexResolveInfo:
12✔
1382
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
×
1383
        for line in repository_info.splitlines():
×
1384
            info = json.loads(line)
×
1385
            requires_python = info["requires_python"]
×
1386
            yield PexDistributionInfo(
×
1387
                project_name=info["project_name"],
1388
                version=packaging.version.Version(info["version"]),
1389
                requires_python=(
1390
                    packaging.specifiers.SpecifierSet(requires_python)
1391
                    if requires_python is not None
1392
                    else None
1393
                ),
1394
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1395
            )
1396

1397
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
×
1398

1399

1400
@rule
12✔
1401
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
12✔
1402
    process_result = await fallible_to_exec_result_or_raise(
×
1403
        **implicitly(
1404
            VenvPexProcess(
1405
                venv_pex,
1406
                argv=["repository", "info", "-v"],
1407
                extra_env={"PEX_TOOLS": "1"},
1408
                input_digest=venv_pex.digest,
1409
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1410
                level=LogLevel.DEBUG,
1411
            )
1412
        )
1413
    )
1414
    return parse_repository_info(process_result.stdout.decode())
×
1415

1416

1417
@rule
12✔
1418
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
12✔
1419
    process_result = await fallible_to_exec_result_or_raise(
×
1420
        **implicitly(
1421
            PexProcess(
1422
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1423
                argv=[pex.name, "repository", "info", "-v"],
1424
                input_digest=pex.digest,
1425
                extra_env={"PEX_MODULE": "pex.tools"},
1426
                description=f"Determine distributions found in {pex.name}",
1427
                level=LogLevel.DEBUG,
1428
            )
1429
        )
1430
    )
1431
    return parse_repository_info(process_result.stdout.decode())
×
1432

1433

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

© 2025 Coveralls, Inc