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

pantsbuild / pants / 20332790708

18 Dec 2025 09:48AM UTC coverage: 64.992% (-15.3%) from 80.295%
20332790708

Pull #22949

github

web-flow
Merge f730a56cd into 407284c67
Pull Request #22949: Add experimental uv resolver for Python lockfiles

54 of 97 new or added lines in 5 files covered. (55.67%)

8270 existing lines in 295 files now uncovered.

48990 of 75379 relevant lines covered (64.99%)

1.81 hits per line

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

50.58
/src/python/pants/backend/python/util_rules/pex.py
1
# Copyright 2019 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
5✔
5

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

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

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

86
logger = logging.getLogger(__name__)
5✔
87

88

89
@union(in_scope_types=[EnvironmentName])
5✔
90
@dataclass(frozen=True)
5✔
91
class PythonProvider:
5✔
92
    """Union which should have 0 or 1 implementations registered which provide Python.
93

94
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
95
    """
96

97
    interpreter_constraints: InterpreterConstraints
5✔
98

99

100
@rule(polymorphic=True)
5✔
101
async def get_python_executable(
5✔
102
    provider: PythonProvider, env_name: EnvironmentName
103
) -> PythonExecutable:
104
    raise NotImplementedError()
×
105

106

107
class PexPlatforms(DeduplicatedCollection[str]):
5✔
108
    sort_input = True
5✔
109

110
    def generate_pex_arg_list(self) -> list[str]:
5✔
111
        args = []
1✔
112
        for platform in self:
1✔
UNCOV
113
            args.extend(["--platform", platform])
×
114
        return args
1✔
115

116

117
class CompletePlatforms(DeduplicatedCollection[str]):
5✔
118
    sort_input = True
5✔
119

120
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
5✔
121
        super().__init__(iterable)
5✔
122
        self._digest = digest
5✔
123

124
    @classmethod
5✔
125
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
5✔
126
        return cls(snapshot.files, digest=snapshot.digest)
1✔
127

128
    @property
5✔
129
    def digest(self) -> Digest:
5✔
130
        return self._digest
1✔
131

132
    def generate_pex_arg_list(self) -> Iterator[str]:
5✔
133
        for path in self:
1✔
UNCOV
134
            yield "--complete-platform"
×
UNCOV
135
            yield path
×
136

137

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

162

163
@rule
5✔
164
async def digest_complete_platforms(
5✔
165
    complete_platforms: PexCompletePlatformsField,
166
) -> CompletePlatforms:
167
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
×
168

169

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

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

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

278
        self.__post_init__()
1✔
279

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

329
    def debug_hint(self) -> str:
5✔
330
        return self.output_filename
×
331

332

333
@dataclass(frozen=True)
5✔
334
class OptionalPexRequest:
5✔
335
    maybe_pex_request: PexRequest | None
5✔
336

337

338
@dataclass(frozen=True)
5✔
339
class Pex:
5✔
340
    """Wrapper for a digest containing a pex file created with some filename."""
341

342
    digest: Digest
5✔
343
    name: str
5✔
344
    python: PythonExecutable | None
5✔
345

346

347
@dataclass(frozen=True)
5✔
348
class OptionalPex:
5✔
349
    maybe_pex: Pex | None
5✔
350

351

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

367
                {bullet_list(repr(provider.__class__) for provider in python_providers)}
