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

pantsbuild / pants / 24021276439

06 Apr 2026 06:18AM UTC coverage: 92.908%. Remained the same
24021276439

Pull #23218

github

web-flow
Merge e40bf58db into e1a14d703
Pull Request #23218: Fix uv PEX builder for VCS and direct URL requirements

38 of 40 new or added lines in 2 files covered. (95.0%)

3 existing lines in 2 files now uncovered.

91578 of 98568 relevant lines covered (92.91%)

4.04 hits per line

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

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

4
from __future__ import annotations
12✔
5

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

18
import packaging.specifiers
12✔
19
import packaging.version
12✔
20
from packaging.requirements import Requirement
12✔
21
from packaging.utils import canonicalize_name as canonicalize_project_name
12✔
22

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

107
logger = logging.getLogger(__name__)
12✔
108

109

110
@union(in_scope_types=[EnvironmentName])
12✔
111
@dataclass(frozen=True)
12✔
112
class PythonProvider:
12✔
113
    """Union which should have 0 or 1 implementations registered which provide Python.
114

115
    Subclasses should provide a rule from their subclass type to `PythonExecutable`.
116
    """
117

118
    interpreter_constraints: InterpreterConstraints
12✔
119

120

121
@rule(polymorphic=True)
12✔
122
async def get_python_executable(
12✔
123
    provider: PythonProvider, env_name: EnvironmentName
124
) -> PythonExecutable:
125
    raise NotImplementedError()
×
126

127

128
class PexPlatforms(DeduplicatedCollection[str]):
12✔
129
    sort_input = True
12✔
130

131
    def generate_pex_arg_list(self) -> list[str]:
12✔
132
        args = []
4✔
133
        for platform in self:
4✔
134
            args.extend(["--platform", platform])
2✔
135
        return args
4✔
136

137

138
class CompletePlatforms(DeduplicatedCollection[str]):
12✔
139
    sort_input = True
12✔
140

141
    def __init__(self, iterable: Iterable[str] = (), *, digest: Digest = EMPTY_DIGEST):
12✔
142
        super().__init__(iterable)
12✔
143
        self._digest = digest
12✔
144

145
    @classmethod
12✔
146
    def from_snapshot(cls, snapshot: Snapshot) -> CompletePlatforms:
12✔
147
        return cls(snapshot.files, digest=snapshot.digest)
7✔
148

149
    @property
12✔
150
    def digest(self) -> Digest:
12✔
151
        return self._digest
12✔
152

153
    def generate_pex_arg_list(self) -> Iterator[str]:
12✔
154
        for path in self:
4✔
155
            yield "--complete-platform"
4✔
156
            yield path
4✔
157

158

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

183

184
@rule
12✔
185
async def digest_complete_platforms(
12✔
186
    complete_platforms: PexCompletePlatformsField,
187
) -> CompletePlatforms:
188
    return await digest_complete_platform_addresses(complete_platforms.to_unparsed_address_inputs())
7✔
189

190

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

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

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

291
        self.__post_init__()
12✔
292

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

342
    def debug_hint(self) -> str:
12✔
343
        return self.output_filename
×
344

345

346
@dataclass(frozen=True)
12✔
347
class OptionalPexRequest:
12✔
348
    maybe_pex_request: PexRequest | None
12✔
349

350

351
@dataclass(frozen=True)
12✔
352
class Pex:
12✔
353
    """Wrapper for a digest containing a pex file created with some filename."""
354

355
    digest: Digest
12✔
356
    name: str
12✔
357
    python: PythonExecutable | None
12✔
358

359

360
@dataclass(frozen=True)
12✔
361
class OptionalPex:
12✔
362
    maybe_pex: Pex | None
12✔
363

364

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

380
                {bullet_list(repr(provider.__class__) for provider in python_providers)}
381
                """
382
            )
383
        )
384
    if python_providers:
12✔
385
        python_provider = next(iter(python_providers))
5✔
386
        python = await get_python_executable(
5✔
387
            **implicitly({python_provider(interpreter_constraints): PythonProvider})
388
        )
389
        return python
5✔
390

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

416
                    python = os.path.realpath(sys.executable)
417
                    print(python)
418

419
                    hasher = hashlib.sha256()
420
                    with open(python, "rb") as fp:
421
                      for chunk in iter(lambda: fp.read(8192), b""):
422
                          hasher.update(chunk)
423
                    print(hasher.hexdigest())
424
                    """
425
                    ),
426
                ),
427
                level=LogLevel.DEBUG,
428
                cache_scope=env_target.executable_search_path_cache_scope(),
429
            )
430
        )
431
    )
432
    path, fingerprint = result.stdout.decode().strip().splitlines()
12✔
433

434
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
435

436
    return PythonExecutable(path=path, fingerprint=fingerprint)
12✔
437

438

439
@dataclass(frozen=True)
12✔
440
class BuildPexResult:
12✔
441
    result: ProcessResult
12✔
442
    pex_filename: str
12✔
443
    digest: Digest
12✔
444
    python: PythonExecutable | None
12✔
445

446
    def create_pex(self) -> Pex:
12✔
447
        return Pex(digest=self.digest, name=self.pex_filename, python=self.python)
12✔
448

449

450
@dataclass
12✔
451
class _BuildPexPythonSetup:
12✔
452
    python: PythonExecutable | None
12✔
453
    argv: list[str]
12✔
454

455

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

473
    if request.python:
12✔
474
        python = request.python
3✔
475
    else:
476
        python = await find_interpreter(request.interpreter_constraints, **implicitly())
12✔
477

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

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

500

501
_UV_VENV_DIR = "__uv_venv"
12✔
502

503

504
@dataclass(frozen=True)
12✔
505
class _UvVenvRequest:
12✔
506
    """Request to build a pre-populated venv using uv for PEX --venv-repository."""
507

508
    req_strings: tuple[str, ...]
12✔
509
    requirements: PexRequirements | EntireLockfile
12✔
510
    python_path: str
12✔
511
    description: str
12✔
512

513

514
@dataclass(frozen=True)
12✔
515
class _UvVenvResult:
12✔
516
    """Result of building a uv venv."""
517

518
    venv_digest: Digest | None
12✔
519

520

521
def _check_uv_preconditions(
12✔
522
    request: PexRequest,
523
    req_strings: tuple[str, ...],
524
    pex_python_setup: _BuildPexPythonSetup,
525
) -> str | None:
526
    """Check whether the uv builder can be used for this PEX request.
527

528
    Returns None if all preconditions are met, or a warning message describing
529
    why uv cannot be used.
530
    """
531
    label = request.description or request.output_filename
1✔
532
    if not req_strings:
1✔
533
        return (
×
534
            f"pex_builder=uv: no individual requirement strings for {label} "
535
            "(e.g. using a whole-lockfile resolve or no third-party deps). "
536
            "Falling back to the default PEX/pip builder."
537
        )
538
    if request.platforms or request.complete_platforms:
1✔
539
        return (
×
540
            f"pex_builder=uv: cross-platform build detected for {label}. "
541
            "Falling back to the default PEX/pip builder."
542
        )
543
    if pex_python_setup.python is None:
1✔
544
        return (
×
545
            f"pex_builder=uv: no local Python interpreter available for {label}. "
546
            "Falling back to the default PEX/pip builder."
547
        )
548
    return None
1✔
549

550

551
def _parse_direct_ref_names(top_level_requirements: tuple[str, ...]) -> frozenset[str]:
12✔
552
    """Extract canonicalized names from direct references in lockfile requirements.
553

554
    Assumes PEX-serialized requirement strings normalize to ``name @ url``.
555
    """
556
    names: set[str] = set()
1✔
557
    for req_str in top_level_requirements:
1✔
558
        if not isinstance(req_str, str) or " @ " not in req_str:
1✔
559
            continue
1✔
560
        name_part = req_str.split(" @ ", 1)[0]
1✔
561
        name = name_part.split("[", 1)[0].strip()
1✔
562
        names.add(canonicalize_project_name(name))
1✔
563
    return frozenset(names)
1✔
564

565

566
def _format_lockfile_requirement(req: dict, direct_ref_names: frozenset[str] = frozenset()) -> str:
12✔
567
    """Format a locked requirement for uv as ``name @ url`` or ``name==version``."""
568
    name = req["project_name"]
1✔
569
    normalized = canonicalize_project_name(name)
1✔
570
    if normalized in direct_ref_names:
1✔
571
        artifacts: Sequence = req.get("artifacts") or ()
1✔
572
        first = artifacts[0] if len(artifacts) >= 1 else None
1✔
573
        if isinstance(first, Mapping):
1✔
574
            url = first.get("url")
1✔
575
            if isinstance(url, str) and url:
1✔
576
                return f"{name} @ {url}"
1✔
577
    return f"{name}=={req['version']}"
1✔
578

579

580
@rule
12✔
581
async def _build_uv_venv(
12✔
582
    uv_request: _UvVenvRequest,
583
    pex_env: PexEnvironment,
584
) -> _UvVenvResult:
585
    """Build a pre-populated venv using uv for use with PEX --venv-repository."""
586
    downloaded_uv = await download_uv_binary(**implicitly())
1✔
587

588
    logger.debug(
1✔
589
        "pex_builder=uv: using uv builder for %s",
590
        uv_request.description,
591
    )
592

593
    # Try to extract the full resolved package list from the lockfile
594
    # so we can pass pinned versions with --no-deps (reproducible).
595
    # Fall back to letting uv resolve transitively if no lockfile.
596
    all_resolved_reqs: tuple[str, ...] = ()
1✔
597
    if isinstance(uv_request.requirements, PexRequirements) and isinstance(
1✔
598
        uv_request.requirements.from_superset, Resolve
599
    ):
600
        lockfile = await get_lockfile_for_resolve(
×
601
            uv_request.requirements.from_superset, **implicitly()
602
        )
603
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
×
604
        if loaded_lockfile.is_pex_native:
×
605
            try:
×
606
                digest_contents = await get_digest_contents(loaded_lockfile.lockfile_digest)
×
607
                lockfile_bytes = next(
×
608
                    c.content for c in digest_contents if c.path == loaded_lockfile.lockfile_path
609
                )
610
                lockfile_data = json.loads(lockfile_bytes)
×
NEW
611
                direct_ref_names = _parse_direct_ref_names(
×
612
                    tuple(lockfile_data.get("requirements") or ())
613
                )
UNCOV
614
                all_resolved_reqs = tuple(
×
615
                    _format_lockfile_requirement(req, direct_ref_names)
616
                    for resolve in lockfile_data.get("locked_resolves", ())
617
                    for req in resolve.get("locked_requirements", ())
618
                )
NEW
619
            except (
×
620
                json.JSONDecodeError,
621
                KeyError,
622
                StopIteration,
623
                TypeError,
624
                AttributeError,
625
            ) as e:
UNCOV
626
                logger.warning(
×
627
                    "pex_builder=uv: failed to parse lockfile for %s: %s. "
628
                    "Falling back to transitive uv resolution.",
629
                    uv_request.description,
630
                    e,
631
                )
632
                all_resolved_reqs = ()
×
633

634
    uv_reqs = all_resolved_reqs or uv_request.req_strings
1✔
635

636
    if all_resolved_reqs:
1✔
637
        logger.debug(
×
638
            "pex_builder=uv: using %d pinned packages from lockfile with --no-deps for %s",
639
            len(all_resolved_reqs),
640
            uv_request.description,
641
        )
642
    else:
643
        logger.debug(
1✔
644
            "pex_builder=uv: no lockfile available, using transitive uv resolution for %s",
645
            uv_request.description,
646
        )
647

648
    reqs_file = "__uv_requirements.txt"
1✔
649
    reqs_content = "\n".join(uv_reqs) + "\n"
1✔
650
    reqs_digest = await create_digest(CreateDigest([FileContent(reqs_file, reqs_content.encode())]))
1✔
651

652
    complete_pex_env = pex_env.in_sandbox(working_directory=None)
1✔
653
    uv_cache_dir = ".cache/uv_cache"
1✔
654
    uv_env = {
1✔
655
        **complete_pex_env.environment_dict(python_configured=True),
656
        "UV_CACHE_DIR": uv_cache_dir,
657
        "UV_NO_CONFIG": "1",
658
    }
659
    uv_caches = {
1✔
660
        **complete_pex_env.append_only_caches,
661
        "uv_cache": uv_cache_dir,
662
    }
663
    uv_tmpdir = "__uv_tmp"
1✔
664
    tmpdir_digest = await create_digest(CreateDigest([Directory(uv_tmpdir)]))
1✔
665

666
    python_path = uv_request.python_path
1✔
667

668
    uv_input = await merge_digests(MergeDigests([downloaded_uv.digest, reqs_digest, tmpdir_digest]))
1✔
669

670
    # Step 1: Create venv with uv.
671
    venv_result = await execute_process_or_raise(
1✔
672
        **implicitly(
673
            Process(
674
                argv=(
675
                    downloaded_uv.exe,
676
                    "venv",
677
                    _UV_VENV_DIR,
678
                    "--python",
679
                    python_path,
680
                ),
681
                input_digest=uv_input,
682
                output_directories=(_UV_VENV_DIR,),
683
                env={**uv_env, "TMPDIR": uv_tmpdir},
684
                append_only_caches=uv_caches,
685
                description=f"Create uv venv for {uv_request.description}",
686
                level=LogLevel.DEBUG,
687
                cache_scope=ProcessCacheScope.SUCCESSFUL,
688
            )
689
        )
690
    )
691

692
    # Step 2: Install dependencies into the venv.
693
    install_input = await merge_digests(MergeDigests([uv_input, venv_result.output_digest]))
1✔
694

695
    install_argv: tuple[str, ...] = (
1✔
696
        downloaded_uv.exe,
697
        "pip",
698
        "install",
699
        "--python",
700
        os.path.join(_UV_VENV_DIR, "bin", "python"),
701
        "-r",
702
        reqs_file,
703
        *(("--no-deps",) if all_resolved_reqs else ()),
704
        *downloaded_uv.args_for_uv_pip_install,
705
    )
706

707
    uv_install_result = await execute_process_or_raise(
1✔
708
        **implicitly(
709
            Process(
710
                argv=install_argv,
711
                input_digest=install_input,
712
                output_directories=(_UV_VENV_DIR,),
713
                env={**uv_env, "TMPDIR": uv_tmpdir},
714
                append_only_caches=uv_caches,
715
                description=f"uv pip install for {uv_request.description}",
716
                level=LogLevel.DEBUG,
717
                cache_scope=ProcessCacheScope.SUCCESSFUL,
718
            )
719
        )
720
    )
721

722
    return _UvVenvResult(
1✔
723
        venv_digest=uv_install_result.output_digest,
724
    )
725

726

727
@dataclass
12✔
728
class _BuildPexRequirementsSetup:
12✔
729
    digests: list[Digest]
12✔
730
    argv: list[str]