368
                """
369
            )
370
        )
371
    if python_providers:
×
372
        python_provider = next(iter(python_providers))
×
373
        python = await get_python_executable(
×
374
            **implicitly({python_provider(interpreter_constraints): PythonProvider})
375
        )
376
        return python
×
377

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

403
                    python = os.path.realpath(sys.executable)
404
                    print(python)
405

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

421
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
422

423
    return PythonExecutable(path=path, fingerprint=fingerprint)
×
424

425

426
@dataclass(frozen=True)
5✔
427
class BuildPexResult:
5✔
428
    result: ProcessResult
5✔
429
    pex_filename: str
5✔
430
    digest: Digest
5✔
431
    python: PythonExecutable | None
5✔
432

433
    def create_pex(self) -> Pex:
5✔
434
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
×
435

436

437
@dataclass
5✔
438
class _BuildPexPythonSetup:
5✔
439
    python: PythonExecutable | None
5✔
440
    argv: list[str]
5✔
441

442

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

UNCOV
460
    if request.python:
×
UNCOV
461
        python = request.python
×
462
    else:
UNCOV
463
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
×
464

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

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

487

488
@dataclass
5✔
489
class _BuildPexRequirementsSetup:
5✔
490
    digests: list[Digest]
5✔
491
    argv: list[str]
5✔
492
    concurrency_available: int
5✔
493

494

495
@dataclass(frozen=True)
5✔
496
class PexRequirementsInfo:
5✔
497
    req_strings: tuple[str, ...]
5✔
498
    find_links: tuple[str, ...]
5✔
499

500

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

547

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

568

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

UNCOV
587
    pex_lock_resolver_args = list(resolve_config.pex_args())
×
UNCOV
588
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
×
589

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

UNCOV
619
        return _BuildPexRequirementsSetup(
×
620
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
621
        )
622

UNCOV
623
    assert isinstance(request.requirements, PexRequirements)
×
UNCOV
624
    reqs_info = await get_req_strings(request.requirements)
×
625

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

UNCOV
631
    if isinstance(request.requirements.from_superset, Pex):
×
UNCOV
632
        repository_pex = request.requirements.from_superset
×
UNCOV
633
        return _BuildPexRequirementsSetup(
×
634
            [repository_pex.digest],
635
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
636
            concurrency_available,
637
        )
638

UNCOV
639
    elif isinstance(request.requirements.from_superset, Resolve):
×
UNCOV
640
        lockfile = await get_lockfile_for_resolve(
×
641
            request.requirements.from_superset, **implicitly()
642
        )
UNCOV
643
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
644

645
        # NB: This is also validated in the constructor.
UNCOV
646
        assert loaded_lockfile.is_pex_native
×
UNCOV
647
        if not reqs_info.req_strings:
×
648
            return _BuildPexRequirementsSetup([], [], concurrency_available)
×
649

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

UNCOV
666
        return _BuildPexRequirementsSetup(
×
667
            [loaded_lockfile.lockfile_digest],
668
            [
669
                *reqs_info.req_strings,
670
                "--lock",
671
                loaded_lockfile.lockfile_path,
672
                *pex_lock_resolver_args,
673
            ],
674
            concurrency_available,
675
        )
676

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

695

696
@rule(level=LogLevel.DEBUG)
5✔
697
async def build_pex(
5✔
698
    request: PexRequest, python_setup: PythonSetup, pex_subsystem: PexSubsystem
699
) -> BuildPexResult:
700
    """Returns a PEX with the given settings."""
701

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

713
        if resolve_name:
×
714
            request = dataclasses.replace(
×
715
                request,
716
                interpreter_constraints=InterpreterConstraints(
717
                    python_setup.resolves_to_interpreter_constraints.get(
718
                        resolve_name,
719
                        python_setup.interpreter_constraints,
720
                    )
721
                ),
722
            )
723

724
    source_dir_name = "source_files"
×
725

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

752
    argv = [
×
753
        "--output-file",
754
        request.output_filename,
755
        *request.additional_args,
756
    ]
757

758
    argv.extend(pex_python_setup.argv)
×
759

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

771
    argv.extend(
×
772
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
773
    )
774
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
×
775

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

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

788
    argv.append(f"--sources-directory={source_dir_name}")
×
789

790
    # Include any additional arguments and input digests required by the requirements.
791
    argv.extend(requirements_setup.argv)
×
792

793
    merged_digest = await merge_digests(
×
794
        MergeDigests(
795
            (
796
                request.complete_platforms.digest,
797
                sources_digest_as_subdir,
798
                request.additional_inputs,
799
                *requirements_setup.digests,
800
                *(pex.digest for pex in request.pex_path),
801
            )
802
        )
803
    )
804

805
    argv.extend(["--layout", request.layout.value])
×
806

807
    pex_output_files: Iterable[str] | None = None
×
808
    pex_output_directories: Iterable[str] | None = None
×
809
    if PexLayout.ZIPAPP == request.layout:
×
810
        pex_output_files = [request.output_filename]
×
811
    else:
812
        pex_output_directories = [request.output_filename]
×
813

814
    output_files = (
×
815
        *(pex_output_files if pex_output_files else []),
816
        *(request.scie_output_files if request.scie_output_files else []),
817
    )
818
    output_directories = (
×
819
        *(pex_output_directories if pex_output_directories else []),
820
        *(request.scie_output_directories if request.scie_output_directories else []),
821
    )
822

823
    result = await fallible_to_exec_result_or_raise(
×
824
        **implicitly(
825
            PexCliProcess(
826
                subcommand=(),
827
                extra_args=argv,
828
                additional_input_digest=merged_digest,
829
                description=_build_pex_description(request, req_strings, python_setup.resolves),
830
                output_files=output_files if output_files else None,
831
                output_directories=output_directories if output_directories else None,
832
                concurrency_available=requirements_setup.concurrency_available,
833
                cache_scope=request.cache_scope,
834
            )
835
        )
836
    )
837

838
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
839

840
    digest = (
×
841
        await merge_digests(
842
            MergeDigests((result.output_digest, *(pex.digest for pex in request.pex_path)))
843
        )
844
        if request.pex_path
845
        else result.output_digest
846
    )
847

848
    return BuildPexResult(
×
849
        result=result,
850
        pex_filename=request.output_filename,
851
        digest=digest,
852
        python=pex_python_setup.python,
853
    )
854

855

856
def _build_pex_description(
5✔
857
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
858
) -> str:
UNCOV
859
    if request.description:
×
UNCOV
860
        return request.description
×
861

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

898

899
@rule
5✔
900
async def create_pex(request: PexRequest) -> Pex:
5✔
901
    result = await build_pex(request, **implicitly())
×
902
    return result.create_pex()
×
903

904

905
@rule
5✔
906
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
5✔
907
    if request.maybe_pex_request is None:
×
908
        return OptionalPex(None)
×
909
    result = await create_pex(request.maybe_pex_request)
×
910
    return OptionalPex(result)
×
911

912

913
@dataclass(frozen=True)
5✔
914
class Script:
5✔
915
    path: PurePath
5✔
916

917
    @property
5✔
918
    def argv0(self) -> str:
5✔
919
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
×
920

921

922
@dataclass(frozen=True)
5✔
923
class VenvScript:
5✔
924
    script: Script
5✔
925
    content: FileContent
5✔
926

927

928
@dataclass(frozen=True)
5✔
929
class VenvScriptWriter:
5✔
930
    complete_pex_env: CompletePexEnvironment
5✔
931
    pex: Pex
5✔
932
    venv_dir: PurePath
5✔
933

934
    @classmethod
5✔
935
    def create(
5✔
936
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
937
    ) -> VenvScriptWriter:
938
        # N.B.: We don't know the working directory that will be used in any given
939
        # invocation of the venv scripts; so we deal with working_directory once in an
940
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
941
        # CWD offset math in every rule for all the relative paths their process depends on.
942
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
×
943
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
×
944

945
    def _create_venv_script(
5✔
946
        self,
947
        bash: BashBinary,
948
        *,
949
        script_path: PurePath,
950
        venv_executable: PurePath,
951
    ) -> VenvScript:
952
        env_vars = (
×
953
            f"{name}={shlex.quote(value)}"
954
            for name, value in self.complete_pex_env.environment_dict(
955
                python=self.pex.python
956
            ).items()
957
        )
958

959
        target_venv_executable = shlex.quote(str(venv_executable))
×
960
        venv_dir = shlex.quote(str(self.venv_dir))
×
961
        execute_pex_args = " ".join(
×
962
            f"$(adjust_relative_paths {shlex.quote(arg)})"
963
            for arg in self.complete_pex_env.create_argv(self.pex.name)
964
        )
965

966
        script = dedent(
×
967
            f"""\