12✔
731
    concurrency_available: int
12✔
732

733

734
@dataclass(frozen=True)
12✔
735
class PexRequirementsInfo:
12✔
736
    req_strings: tuple[str, ...]
12✔
737
    find_links: tuple[str, ...]
12✔
738

739

740
@rule
12✔
741
async def get_req_strings(pex_reqs: PexRequirements) -> PexRequirementsInfo:
12✔
742
    addrs: list[Address] = []
12✔
743
    specs: list[str] = []
12✔
744
    req_strings: list[str] = []
12✔
745
    find_links: set[str] = set()
12✔
746
    for req_str_or_addr in pex_reqs.req_strings_or_addrs:
12✔
747
        if isinstance(req_str_or_addr, Address):
11✔
748
            addrs.append(req_str_or_addr)
11✔
749
        else:
750
            assert isinstance(req_str_or_addr, str)
6✔
751
            # Require a `//` prefix, to distinguish address specs from
752
            # local or VCS requirements.
753
            if req_str_or_addr.startswith(os.path.sep * 2):
6✔
754
                specs.append(req_str_or_addr)
×
755
            else:
756
                req_strings.append(req_str_or_addr)
6✔
757
    if specs:
12✔
758
        addrs_from_specs = await resolve_unparsed_address_inputs(
×
759
            UnparsedAddressInputs(
760
                specs,
761
                owning_address=None,
762
                description_of_origin=pex_reqs.description_of_origin,
763
            ),
764
            **implicitly(),
765
        )
766
        addrs.extend(addrs_from_specs)
×
767
    if addrs:
12✔
768
        transitive_targets = await transitive_targets_get(
11✔
769
            TransitiveTargetsRequest(addrs), **implicitly()
770
        )
771
        req_strings.extend(
11✔
772
            PexRequirements.req_strings_from_requirement_fields(
773
                tgt[PythonRequirementsField]
774
                for tgt in transitive_targets.closure
775
                if tgt.has_field(PythonRequirementsField)
776
            )
777
        )
778
        find_links.update(
11✔
779
            find_links
780
            for tgt in transitive_targets.closure
781
            if tgt.has_field(PythonRequirementFindLinksField)
782
            for find_links in tgt[PythonRequirementFindLinksField].value or ()
783
        )
784
    return PexRequirementsInfo(tuple(sorted(req_strings)), tuple(sorted(find_links)))
12✔
785

786

787
async def _get_entire_lockfile_and_requirements(
12✔
788
    requirements: EntireLockfile | PexRequirements,
789
) -> tuple[LoadedLockfile | None, tuple[str, ...]]:
790
    lockfile: Lockfile | None = None
12✔
791
    complete_req_strings: tuple[str, ...] = tuple()
12✔
792
    # TODO: This is clunky, but can be simplified once we get rid of old-style tool
793
    #  lockfiles, because we can unify EntireLockfile and Resolve.
794
    if isinstance(requirements, EntireLockfile):
12✔
795
        complete_req_strings = requirements.complete_req_strings or tuple()
12✔
796
        lockfile = requirements.lockfile
12✔
797
    elif (
12✔
798
        isinstance(requirements.from_superset, Resolve)
799
        and requirements.from_superset.use_entire_lockfile
800
    ):
801
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
5✔
802
    if not lockfile:
12✔
803
        return None, complete_req_strings
11✔
804
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
12✔
805
    return loaded_lockfile, complete_req_strings
12✔
806

807

808
@rule
12✔
809
async def _setup_pex_requirements(
12✔
810
    request: PexRequest, python_setup: PythonSetup
811
) -> _BuildPexRequirementsSetup:
812
    resolve_name: str | None
813
    if isinstance(request.requirements, EntireLockfile):
12✔
814
        resolve_name = request.requirements.lockfile.resolve_name
12✔
815
    elif isinstance(request.requirements.from_superset, Resolve):
12✔
816
        resolve_name = request.requirements.from_superset.name
6✔
817
    else:
818
        # This implies that, currently, per-resolve options are only configurable for resolves.
819
        # However, if no resolve is specified, we will still load options that apply to every
820
        # resolve, like `[python-repos].indexes`.
821
        resolve_name = None
11✔
822
    resolve_config = await determine_resolve_pex_config(
12✔
823
        ResolvePexConfigRequest(resolve_name), **implicitly()
824
    )
825

826
    pex_lock_resolver_args = list(resolve_config.pex_args())
12✔
827
    pip_resolver_args = [*resolve_config.pex_args(), "--resolver-version", "pip-2020-resolver"]
12✔
828

829
    loaded_lockfile, complete_req_strings = await _get_entire_lockfile_and_requirements(
12✔
830
        request.requirements
831
    )
832
    if loaded_lockfile:
12✔
833
        argv = (
12✔
834
            ["--lock", loaded_lockfile.lockfile_path, *pex_lock_resolver_args]
835
            if loaded_lockfile.is_pex_native
836
            # We use pip to resolve a requirements.txt pseudo-lockfile, possibly with hashes.
837
            else [
838
                "--requirement",
839
                loaded_lockfile.lockfile_path,
840
                "--no-transitive",
841
                *pip_resolver_args,
842
            ]
843
        )
844
        if loaded_lockfile.metadata and complete_req_strings:
12✔
845
            validate_metadata(
1✔
846
                loaded_lockfile.metadata,
847
                request.interpreter_constraints,
848
                loaded_lockfile.original_lockfile,
849
                complete_req_strings,
850
                # We're using the entire lockfile, so there is no Pex subsetting operation we
851
                # can delegate requirement validation to.  So we do our naive string-matching
852
                # validation.
853
                validate_consumed_req_strings=True,
854
                python_setup=python_setup,
855
                resolve_config=resolve_config,
856
            )
857

858
        return _BuildPexRequirementsSetup(
12✔
859
            [loaded_lockfile.lockfile_digest], argv, loaded_lockfile.requirement_estimate
860
        )
861

862
    assert isinstance(request.requirements, PexRequirements)
11✔
863
    reqs_info = await get_req_strings(request.requirements)
11✔
864

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

870
    if isinstance(request.requirements.from_superset, Pex):
11✔
871
        repository_pex = request.requirements.from_superset
1✔
872
        return _BuildPexRequirementsSetup(
1✔
873
            [repository_pex.digest],
874
            [*reqs_info.req_strings, "--pex-repository", repository_pex.name],
875
            concurrency_available,
876
        )
877

878
    elif isinstance(request.requirements.from_superset, Resolve):
11✔
879
        lockfile = await get_lockfile_for_resolve(
2✔
880
            request.requirements.from_superset, **implicitly()
881
        )
882
        loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
2✔
883

884
        # NB: This is also validated in the constructor.
885
        assert loaded_lockfile.is_pex_native
2✔
886
        if not reqs_info.req_strings:
2✔
887
            return _BuildPexRequirementsSetup([], [], concurrency_available)
1✔
888

889
        if loaded_lockfile.metadata:
1✔
890
            validate_metadata(
×
891
                loaded_lockfile.metadata,
892
                request.interpreter_constraints,
893
                loaded_lockfile.original_lockfile,
894
                consumed_req_strings=reqs_info.req_strings,
895
                # Don't validate user requirements when subsetting a resolve, as Pex's
896
                # validation during the subsetting is far more precise than our naive string
897
                # comparison. For example, if a lockfile was generated with `foo==1.2.3`
898
                # and we want to resolve `foo>=1.0.0` or just `foo` out of it, Pex will do
899
                # so successfully, while our naive validation would fail.
900
                validate_consumed_req_strings=False,
901
                python_setup=python_setup,
902
                resolve_config=resolve_config,
903
            )