968
            #!{bash.path}
969
            set -euo pipefail
970

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

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

1001
            export {" ".join(env_vars)}
1002
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1003

1004
            execute_pex_args="{execute_pex_args}"
1005
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1006
            venv_dir="$(adjust_relative_paths {venv_dir})"
1007

1008
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1009
            # with tools support.
1010
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1011
              exec ${{execute_pex_args}} "$@"
1012
            fi
1013

1014
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1015
            # `--venv` mode PEX file.
1016
            if [ ! -e "${{venv_dir}}" ]; then
1017
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1018
            fi
1019

1020
            exec "${{target_venv_executable}}" "$@"
1021
            """
1022
        )
1023
        return VenvScript(
×
1024
            script=Script(script_path),
1025
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1026
        )
1027

1028
    def exe(self, bash: BashBinary) -> VenvScript:
5✔
1029
        """Writes a safe shim for the venv's executable `pex` script."""
1030
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
×
1031
        return self._create_venv_script(
×
1032
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1033
        )
1034

1035
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
5✔
1036
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1037
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
×
1038
        return self._create_venv_script(
×
1039
            bash,
1040
            script_path=script_path,
1041
            venv_executable=self.venv_dir / "bin" / name,
1042
        )
1043

1044
    def python(self, bash: BashBinary) -> VenvScript:
5✔
1045
        """Writes a safe shim for the venv's python binary."""
1046
        return self.bin(bash, "python")
×
1047

1048

1049
@dataclass(frozen=True)
5✔
1050
class VenvPex:
5✔
1051
    digest: Digest
5✔
1052
    append_only_caches: FrozenDict[str, str] | None
5✔
1053
    pex_filename: str
5✔
1054
    pex: Script
5✔
1055
    python: Script
5✔
1056
    bin: FrozenDict[str, Script]
5✔
1057
    venv_rel_dir: str
5✔
1058

1059

1060
@dataclass(frozen=True)
5✔
1061
class VenvPexRequest:
5✔
1062
    pex_request: PexRequest
5✔
1063
    complete_pex_env: CompletePexEnvironment
5✔
1064
    bin_names: tuple[str, ...] = ()
5✔
1065
    site_packages_copies: bool = False
5✔
1066

1067
    def __init__(
5✔
1068
        self,
1069
        pex_request: PexRequest,
1070
        complete_pex_env: CompletePexEnvironment,
1071
        bin_names: Iterable[str] = (),
1072
        site_packages_copies: bool = False,
1073
    ) -> None:
1074
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1075

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

1089

1090
@rule
5✔
1091
async def wrap_venv_prex_request(
5✔
1092
    pex_request: PexRequest, pex_environment: PexEnvironment
1093
) -> VenvPexRequest:
1094
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1095
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
×
1096

1097

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

1128
    pex_request = request.pex_request
×
1129
    seeded_venv_request = dataclasses.replace(
×
1130
        pex_request,
1131
        additional_args=pex_request.additional_args
1132
        + (
1133
            "--venv",
1134
            "prepend",
1135
            "--seed",
1136
            "verbose",
1137
            pex_environment.venv_site_packages_copies_option(
1138
                use_copies=request.site_packages_copies
1139
            ),
1140
        ),
1141
    )
1142
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
×
1143
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1144
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1145
    # directory.
1146
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
×
1147
    abs_pex_root = PurePath(seed_info["pex_root"])
×
1148
    abs_pex_path = PurePath(seed_info["pex"])
×
1149
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
×
1150

1151
    venv_script_writer = VenvScriptWriter.create(
×
1152
        complete_pex_env=request.complete_pex_env,
1153
        pex=venv_pex_result.create_pex(),
1154
        venv_rel_dir=venv_rel_dir,
1155
    )
1156
    pex = venv_script_writer.exe(bash)
×
1157
    python = venv_script_writer.python(bash)
×
1158
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
×
1159
    scripts_digest = await create_digest(
×
1160
        CreateDigest(
1161
            (
1162
                pex.content,
1163
                python.content,
1164
                *(venv_script.content for venv_script in scripts.values()),
1165
            )
1166
        )
1167
    )
1168
    input_digest = await merge_digests(
×
1169
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1170
    )
1171
    append_only_caches = (
×
1172
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1173
    )
1174

1175
    return VenvPex(
×
1176
        digest=input_digest,
1177
        append_only_caches=append_only_caches,
1178
        pex_filename=venv_pex_result.pex_filename,
1179
        pex=pex.script,
1180
        python=python.script,
1181
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1182
        venv_rel_dir=venv_rel_dir.as_posix(),
1183
    )
1184

1185

1186
@dataclass(frozen=True)
5✔
1187
class PexProcess:
5✔
1188
    pex: Pex
5✔
1189
    argv: tuple[str, ...]
5✔
1190
    description: str = dataclasses.field(compare=False)
5✔
1191
    level: LogLevel
5✔
1192
    input_digest: Digest | None
5✔
1193
    working_directory: str | None
5✔
1194
    extra_env: FrozenDict[str, str]