904

905
        return _BuildPexRequirementsSetup(
1✔
906
            [loaded_lockfile.lockfile_digest],
907
            [
908
                *reqs_info.req_strings,
909
                "--lock",
910
                loaded_lockfile.lockfile_path,
911
                *pex_lock_resolver_args,
912
            ],
913
            concurrency_available,
914
        )
915

916
    # We use pip to perform a normal resolve.
917
    digests = []
11✔
918
    argv = [
11✔
919
        *reqs_info.req_strings,
920
        *pip_resolver_args,
921
        *(f"--find-links={find_links}" for find_links in reqs_info.find_links),
922
    ]
923
    if request.requirements.constraints_strings:
11✔
924
        constraints_file = "__constraints.txt"
1✔
925
        constraints_content = "\n".join(request.requirements.constraints_strings)
1✔
926
        digests.append(
1✔
927
            await create_digest(
928
                CreateDigest([FileContent(constraints_file, constraints_content.encode())])
929
            )
930
        )
931
        argv.extend(["--constraints", constraints_file])
1✔
932
    return _BuildPexRequirementsSetup(digests, argv, concurrency_available=concurrency_available)
11✔
933

934

935
@rule(level=LogLevel.DEBUG)
12✔
936
async def build_pex(
12✔
937
    request: PexRequest,
938
    python_setup: PythonSetup,
939
    pex_subsystem: PexSubsystem,
940
    pex_env: PexEnvironment,
941
) -> BuildPexResult:
942
    """Returns a PEX with the given settings."""
943

944
    if not request.python and not request.interpreter_constraints:
12✔
945
        # Blank ICs in the request means that the caller wants us to use the ICs configured
946
        # for the resolve (falling back to the global ICs).
947
        resolve_name = ""
12✔
948
        if isinstance(request.requirements, PexRequirements) and isinstance(
12✔
949
            request.requirements.from_superset, Resolve
950
        ):
951
            resolve_name = request.requirements.from_superset.name
1✔
952
        elif isinstance(request.requirements, EntireLockfile):
12✔
953
            resolve_name = request.requirements.lockfile.resolve_name
12✔
954

955
        if resolve_name:
12✔
956
            request = dataclasses.replace(
12✔
957
                request,
958
                interpreter_constraints=InterpreterConstraints(
959
                    python_setup.resolves_to_interpreter_constraints.get(
960
                        resolve_name,
961
                        python_setup.interpreter_constraints,
962
                    )
963
                ),
964
            )
965

966
    source_dir_name = "source_files"
12✔
967

968
    pex_python_setup_req = _determine_pex_python_and_platforms(request)
12✔
969
    requirements_setup_req = _setup_pex_requirements(**implicitly({request: PexRequest}))
12✔
970
    sources_digest_as_subdir_req = add_prefix(
12✔
971
        AddPrefix(request.sources or EMPTY_DIGEST, source_dir_name)
972
    )
973
    if isinstance(request.requirements, PexRequirements):
12✔
974
        (
12✔
975
            pex_python_setup,
976
            requirements_setup,
977
            sources_digest_as_subdir,
978
            req_info,
979
        ) = await concurrently(
980
            pex_python_setup_req,
981
            requirements_setup_req,
982
            sources_digest_as_subdir_req,
983
            get_req_strings(request.requirements),
984
        )
985
        req_strings = req_info.req_strings
12✔
986
    else:
987
        pex_python_setup, requirements_setup, sources_digest_as_subdir = await concurrently(
12✔
988
            pex_python_setup_req,
989
            requirements_setup_req,
990
            sources_digest_as_subdir_req,
991
        )
992
        req_strings = ()
12✔
993

994
    # Experimental: build PEX via uv + --venv-repository.
995
    # When opted in, we use uv to create a pre-populated venv and let PEX
996
    # package from it instead of resolving with pip.
997
    # See: https://github.com/pantsbuild/pants/issues/20679
998
    uv_venv_digest: Digest | None = None
12✔
999

1000
    use_uv_builder = python_setup.pex_builder == PexBuilder.uv
12✔
1001
    # uv builder only applies to non-internal PEXes with requirements and a
1002
    # local interpreter (not cross-platform builds).
1003
    if use_uv_builder and not request.internal_only:
12✔
1004
        fallback_reason = _check_uv_preconditions(request, req_strings, pex_python_setup)
1✔
1005
        if fallback_reason:
1✔
1006
            logger.warning(fallback_reason)
×
1007
        else:
1008
            assert pex_python_setup.python is not None
1✔
1009
            uv_result = await _build_uv_venv(
1✔
1010
                _UvVenvRequest(
1011
                    req_strings=req_strings,
1012
                    requirements=request.requirements,
1013
                    python_path=pex_python_setup.python.path,
1014
                    description=request.description or request.output_filename,
1015
                ),
1016
                **implicitly(),
1017
            )
1018
            uv_venv_digest = uv_result.venv_digest
1✔
1019

1020
            # Replace requirements_setup: pass requirement strings + --venv-repository
1021
            # so PEX subsets from the uv-populated venv instead of resolving with pip.
1022
            requirements_setup = _BuildPexRequirementsSetup(
1✔
1023
                digests=[],
1024
                argv=[*req_strings, f"--venv-repository={_UV_VENV_DIR}"],
1025
                concurrency_available=requirements_setup.concurrency_available,
1026
            )
1027
    elif use_uv_builder and request.internal_only:
12✔
1028
        logger.debug(
1✔
1029
            "pex_builder=uv: skipping for internal-only PEX %s. Using the default PEX/pip builder.",
1030
            request.description or request.output_filename,
1031
        )
1032

1033
    output_chroot = os.path.dirname(request.output_filename)
12✔
1034
    if output_chroot:
12✔
1035
        output_file = request.output_filename
5✔
1036
        strip_output_chroot = False
5✔
1037
    else:
1038
        # In principle a cache should always be just a cache, but existing
1039
        # tests in this repo make the assumption that they can look into a
1040
        # still intact cache and see the same thing as was there before, which
1041
        # requires this to be deterministic and not random.  adler32, because
1042
        # it is in the stlib, fast, and doesn't need to be cryptographic.
1043
        output_chroot = f"pex-dist-{zlib.adler32(request.output_filename.encode()):08x}"
12✔
1044
        strip_output_chroot = True
12✔
1045
        output_file = os.path.join(output_chroot, request.output_filename)
12✔
1046

1047
    argv = [
12✔
1048
        "--output-file",
1049
        output_file,
1050
        *request.additional_args,
1051
    ]
1052

1053
    if uv_venv_digest is not None:
12✔
1054
        # When using --venv-repository, PEX does not allow any custom target
1055
        # flags (--python, --interpreter-constraint, --platform). The target is
1056
        # implicitly the venv interpreter.
1057
        pass
1✔
1058
    else:
1059
        argv.extend(pex_python_setup.argv)
12✔
1060

1061
    if request.main is not None:
12✔
1062
        argv.extend(request.main.iter_pex_args())
12✔
1063
        if isinstance(request.main, Executable):
12✔
1064
            # Unlike other MainSpecifiecation types (that can pass spec as-is to pex),
1065
            # Executable must be an actual path relative to the sandbox.
1066
            # request.main.spec is a python source file including its spec_path.
1067
            # To make it relative to the sandbox, we strip the source root
1068
            # and add the source_dir_name (sources get prefixed with that below).
1069
            stripped = await strip_file_name(StrippedFileNameRequest(request.main.spec))
1✔
1070
            argv.append(os.path.join(source_dir_name, stripped.value))
1✔
1071

1072
    argv.extend(
12✔
1073
        f"--inject-args={shlex.quote(injected_arg)}" for injected_arg in request.inject_args
1074
    )
1075
    argv.extend(f"--inject-env={k}={v}" for k, v in sorted(request.inject_env.items()))
12✔
1076

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

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

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

1091
    # Include any additional arguments and input digests required by the requirements.
1092
    argv.extend(requirements_setup.argv)
12✔
1093

1094
    merged_digest = await merge_digests(
12✔
1095
        MergeDigests(
1096
            (
1097
                request.complete_platforms.digest,
1098
                sources_digest_as_subdir,
1099
                request.additional_inputs,
1100
                *requirements_setup.digests,
1101
                *(pex.digest for pex in request.pex_path),
1102
                *([uv_venv_digest] if uv_venv_digest else []),
1103
            )
1104
        )
1105
    )
1106

1107
    argv.extend(["--layout", request.layout.value])
12✔
1108

1109
    result = await fallible_to_exec_result_or_raise(
12✔
1110
        **implicitly(
1111
            PexCliProcess(
1112
                subcommand=(),
1113
                extra_args=argv,
1114
                additional_input_digest=merged_digest,
1115
                description=_build_pex_description(request, req_strings, python_setup.resolves),
1116
                output_files=None,
1117
                output_directories=[output_chroot],
1118
                concurrency_available=requirements_setup.concurrency_available,
1119
                cache_scope=request.cache_scope,
1120
            )
1121
        )
1122
    )
1123

1124
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
12✔
1125

1126
    if strip_output_chroot:
12✔
1127
        output_digest = await remove_prefix(RemovePrefix(result.output_digest, output_chroot))
12✔
1128
    else:
1129
        output_digest = result.output_digest
5✔
1130

1131
    digest = (
12✔
1132
        await merge_digests(
1133
            MergeDigests((output_digest, *(pex.digest for pex in request.pex_path)))
1134
        )
1135
        if request.pex_path
1136
        else output_digest
1137
    )
1138

1139
    return BuildPexResult(
12✔
1140
        result=result,
1141
        pex_filename=request.output_filename,
1142
        digest=digest,
1143
        python=pex_python_setup.python,
1144
    )
1145

1146

1147
def _build_pex_description(
12✔
1148
    request: PexRequest, req_strings: Sequence[str], resolve_to_lockfile: Mapping[str, str]
1149
) -> str:
1150
    if request.description:
12✔
1151
        return request.description
3✔
1152

1153
    if isinstance(request.requirements, EntireLockfile):
12✔
1154
        lockfile = request.requirements.lockfile
12✔
1155
        desc_suffix = f"from {lockfile.url}"
12✔
1156
    else:
1157
        if not req_strings:
12✔
1158
            return f"Building {request.output_filename}"
12✔
1159
        elif isinstance(request.requirements.from_superset, Pex):
6✔
1160
            repo_pex = request.requirements.from_superset.name
1✔
1161
            return softwrap(
1✔
1162
                f"""
1163
                Extracting {pluralize(len(req_strings), "requirement")}
1164
                to build {request.output_filename} from {repo_pex}:
1165
                {", ".join(req_strings)}
1166
                """
1167
            )
1168
        elif isinstance(request.requirements.from_superset, Resolve):
6✔
1169
            # At this point we know this is a valid user resolve, so we can assume
1170
            # it's available in the dict. Nonetheless we use get() so that any weird error
1171
            # here gives a bad message rather than an outright crash.
1172
            lockfile_path = resolve_to_lockfile.get(request.requirements.from_superset.name, "")
2✔
1173
            return softwrap(
2✔
1174
                f"""
1175
                Building {pluralize(len(req_strings), "requirement")}
1176
                for {request.output_filename} from the {lockfile_path} resolve:
1177
                {", ".join(req_strings)}
1178
                """
1179
            )
1180
        else:
1181
            desc_suffix = softwrap(
6✔
1182
                f"""
1183
                with {pluralize(len(req_strings), "requirement")}:
1184
                {", ".join(req_strings)}
1185
                """
1186
            )
1187
    return f"Building {request.output_filename} {desc_suffix}"
12✔
1188

1189

1190
@rule
12✔
1191
async def create_pex(request: PexRequest) -> Pex:
12✔
1192
    result = await build_pex(request, **implicitly())
11✔
1193
    return result.create_pex()
11✔
1194

1195

1196
@rule
12✔
1197
async def create_optional_pex(request: OptionalPexRequest) -> OptionalPex:
12✔
1198
    if request.maybe_pex_request is None:
11✔
1199
        return OptionalPex(None)
11✔
1200
    result = await create_pex(request.maybe_pex_request)
1✔
1201
    return OptionalPex(result)
1✔
1202

1203

1204
@dataclass(frozen=True)
12✔
1205
class Script:
12✔
1206
    path: PurePath
12✔
1207

1208
    @property
12✔
1209
    def argv0(self) -> str:
12✔
1210
        return f"./{self.path}" if self.path.parent == PurePath() else str(self.path)
12✔
1211

1212

1213
@dataclass(frozen=True)
12✔
1214
class VenvScript:
12✔
1215
    script: Script
12✔
1216
    content: FileContent
12✔
1217

1218

1219
@dataclass(frozen=True)
12✔
1220
class VenvScriptWriter:
12✔
1221
    complete_pex_env: CompletePexEnvironment
12✔
1222
    pex: Pex
12✔
1223
    venv_dir: PurePath
12✔
1224

1225
    @classmethod
12✔
1226
    def create(
12✔
1227
        cls, complete_pex_env: CompletePexEnvironment, pex: Pex, venv_rel_dir: PurePath
1228
    ) -> VenvScriptWriter:
1229
        # N.B.: We don't know the working directory that will be used in any given
1230
        # invocation of the venv scripts; so we deal with working_directory once in an
1231
        # `adjust_relative_paths` function inside the script to save rule authors from having to do
1232
        # CWD offset math in every rule for all the relative paths their process depends on.
1233
        venv_dir = complete_pex_env.pex_root / venv_rel_dir
12✔
1234
        return cls(complete_pex_env=complete_pex_env, pex=pex, venv_dir=venv_dir)
12✔
1235

1236
    def _create_venv_script(
12✔
1237
        self,
1238
        bash: BashBinary,
1239
        *,
1240
        script_path: PurePath,
1241
        venv_executable: PurePath,
1242
    ) -> VenvScript:
1243
        env_vars = (
12✔
1244
            f"{name}={shlex.quote(value)}"
1245
            for name, value in self.complete_pex_env.environment_dict(
1246
                python_configured=True
1247
            ).items()
1248
        )
1249

1250
        target_venv_executable = shlex.quote(str(venv_executable))
12✔
1251
        venv_dir = shlex.quote(str(self.venv_dir))
12✔
1252
        execute_pex_args = " ".join(
12✔
1253
            f"$(adjust_relative_paths {shlex.quote(arg)})"
1254
            for arg in self.complete_pex_env.create_argv(self.pex.name, python=self.pex.python)
1255
        )
1256