5✔
1195
    output_files: tuple[str, ...] | None
5✔
1196
    output_directories: tuple[str, ...] | None
5✔
1197
    timeout_seconds: int | None
5✔
1198
    execution_slot_variable: str | None
5✔
1199
    concurrency_available: int
5✔
1200
    cache_scope: ProcessCacheScope
5✔
1201

1202
    def __init__(
5✔
1203
        self,
1204
        pex: Pex,
1205
        *,
1206
        description: str,
1207
        argv: Iterable[str] = (),
1208
        level: LogLevel = LogLevel.INFO,
1209
        input_digest: Digest | None = None,
1210
        working_directory: str | None = None,
1211
        extra_env: Mapping[str, str] | None = None,
1212
        output_files: Iterable[str] | None = None,
1213
        output_directories: Iterable[str] | None = None,
1214
        timeout_seconds: int | None = None,
1215
        execution_slot_variable: str | None = None,
1216
        concurrency_available: int = 0,
1217
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1218
    ) -> None:
UNCOV
1219
        object.__setattr__(self, "pex", pex)
×
UNCOV
1220
        object.__setattr__(self, "argv", tuple(argv))
×
UNCOV
1221
        object.__setattr__(self, "description", description)
×
UNCOV
1222
        object.__setattr__(self, "level", level)
×
UNCOV
1223
        object.__setattr__(self, "input_digest", input_digest)
×
UNCOV
1224
        object.__setattr__(self, "working_directory", working_directory)
×
UNCOV
1225
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
×
UNCOV
1226
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
×
UNCOV
1227
        object.__setattr__(
×
1228
            self, "output_directories", tuple(output_directories) if output_directories else None
1229
        )
UNCOV
1230
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
×
UNCOV
1231
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
×
UNCOV
1232
        object.__setattr__(self, "concurrency_available", concurrency_available)
×
UNCOV
1233
        object.__setattr__(self, "cache_scope", cache_scope)
×
1234

1235

1236
@rule
5✔
1237
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
5✔
1238
    pex = request.pex
×
1239
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
×
1240
    argv = complete_pex_env.create_argv(pex.name, *request.argv)
×
1241
    env = {
×
1242
        **complete_pex_env.environment_dict(python=pex.python),
1243
        **request.extra_env,
1244
    }
1245
    input_digest = (
×
1246
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1247
        if request.input_digest
1248
        else pex.digest
1249
    )
1250
    append_only_caches = (
×
1251
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1252
    )
1253
    return Process(
×
1254
        argv,
1255
        description=request.description,
1256
        level=request.level,
1257
        input_digest=input_digest,
1258
        working_directory=request.working_directory,
1259
        env=env,
1260
        output_files=request.output_files,
1261
        output_directories=request.output_directories,
1262
        append_only_caches={
1263
            **complete_pex_env.append_only_caches,
1264
            **append_only_caches,
1265
        },
1266
        timeout_seconds=request.timeout_seconds,
1267
        execution_slot_variable=request.execution_slot_variable,
1268
        concurrency_available=request.concurrency_available,
1269
        cache_scope=request.cache_scope,
1270
    )
1271

1272

1273
@dataclass(unsafe_hash=True)
5✔
1274
class VenvPexProcess:
5✔
1275
    venv_pex: VenvPex
5✔
1276
    argv: tuple[str, ...]
5✔
1277
    description: str = dataclasses.field(compare=False)
5✔
1278
    level: LogLevel
5✔
1279
    input_digest: Digest | None
5✔
1280
    working_directory: str | None
5✔
1281
    extra_env: FrozenDict[str, str]
5✔
1282
    output_files: tuple[str, ...] | None
5✔
1283
    output_directories: tuple[str, ...] | None
5✔
1284
    timeout_seconds: int | None
5✔
1285
    execution_slot_variable: str | None
5✔
1286
    concurrency_available: int
5✔
1287
    cache_scope: ProcessCacheScope
5✔
1288
    append_only_caches: FrozenDict[str, str]
5✔
1289