1257
        script = dedent(
12✔
1258
            f"""\
1259
            #!{bash.path}
1260
            set -euo pipefail
1261

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

1268
            function adjust_relative_paths() {{
1269
                local value0="$1"
1270
                shift
1271
                if [ "${{value0:0:1}}" == "/" ]; then
1272
                    # Don't relativize absolute paths.
1273
                    echo "${{value0}}" "$@"
1274
                else
1275
                    # N.B.: We convert all relative paths to paths relative to the sandbox root so
1276
                    # this script works when run with a PWD set somewhere else than the sandbox
1277
                    # root.
1278
                    #
1279
                    # There are two cases to consider. For the purposes of example, assume PWD is
1280
                    # `/tmp/sandboxes/abc123/foo/bar`; i.e.: the rule API sets working_directory to
1281
                    # `foo/bar`. Also assume `config/tool.yml` is the relative path in question.
1282
                    #
1283
                    # 1. If our BASH_SOURCE is  `/tmp/sandboxes/abc123/pex_shim.sh`; so our
1284
                    #    SANDBOX_ROOT is `/tmp/sandboxes/abc123`, we calculate
1285
                    #    `/tmp/sandboxes/abc123/config/tool.yml`.
1286
                    # 2. If our BASH_SOURCE is instead `../../pex_shim.sh`; so our SANDBOX_ROOT is
1287
                    #    `../..`, we calculate `../../config/tool.yml`.
1288
                    echo "${{SANDBOX_ROOT}}/${{value0}}" "$@"
1289
                fi
1290
            }}
1291

1292
            export {" ".join(env_vars)}
1293
            export PEX_ROOT="$(adjust_relative_paths ${{PEX_ROOT}})"
1294

1295
            execute_pex_args="{execute_pex_args}"
1296
            target_venv_executable="$(adjust_relative_paths {target_venv_executable})"
1297
            venv_dir="$(adjust_relative_paths {venv_dir})"
1298

1299
            # Let PEX_TOOLS invocations pass through to the original PEX file since venvs don't come
1300
            # with tools support.
1301
            if [ -n "${{PEX_TOOLS:-}}" ]; then
1302
              exec ${{execute_pex_args}} "$@"
1303
            fi
1304

1305
            # If the seeded venv has been removed from the PEX_ROOT, we re-seed from the original
1306
            # `--venv` mode PEX file.
1307
            if [ ! -e "${{venv_dir}}" ]; then
1308
                PEX_INTERPRETER=1 ${{execute_pex_args}} -c ''
1309
            fi
1310

1311
            exec "${{target_venv_executable}}" "$@"
1312
            """
1313
        )
1314
        return VenvScript(
12✔
1315
            script=Script(script_path),
1316
            content=FileContent(path=str(script_path), content=script.encode(), is_executable=True),
1317
        )
1318

1319
    def exe(self, bash: BashBinary) -> VenvScript:
12✔
1320
        """Writes a safe shim for the venv's executable `pex` script."""
1321
        script_path = PurePath(f"{self.pex.name}_pex_shim.sh")
12✔
1322
        return self._create_venv_script(
12✔
1323
            bash, script_path=script_path, venv_executable=self.venv_dir / "pex"
1324
        )
1325

1326
    def bin(self, bash: BashBinary, name: str) -> VenvScript:
12✔
1327
        """Writes a safe shim for an executable or script in the venv's `bin` directory."""
1328
        script_path = PurePath(f"{self.pex.name}_bin_{name}_shim.sh")
12✔
1329
        return self._create_venv_script(
12✔
1330
            bash,
1331
            script_path=script_path,
1332
            venv_executable=self.venv_dir / "bin" / name,
1333
        )
1334

1335
    def python(self, bash: BashBinary) -> VenvScript:
12✔
1336
        """Writes a safe shim for the venv's python binary."""
1337
        return self.bin(bash, "python")
12✔
1338

1339

1340
@dataclass(frozen=True)
12✔
1341
class VenvPex:
12✔
1342
    digest: Digest
12✔
1343
    append_only_caches: FrozenDict[str, str] | None
12✔
1344
    pex_filename: str
12✔
1345
    pex: Script
12✔
1346
    python: Script
12✔
1347
    bin: FrozenDict[str, Script]
12✔
1348
    venv_rel_dir: str
12✔
1349

1350

1351
@dataclass(frozen=True)
12✔
1352
class VenvPexRequest:
12✔
1353
    pex_request: PexRequest
12✔
1354
    complete_pex_env: CompletePexEnvironment
12✔
1355
    bin_names: tuple[str, ...] = ()
12✔
1356
    site_packages_copies: bool = False
12✔
1357

1358
    def __init__(
12✔
1359
        self,
1360
        pex_request: PexRequest,
1361
        complete_pex_env: CompletePexEnvironment,
1362
        bin_names: Iterable[str] = (),
1363
        site_packages_copies: bool = False,
1364
    ) -> None:
1365
        """A request for a PEX that runs in a venv and optionally exposes select venv `bin` scripts.
1366

1367
        :param pex_request: The details of the desired PEX.
1368
        :param complete_pex_env: The complete PEX environment the pex will be run in.
1369
        :param bin_names: The names of venv `bin` scripts to expose for execution.
1370
        :param site_packages_copies: `True` to use copies (hardlinks when possible) of PEX
1371
            dependencies when installing them in the venv site-packages directory. By default this
1372
            is `False` and symlinks are used instead which is a win in the time and space dimensions
1373
            but results in a non-standard venv structure that does trip up some libraries.
1374
        """
1375
        object.__setattr__(self, "pex_request", pex_request)
12✔
1376
        object.__setattr__(self, "complete_pex_env", complete_pex_env)
12✔
1377
        object.__setattr__(self, "bin_names", tuple(bin_names))
12✔
1378
        object.__setattr__(self, "site_packages_copies", site_packages_copies)
12✔
1379

1380

1381
@rule
12✔
1382
async def wrap_venv_prex_request(
12✔
1383
    pex_request: PexRequest, pex_environment: PexEnvironment
1384
) -> VenvPexRequest:
1385
    # Allow creating a VenvPex from a plain PexRequest when no extra bin scripts need to be exposed.
1386
    return VenvPexRequest(pex_request, pex_environment.in_sandbox(working_directory=None))
12✔
1387

1388

1389
@rule
12✔
1390
async def create_venv_pex(
12✔
1391
    request: VenvPexRequest, bash: BashBinary, pex_environment: PexEnvironment
1392
) -> VenvPex:
1393
    # VenvPex is motivated by improving performance of Python tools by eliminating traditional PEX
1394
    # file startup overhead.
1395
    #
1396
    # To achieve the minimal overhead (on the order of 1ms) we discard:
1397
    # 1. Using Pex default mode:
1398
    #    Although this does reduce initial tool execution overhead, it still leaves a minimum
1399
    #    O(100ms) of overhead per subsequent tool invocation. Fundamentally, Pex still needs to
1400
    #    execute its `sys.path` isolation bootstrap code in this case.
1401
    # 2. Using the Pex `venv` tool:
1402
    #    The idea here would be to create a tool venv as a Process output and then use the tool
1403
    #    venv as an input digest for all tool invocations. This was tried and netted ~500ms of
1404
    #    overhead over raw venv use.
1405
    #
1406
    # Instead we use Pex's `--venv` mode. In this mode you can run the Pex file and it will create a
1407
    # venv on the fly in the PEX_ROOT as needed. Since the PEX_ROOT is a named_cache, we avoid the
1408
    # digest materialization overhead present in 2 above. Since the venv is naturally isolated we
1409
    # avoid the `sys.path` isolation overhead of Pex itself present in 1 above.
1410
    #
1411
    # This does leave O(50ms) of overhead though for the PEX bootstrap code to detect an already