1290
    def __init__(
5✔
1291
        self,
1292
        venv_pex: VenvPex,
1293
        *,
1294
        description: str,
1295
        argv: Iterable[str] = (),
1296
        level: LogLevel = LogLevel.INFO,
1297
        input_digest: Digest | None = None,
1298
        working_directory: str | None = None,
1299
        extra_env: Mapping[str, str] | None = None,
1300
        output_files: Iterable[str] | None = None,
1301
        output_directories: Iterable[str] | None = None,
1302
        timeout_seconds: int | None = None,
1303
        execution_slot_variable: str | None = None,
1304
        concurrency_available: int = 0,
1305
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1306
        append_only_caches: Mapping[str, str] | None = None,
1307
    ) -> None:
UNCOV
1308
        object.__setattr__(self, "venv_pex", venv_pex)
×
UNCOV
1309
        object.__setattr__(self, "argv", tuple(argv))
×
UNCOV
1310
        object.__setattr__(self, "description", description)
×
UNCOV
1311
        object.__setattr__(self, "level", level)
×
UNCOV
1312
        object.__setattr__(self, "input_digest", input_digest)
×
UNCOV
1313
        object.__setattr__(self, "working_directory", working_directory)
×
UNCOV
1314
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
×
UNCOV
1315
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
×
UNCOV
1316
        object.__setattr__(
×
1317
            self, "output_directories", tuple(output_directories) if output_directories else None
1318
        )
UNCOV
1319
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
×
UNCOV
1320
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
×
UNCOV
1321
        object.__setattr__(self, "concurrency_available", concurrency_available)
×
UNCOV
1322
        object.__setattr__(self, "cache_scope", cache_scope)
×
UNCOV
1323
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
×
1324

1325

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

1365

1366
@dataclass(frozen=True)
5✔
1367
class PexDistributionInfo:
5✔
1368
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1369
    repository info -v`."""
1370

1371
    project_name: str
5✔
1372
    version: packaging.version.Version
5✔
1373
    requires_python: packaging.specifiers.SpecifierSet | None
5✔
1374
    # Note: These are parsed from metadata written by the pex tool, and are always
1375
    #   a valid packaging.requirements.Requirement.
1376
    requires_dists: tuple[Requirement, ...]
5✔
1377

1378

1379
DefaultT = TypeVar("DefaultT")
5✔
1380

1381

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

1386
    def find(
5✔
1387
        self, name: str, default: DefaultT | None = None
1388
    ) -> PexDistributionInfo | DefaultT | None:
1389
        """Returns the PexDistributionInfo with the given name, first one wins."""
1390
        try:
×
1391
            return next(info for info in self if info.project_name == name)
×
1392
        except StopIteration:
×
1393
            return default
×
1394

1395

1396
def parse_repository_info(repository_info: str) -> PexResolveInfo:
5✔
1397
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
×
1398
        for line in repository_info.splitlines():
×
1399
            info = json.loads(line)
×
1400
            requires_python = info["requires_python"]
×
1401
            yield PexDistributionInfo(
×
1402
                project_name=info["project_name"],
1403
                version=packaging.version.Version(info["version"]),
1404
                requires_python=(
1405
                    packaging.specifiers.SpecifierSet(requires_python)
1406
                    if requires_python is not None
1407
                    else None
1408
                ),
1409
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1410
            )
1411

1412
    return PexResolveInfo(sorted(iter_dist_info(), key=lambda dist: dist.project_name))
×
1413

1414

1415
@rule
5✔
1416
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
5✔
1417
    process_result = await fallible_to_exec_result_or_raise(
×
1418
        **implicitly(
1419
            VenvPexProcess(
1420
                venv_pex,
1421
                argv=["repository", "info", "-v"],
1422
                extra_env={"PEX_TOOLS": "1"},
1423
                input_digest=venv_pex.digest,
1424
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1425
                level=LogLevel.DEBUG,
1426
            )
1427
        )
1428
    )
1429
    return parse_repository_info(process_result.stdout.decode())
×
1430

1431

1432
@rule
5✔
1433
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
5✔
1434
    process_result = await fallible_to_exec_result_or_raise(
×
1435
        **implicitly(
1436
            PexProcess(
1437
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1438
                argv=[pex.name, "repository", "info", "-v"],
1439
                input_digest=pex.digest,
1440
                extra_env={"PEX_MODULE": "pex.tools"},
1441
                description=f"Determine distributions found in {pex.name}",
1442
                level=LogLevel.DEBUG,
1443
            )
1444
        )
1445
    )
1446
    return parse_repository_info(process_result.stdout.decode())
×
1447

1448

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