1412
    # created venv in the PEX_ROOT and re-exec into it. To eliminate this overhead we execute the
1413
    # `pex` venv script in the PEX_ROOT directly. This is not robust on its own though, since the
1414
    # named caches store might be pruned at any time. To guard against that case we introduce a shim
1415
    # bash script that checks to see if the `pex` venv script exists in the PEX_ROOT and re-creates
1416
    # the PEX_ROOT venv if not. Using the shim script to run Python tools gets us down to the ~1ms
1417
    # of overhead we currently enjoy.
1418

1419
    pex_request = request.pex_request
12✔
1420
    seeded_venv_request = dataclasses.replace(
12✔
1421
        pex_request,
1422
        additional_args=pex_request.additional_args
1423
        + (
1424
            "--venv",
1425
            "prepend",
1426
            "--seed",
1427
            "verbose",
1428
            pex_environment.venv_site_packages_copies_option(
1429
                use_copies=request.site_packages_copies
1430
            ),
1431
        ),
1432
    )
1433
    venv_pex_result = await build_pex(seeded_venv_request, **implicitly())
12✔
1434
    # Pex verbose --seed mode outputs the absolute path of the PEX executable as well as the
1435
    # absolute path of the PEX_ROOT.  In the --venv case this is the `pex` script in the venv root
1436
    # directory.
1437
    seed_info = json.loads(venv_pex_result.result.stdout.decode())
12✔
1438
    abs_pex_root = PurePath(seed_info["pex_root"])
12✔
1439
    abs_pex_path = PurePath(seed_info["pex"])
12✔
1440
    venv_rel_dir = abs_pex_path.relative_to(abs_pex_root).parent
12✔
1441

1442
    venv_script_writer = VenvScriptWriter.create(
12✔
1443
        complete_pex_env=request.complete_pex_env,
1444
        pex=venv_pex_result.create_pex(),
1445
        venv_rel_dir=venv_rel_dir,
1446
    )
1447
    pex = venv_script_writer.exe(bash)
12✔
1448
    python = venv_script_writer.python(bash)
12✔
1449
    scripts = {bin_name: venv_script_writer.bin(bash, bin_name) for bin_name in request.bin_names}
12✔
1450
    scripts_digest = await create_digest(
12✔
1451
        CreateDigest(
1452
            (
1453
                pex.content,
1454
                python.content,
1455
                *(venv_script.content for venv_script in scripts.values()),
1456
            )
1457
        )
1458
    )
1459
    input_digest = await merge_digests(
12✔
1460
        MergeDigests((venv_script_writer.pex.digest, scripts_digest))
1461
    )
1462
    append_only_caches = (
12✔
1463
        venv_pex_result.python.append_only_caches if venv_pex_result.python else None
1464
    )
1465

1466
    return VenvPex(
12✔
1467
        digest=input_digest,
1468
        append_only_caches=append_only_caches,
1469
        pex_filename=venv_pex_result.pex_filename,
1470
        pex=pex.script,
1471
        python=python.script,
1472
        bin=FrozenDict((bin_name, venv_script.script) for bin_name, venv_script in scripts.items()),
1473
        venv_rel_dir=venv_rel_dir.as_posix(),
1474
    )
1475

1476

1477
@dataclass(frozen=True)
12✔
1478
class PexProcess:
12✔
1479
    pex: Pex
12✔
1480
    argv: tuple[str, ...]
12✔
1481
    description: str = dataclasses.field(compare=False)
12✔
1482
    level: LogLevel
12✔
1483
    input_digest: Digest | None
12✔
1484
    working_directory: str | None
12✔
1485
    extra_env: FrozenDict[str, str]
12✔
1486
    output_files: tuple[str, ...] | None
12✔
1487
    output_directories: tuple[str, ...] | None
12✔
1488
    timeout_seconds: int | None
12✔
1489
    execution_slot_variable: str | None
12✔
1490
    concurrency_available: int
12✔
1491
    cache_scope: ProcessCacheScope
12✔
1492

1493
    def __init__(
12✔
1494
        self,
1495
        pex: Pex,
1496
        *,
1497
        description: str,
1498
        argv: Iterable[str] = (),
1499
        level: LogLevel = LogLevel.INFO,
1500
        input_digest: Digest | None = None,
1501
        working_directory: str | None = None,
1502
        extra_env: Mapping[str, str] | None = None,
1503
        output_files: Iterable[str] | None = None,
1504
        output_directories: Iterable[str] | None = None,
1505
        timeout_seconds: int | None = None,
1506
        execution_slot_variable: str | None = None,
1507
        concurrency_available: int = 0,
1508
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1509
    ) -> None:
1510
        object.__setattr__(self, "pex", pex)
7✔
1511
        object.__setattr__(self, "argv", tuple(argv))
7✔
1512
        object.__setattr__(self, "description", description)
7✔
1513
        object.__setattr__(self, "level", level)
7✔
1514
        object.__setattr__(self, "input_digest", input_digest)
7✔
1515
        object.__setattr__(self, "working_directory", working_directory)
7✔
1516
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
7✔
1517
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
7✔
1518
        object.__setattr__(
7✔
1519
            self, "output_directories", tuple(output_directories) if output_directories else None
1520
        )
1521
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
7✔
1522
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
7✔
1523
        object.__setattr__(self, "concurrency_available", concurrency_available)
7✔
1524
        object.__setattr__(self, "cache_scope", cache_scope)
7✔
1525

1526

1527
@rule
12✔
1528
async def setup_pex_process(request: PexProcess, pex_environment: PexEnvironment) -> Process:
12✔
1529
    pex = request.pex
7✔
1530
    complete_pex_env = pex_environment.in_sandbox(working_directory=request.working_directory)
7✔
1531
    argv = complete_pex_env.create_argv(pex.name, *request.argv, python=pex.python)
7✔
1532
    env = {
7✔
1533
        **complete_pex_env.environment_dict(python_configured=pex.python is not None),
1534
        **request.extra_env,
1535
    }
1536
    input_digest = (
7✔
1537
        await merge_digests(MergeDigests((pex.digest, request.input_digest)))
1538
        if request.input_digest
1539
        else pex.digest
1540
    )
1541
    append_only_caches = (
7✔
1542
        request.pex.python.append_only_caches if request.pex.python else FrozenDict({})
1543
    )
1544
    return Process(
7✔
1545
        argv,
1546
        description=request.description,
1547
        level=request.level,
1548
        input_digest=input_digest,
1549
        working_directory=request.working_directory,
1550
        env=env,
1551
        output_files=request.output_files,
1552
        output_directories=request.output_directories,
1553
        append_only_caches={
1554
            **complete_pex_env.append_only_caches,
1555
            **append_only_caches,
1556
        },
1557
        timeout_seconds=request.timeout_seconds,
1558
        execution_slot_variable=request.execution_slot_variable,
1559
        concurrency_available=request.concurrency_available,
1560
        cache_scope=request.cache_scope,
1561
    )
1562

1563

1564
@dataclass(unsafe_hash=True)
12✔
1565
class VenvPexProcess:
12✔
1566
    venv_pex: VenvPex
12✔
1567
    argv: tuple[str, ...]
12✔
1568
    description: str = dataclasses.field(compare=False)
12✔
1569
    level: LogLevel
12✔
1570
    input_digest: Digest | None
12✔
1571
    working_directory: str | None
12✔
1572
    extra_env: FrozenDict[str, str]
12✔
1573
    output_files: tuple[str, ...] | None
12✔
1574
    output_directories: tuple[str, ...] | None
12✔
1575
    timeout_seconds: int | None
12✔
1576
    execution_slot_variable: str | None
12✔
1577
    concurrency_available: int
12✔
1578
    cache_scope: ProcessCacheScope
12✔
1579
    append_only_caches: FrozenDict[str, str]
12✔
1580

1581
    def __init__(
12✔
1582
        self,
1583
        venv_pex: VenvPex,
1584
        *,
1585
        description: str,
1586
        argv: Iterable[str] = (),
1587
        level: LogLevel = LogLevel.INFO,
1588
        input_digest: Digest | None = None,
1589
        working_directory: str | None = None,
1590
        extra_env: Mapping[str, str] | None = None,
1591
        output_files: Iterable[str] | None = None,
1592
        output_directories: Iterable[str] | None = None,
1593
        timeout_seconds: int | None = None,
1594
        execution_slot_variable: str | None = None,
1595
        concurrency_available: int = 0,
1596
        cache_scope: ProcessCacheScope = ProcessCacheScope.SUCCESSFUL,
1597
        append_only_caches: Mapping[str, str] | None = None,
1598
    ) -> None:
1599
        object.__setattr__(self, "venv_pex", venv_pex)
12✔
1600
        object.__setattr__(self, "argv", tuple(argv))
12✔
1601
        object.__setattr__(self, "description", description)
12✔
1602
        object.__setattr__(self, "level", level)
12✔
1603
        object.__setattr__(self, "input_digest", input_digest)
12✔
1604
        object.__setattr__(self, "working_directory", working_directory)
12✔
1605
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
12✔
1606
        object.__setattr__(self, "output_files", tuple(output_files) if output_files else None)
12✔
1607
        object.__setattr__(
12✔
1608
            self, "output_directories", tuple(output_directories) if output_directories else None
1609
        )
1610
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
12✔
1611
        object.__setattr__(self, "execution_slot_variable", execution_slot_variable)
12✔
1612
        object.__setattr__(self, "concurrency_available", concurrency_available)
12✔
1613
        object.__setattr__(self, "cache_scope", cache_scope)
12✔
1614
        object.__setattr__(self, "append_only_caches", FrozenDict(append_only_caches or {}))
12✔
1615

1616

1617
@rule
12✔
1618
async def setup_venv_pex_process(
12✔
1619
    request: VenvPexProcess, pex_environment: PexEnvironment
1620
) -> Process:
1621
    venv_pex = request.venv_pex
12✔
1622
    pex_bin = (
12✔
1623
        os.path.relpath(venv_pex.pex.argv0, request.working_directory)
1624
        if request.working_directory
1625
        else venv_pex.pex.argv0
1626
    )
1627
    argv = (pex_bin, *request.argv)
12✔
1628
    input_digest = (
12✔
1629
        await merge_digests(MergeDigests((venv_pex.digest, request.input_digest)))
1630
        if request.input_digest
1631
        else venv_pex.digest
1632
    )
1633
    append_only_caches: FrozenDict[str, str] = FrozenDict(
12✔
1634
        **pex_environment.in_sandbox(
1635
            working_directory=request.working_directory
1636
        ).append_only_caches,
1637
        **request.append_only_caches,
1638
        **(FrozenDict({}) if venv_pex.append_only_caches is None else venv_pex.append_only_caches),
1639
    )
1640
    return Process(
12✔
1641
        argv=argv,
1642
        description=request.description,
1643
        level=request.level,
1644
        input_digest=input_digest,
1645
        working_directory=request.working_directory,
1646
        env=request.extra_env,
1647
        output_files=request.output_files,
1648
        output_directories=request.output_directories,
1649
        append_only_caches=append_only_caches,
1650
        timeout_seconds=request.timeout_seconds,
1651
        execution_slot_variable=request.execution_slot_variable,
1652
        concurrency_available=request.concurrency_available,
1653
        cache_scope=request.cache_scope,
1654
    )
1655

1656

1657
@dataclass(frozen=True)
12✔
1658
class PexDistributionInfo:
12✔
1659
    """Information about an individual distribution in a PEX file, as reported by `PEX_TOOLS=1
1660
    repository info -v`."""
1661

1662
    project_name: str
12✔
1663
    version: packaging.version.Version
12✔
1664
    requires_python: packaging.specifiers.SpecifierSet | None
12✔
1665
    # Note: These are parsed from metadata written by the pex tool, and are always
1666
    #   a valid packaging.requirements.Requirement.
1667
    requires_dists: tuple[Requirement, ...]
12✔
1668

1669

1670
DefaultT = TypeVar("DefaultT")
12✔
1671

1672

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

1677
    def find(
12✔
1678
        self, name: str, default: DefaultT | None = None
1679
    ) -> PexDistributionInfo | DefaultT | None:
1680
        """Returns the PexDistributionInfo with the given name, first one wins."""
1681
        try:
6✔
1682
            return next(info for info in self if info.project_name == name)
6✔
1683
        except StopIteration:
×
1684
            return default
×
1685

1686

1687
def parse_repository_info(repository_info: str) -> PexResolveInfo:
12✔
1688
    def iter_dist_info() -> Iterator[PexDistributionInfo]:
7✔
1689
        for line in repository_info.splitlines():
7✔
1690
            info = json.loads(line)
7✔
1691
            requires_python = info["requires_python"]
7✔
1692
            yield PexDistributionInfo(
7✔
1693
                project_name=info["project_name"],
1694
                version=packaging.version.Version(info["version"]),
1695
                requires_python=(
1696
                    packaging.specifiers.SpecifierSet(requires_python)
1697
                    if requires_python is not None
1698
                    else None
1699
                ),
1700
                requires_dists=tuple(Requirement(req) for req in sorted(info["requires_dists"])),
1701
            )
1702

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

1705

1706
@rule
12✔
1707
async def determine_venv_pex_resolve_info(venv_pex: VenvPex) -> PexResolveInfo:
12✔
1708
    process_result = await fallible_to_exec_result_or_raise(
6✔
1709
        **implicitly(
1710
            VenvPexProcess(
1711
                venv_pex,
1712
                argv=["repository", "info", "-v"],
1713
                extra_env={"PEX_TOOLS": "1"},
1714
                input_digest=venv_pex.digest,
1715
                description=f"Determine distributions found in {venv_pex.pex_filename}",
1716
                level=LogLevel.DEBUG,
1717
            )
1718
        )
1719
    )
1720
    return parse_repository_info(process_result.stdout.decode())
6✔
1721

1722

1723
@rule
12✔
1724
async def determine_pex_resolve_info(pex_pex: PexPEX, pex: Pex) -> PexResolveInfo:
12✔
1725
    process_result = await fallible_to_exec_result_or_raise(
4✔
1726
        **implicitly(
1727
            PexProcess(
1728
                pex=Pex(digest=pex_pex.digest, name=pex_pex.exe, python=pex.python),
1729
                argv=[pex.name, "repository", "info", "-v"],
1730
                input_digest=pex.digest,
1731
                extra_env={"PEX_MODULE": "pex.tools"},
1732
                description=f"Determine distributions found in {pex.name}",
1733
                level=LogLevel.DEBUG,
1734
            )
1735
        )
1736
    )
1737
    return parse_repository_info(process_result.stdout.decode())
4✔
1738

1739

1740
def rules():
12✔
1741
    return [
12✔
1742
        *collect_rules(),
1743
        *pex_cli.rules(),
1744
        *pex_requirements.rules(),
1745
        *uv_subsystem.rules(),  # Also in register.py; engine deduplicates.
1746
        *stripped_source_rules(),
1747
    ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc