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

pantsbuild / pants / 19015773527

02 Nov 2025 05:33PM UTC coverage: 17.872% (-62.4%) from 80.3%
19015773527

Pull #22816

github

web-flow
Merge a12d75757 into 6c024e162
Pull Request #22816: Update Pants internal Python to 3.14

4 of 5 new or added lines in 3 files covered. (80.0%)

28452 existing lines in 683 files now uncovered.

9831 of 55007 relevant lines covered (17.87%)

0.18 hits per line

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

0.0
/src/python/pants/backend/python/providers/python_build_standalone/rules.py
1
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
UNCOV
3
from __future__ import annotations
×
4

UNCOV
5
import functools
×
UNCOV
6
import json
×
UNCOV
7
import logging
×
UNCOV
8
import posixpath
×
UNCOV
9
import re
×
UNCOV
10
import textwrap
×
UNCOV
11
import urllib
×
UNCOV
12
import uuid
×
UNCOV
13
from collections.abc import Iterable, Mapping, Sequence
×
UNCOV
14
from dataclasses import dataclass
×
UNCOV
15
from pathlib import PurePath
×
UNCOV
16
from typing import TypedDict, TypeVar, cast
×
17

UNCOV
18
from packaging.version import InvalidVersion
×
19

UNCOV
20
from pants.backend.python.providers.python_build_standalone.constraints import (
×
21
    ConstraintParseError,
22
    ConstraintSatisfied,
23
    ConstraintsList,
24
)
UNCOV
25
from pants.backend.python.subsystems.setup import PythonSetup
×
UNCOV
26
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
×
UNCOV
27
from pants.backend.python.util_rules.pex import PythonProvider
×
UNCOV
28
from pants.backend.python.util_rules.pex import rules as pex_rules
×
UNCOV
29
from pants.backend.python.util_rules.pex_environment import PythonExecutable
×
UNCOV
30
from pants.core.util_rules.external_tool import (
×
31
    ExternalToolError,
32
    ExternalToolRequest,
33
    download_external_tool,
34
)
UNCOV
35
from pants.core.util_rules.external_tool import rules as external_tools_rules
×
UNCOV
36
from pants.core.util_rules.system_binaries import (
×
37
    CpBinary,
38
    MkdirBinary,
39
    MvBinary,
40
    RmBinary,
41
    ShBinary,
42
    TestBinary,
43
)
UNCOV
44
from pants.engine.fs import DownloadFile
×
UNCOV
45
from pants.engine.internals.native_engine import FileDigest
×
UNCOV
46
from pants.engine.platform import Platform
×
UNCOV
47
from pants.engine.process import Process, ProcessCacheScope, fallible_to_exec_result_or_raise
×
UNCOV
48
from pants.engine.rules import collect_rules, implicitly, rule
×
UNCOV
49
from pants.engine.unions import UnionRule
×
UNCOV
50
from pants.option.errors import OptionsError
×
UNCOV
51
from pants.option.global_options import NamedCachesDirOption
×
UNCOV
52
from pants.option.option_types import StrListOption, StrOption
×
UNCOV
53
from pants.option.subsystem import Subsystem
×
UNCOV
54
from pants.util.docutil import bin_name
×
UNCOV
55
from pants.util.frozendict import FrozenDict
×
UNCOV
56
from pants.util.logging import LogLevel
×
UNCOV
57
from pants.util.memo import memoized_property
×
UNCOV
58
from pants.util.resources import read_sibling_resource
×
UNCOV
59
from pants.util.strutil import softwrap
×
UNCOV
60
from pants.version import Version
×
61

UNCOV
62
logger = logging.getLogger(__name__)
×
63

UNCOV
64
PBS_SANDBOX_NAME = ".python_build_standalone"
×
UNCOV
65
PBS_NAMED_CACHE_NAME = "python_build_standalone"
×
UNCOV
66
PBS_APPEND_ONLY_CACHES = FrozenDict({PBS_NAMED_CACHE_NAME: PBS_SANDBOX_NAME})
×
67

UNCOV
68
_T = TypeVar("_T")  # Define type variable "T"
×
69

70

UNCOV
71
class PBSPythonInfo(TypedDict):
×
UNCOV
72
    url: str
×
UNCOV
73
    sha256: str
×
UNCOV
74
    size: int
×
75

76

UNCOV
77
PBSVersionsT = dict[str, dict[str, dict[str, PBSPythonInfo]]]
×
78

79

UNCOV
80
@dataclass
×
UNCOV
81
class _ParsedPBSPython:
×
UNCOV
82
    py_version: Version
×
UNCOV
83
    pbs_release_tag: Version
×
UNCOV
84
    platform: Platform
×
UNCOV
85
    url: str
×
UNCOV
86
    sha256: str
×
UNCOV
87
    size: int
×
88

89

UNCOV
90
def _parse_py_version_and_pbs_release_tag(
×
91
    version_and_tag: str,
92
) -> tuple[Version | None, Version | None]:
UNCOV
93
    version_and_tag = version_and_tag.strip()
×
UNCOV
94
    if not version_and_tag:
×
UNCOV
95
        return None, None
×
96

UNCOV
97
    parts = version_and_tag.split("+", 1)
×
UNCOV
98
    py_version: Version | None = None
×
UNCOV
99
    pbs_release_tag: Version | None = None
×
100

UNCOV
101
    if len(parts) >= 1:
×
UNCOV
102
        try:
×
UNCOV
103
            py_version = Version(parts[0])
×
UNCOV
104
        except InvalidVersion:
×
UNCOV
105
            raise ValueError(f"Version `{parts[0]}` is not a valid Python version.")
×
106

UNCOV
107
    if len(parts) == 2:
×
UNCOV
108
        try:
×
UNCOV
109
            pbs_release_tag = Version(parts[1])
×
UNCOV
110
        except InvalidVersion:
×
UNCOV
111
            raise ValueError(f"PBS release tag `{parts[1]}` is not a valid version.")
×
112

UNCOV
113
    return py_version, pbs_release_tag
×
114

115

UNCOV
116
def _parse_pbs_url(url: str) -> tuple[Version, Version, Platform]:
×
UNCOV
117
    parsed_url = urllib.parse.urlparse(urllib.parse.unquote(url))
×
UNCOV
118
    base_path = posixpath.basename(parsed_url.path)
×
119

UNCOV
120
    base_path_no_prefix = base_path.removeprefix("cpython-")
×
UNCOV
121
    if base_path_no_prefix == base_path:
×
UNCOV
122
        raise ValueError(
×
123
            f"Unable to parse the provided URL since it does not have a cpython prefix as per the PBS naming convention: {url}"
124
        )
125

UNCOV
126
    base_path_parts = base_path_no_prefix.split("-", 1)
×
UNCOV
127
    if len(base_path_parts) != 2:
×
128
        raise ValueError(
×
129
            f"Unable to parse the provided URL because it does not follow the PBS naming convention: {url}"
130
        )
131

UNCOV
132
    py_version, pbs_release_tag = _parse_py_version_and_pbs_release_tag(base_path_parts[0])
×
UNCOV
133
    if not py_version or not pbs_release_tag:
×
UNCOV
134
        raise ValueError(
×
135
            "Unable to parse the Python version and PBS release tag from the provided URL "
136
            f"because it does not follow the PBS naming convention: {url}"
137
        )
138

139
    platform: Platform
UNCOV
140
    match base_path_parts[1].split("-"):
×
UNCOV
141
        case [
×
142
            "x86_64" | "x86_64_v2" | "x86_64_v3" | "x86_64_v4",
143
            "unknown",
144
            "linux",
145
            "gnu" | "musl",
146
            *_,
147
        ]:
UNCOV
148
            platform = Platform.linux_x86_64
×
UNCOV
149
        case ["aarch64", "unknown", "linux", "gnu", *_]:
×
UNCOV
150
            platform = Platform.linux_arm64
×
UNCOV
151
        case ["x86_64", "apple", "darwin", *_]:
×
152
            platform = Platform.macos_x86_64
×
UNCOV
153
        case ["aarch64", "apple", "darwin", *_]:
×
154
            platform = Platform.macos_arm64
×
UNCOV
155
        case _:
×
UNCOV
156
            raise ValueError(
×
157
                "Unable to parse the platform from the provided URL "
158
                f"because it does not follow the PBS naming convention: {url}"
159
            )
160

UNCOV
161
    return py_version, pbs_release_tag, platform
×
162

163

UNCOV
164
def _parse_from_three_fields(parts: Sequence[str], orig_value: str) -> _ParsedPBSPython:
×
UNCOV
165
    assert len(parts) == 3
×
UNCOV
166
    sha256, size, url = parts
×
167

UNCOV
168
    try:
×
UNCOV
169
        py_version, pbs_release_tag, platform = _parse_pbs_url(url)
×
UNCOV
170
    except ValueError as e:
×
UNCOV
171
        raise ExternalToolError(
×
172
            f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
173
            f"the value `{orig_value}` could not be parsed: {e}"
174
        )
175

UNCOV
176
    return _ParsedPBSPython(
×
177
        py_version=py_version,
178
        pbs_release_tag=pbs_release_tag,
179
        platform=platform,
180
        url=url,
181
        sha256=sha256,
182
        size=int(size),
183
    )
184

185

UNCOV
186
def _parse_from_five_fields(parts: Sequence[str], orig_value: str) -> _ParsedPBSPython:
×
UNCOV
187
    assert len(parts) == 5
×
UNCOV
188
    py_version_and_tag_str, platform_str, sha256, filesize_str, url = (x.strip() for x in parts)
×
189

UNCOV
190
    try:
×
UNCOV
191
        maybe_py_version, maybe_pbs_release_tag = _parse_py_version_and_pbs_release_tag(
×
192
            py_version_and_tag_str
193
        )
194
    except ValueError:
×
195
        raise ExternalToolError(
×
196
            f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
197
            f"the value `{orig_value}` declares version `{py_version_and_tag_str}` in the first field, "
198
            "but it could not be parsed as a PBS release version."
199
        )
200

UNCOV
201
    maybe_platform: Platform | None = None
×
UNCOV
202
    if not platform_str:
×
UNCOV
203
        pass
×
UNCOV
204
    elif platform_str in (
×
205
        Platform.linux_x86_64.value,
206
        Platform.linux_arm64.value,
207
        Platform.macos_x86_64.value,
208
        Platform.macos_arm64.value,
209
    ):
UNCOV
210
        maybe_platform = Platform(platform_str)
×
211
    else:
212
        raise ExternalToolError(
×
213
            f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
214
            f"the value `{orig_value}` declares platforn `{platform_str}` in the second field, "
215
            "but that value is not a known Pants platform. It must be one of "
216
            "`linux_x86_64`, `linux_arm64`, `macos_x86_64`, or `macos_arm64`."
217
        )
218

UNCOV
219
    if len(sha256) != 64 or not re.match("^[a-zA-Z0-9]+$", sha256):
×
220
        raise ExternalToolError(
×
221
            f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
222
            f"the value `{orig_value}` declares SHA256 checksum `{sha256}` in the third field, "
223
            "but that value does not parse as a SHA256 checksum."
224
        )
225

UNCOV
226
    try:
×
UNCOV
227
        filesize: int = int(filesize_str)
×
228
    except ValueError:
×
229
        raise ExternalToolError(
×
230
            f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
231
            f"the value `{orig_value}` declares file size `{filesize_str}` in the fourth field, "
232
            "but that value does not parse as an integer."
233
        )
234

UNCOV
235
    maybe_inferred_py_version: Version | None = None
×
UNCOV
236
    maybe_inferred_pbs_release_tag: Version | None = None
×
UNCOV
237
    maybe_inferred_platform: Platform | None = None
×
UNCOV
238
    try:
×
UNCOV
239
        (
×
240
            maybe_inferred_py_version,
241
            maybe_inferred_pbs_release_tag,
242
            maybe_inferred_platform,
243
        ) = _parse_pbs_url(url)
UNCOV
244
    except ValueError:
×
UNCOV
245
        pass
×
246

UNCOV
247
    def _validate_inferred(
×
248
        *, explicit: _T | None, inferred: _T | None, description: str, field_pos: str
249
    ) -> _T:
UNCOV
250
        if explicit is None:
×
UNCOV
251
            if inferred is None:
×
UNCOV
252
                raise ExternalToolError(
×
253
                    f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
254
                    f"the value `{orig_value}` does not declare a {description} in the {field_pos} field, and no {description} "
255
                    "could be inferred from the URL."
256
                )
257
            else:
UNCOV
258
                return inferred
×
259
        else:
UNCOV
260
            if inferred is not None and explicit != inferred:
×
261
                logger.warning(
×
262
                    f"While parsing the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option, "
263
                    f"the value `{orig_value}` declares {description} `{explicit}` in the {field_pos} field, but Pants inferred "
264
                    f"{description} `{inferred}` from the URL."
265
                )
UNCOV
266
            return explicit
×
267

UNCOV
268
    maybe_py_version = _validate_inferred(
×
269
        explicit=maybe_py_version,
270
        inferred=maybe_inferred_py_version,
271
        description="version",
272
        field_pos="first",
273
    )
274

UNCOV
275
    maybe_pbs_release_tag = _validate_inferred(
×
276
        explicit=maybe_pbs_release_tag,
277
        inferred=maybe_inferred_pbs_release_tag,
278
        description="PBS release tag",
279
        field_pos="first",
280
    )
281

UNCOV
282
    maybe_platform = _validate_inferred(
×
283
        explicit=maybe_platform,
284
        inferred=maybe_inferred_platform,
285
        description="platform",
286
        field_pos="second",
287
    )
288

UNCOV
289
    return _ParsedPBSPython(
×
290
        py_version=maybe_py_version,
291
        pbs_release_tag=maybe_pbs_release_tag,
292
        platform=maybe_platform,
293
        url=url,
294
        sha256=sha256,
295
        size=filesize,
296
    )
297

298

UNCOV
299
@functools.cache
×
UNCOV
300
def load_pbs_pythons() -> PBSVersionsT:
×
UNCOV
301
    versions_info = json.loads(read_sibling_resource(__name__, "versions_info.json"))
×
UNCOV
302
    pbs_release_metadata = versions_info["pythons"]
×
UNCOV
303
    return cast("PBSVersionsT", pbs_release_metadata)
×
304

305

UNCOV
306
class PBSPythonProviderSubsystem(Subsystem):
×
UNCOV
307
    options_scope = "python-build-standalone-python-provider"
×
UNCOV
308
    name = "python-build-standalone"
×
UNCOV
309
    help = softwrap(
×
310
        """
311
        A subsystem for Pants-provided Python leveraging Python Build Standalone (or PBS) (https://gregoryszorc.com/docs/python-build-standalone/main/).
312

313
        Enabling this subsystem will switch Pants from trying to find an appropriate Python on your
314
        system to using PBS to download the correct Python(s).
315

316
        The Pythons provided by PBS will be used to run any "user" code (your Python code as well
317
        as any Python-based tools you use, like black or pylint). The Pythons are also read-only to
318
        ensure they remain hermetic across runs of different tools and code.
319

320
        The Pythons themselves are stored in your `named_caches_dir`: https://www.pantsbuild.org/docs/reference-global#named_caches_dir
321
        under `python_build_standalone/<version>`. Wiping the relevant version directory
322
        (with `sudo rm -rf`) will force a re-download of Python.
323

324
        WARNING: PBS does have some behavior quirks, most notably that it has some hardcoded references
325
        to build-time paths (such as constants that are found in the `sysconfig` module). These paths
326
        may be used when trying to compile some extension modules from source.
327

328
        For more info, see https://gregoryszorc.com/docs/python-build-standalone/main/quirks.html.
329
        """
330
    )
331

UNCOV
332
    known_python_versions = StrListOption(
×
333
        default=None,
334
        default_help_repr=f"<Metadata for versions: {', '.join(sorted(load_pbs_pythons()))}>",
335
        advanced=True,
336
        help=textwrap.dedent(
337
            f"""
338
            Known versions to verify downloads against.
339

340
            Each element is a pipe-separated string of either `py_version+pbs_release_tag|platform|sha256|length|url` or
341
            `sha256|length|url`, where:
342

343
            - `py_version` is the Python version string
344
            - `pbs_release_tag` is the PBS release tag (i.e., the PBS-specific version)
345
            - `platform` is one of `[{",".join(Platform.__members__.keys())}]`
346
            - `sha256` is the 64-character hex representation of the expected sha256
347
                digest of the download file, as emitted by `shasum -a 256`
348
            - `length` is the expected length of the download file in bytes, as emitted by
349
                `wc -c`
350
            - `url` is the download URL to the `.tar.gz` archive
351

352
            E.g., `3.1.2|macos_x86_64|6d0f18cd84b918c7b3edd0203e75569e0c7caecb1367bbbe409b44e28514f5be|42813|https://<URL>`
353
            or `https://<URL>|6d0f18cd84b918c7b3edd0203e75569e0c7caecb1367bbbe409b44e28514f5be|42813`.
354

355
            Values are space-stripped, so pipes can be indented for readability if necessary. If the three field
356
            format is used, then Pants will infer the `py_version`, `pbs_release_tag`, and `platform` fields from
357
            the URL. With the five field format, one or more of `py_version`, `pbs_release_tag`, and `platform`
358
            may be left blank if Pants can infer the field from the URL.
359

360
            Additionally, any versions you specify here will override the default Pants metadata for
361
            that version.
362
            """
363
        ),
364
    )
365

UNCOV
366
    _release_constraints = StrOption(
×
367
        default=None,
368
        help=textwrap.dedent(
369
            """
370
            Version constraints on the PBS "release" version to ensure only matching PBS releases are considered.
371
            Constraints should be specfied using operators like `>=`, `<=`, `>`, `<`, `==`, or `!=` in a similar
372
            manner to Python interpreter constraints: e.g., `>=20241201` or `>=20241201,<20250101`.
373
            """
374
        ),
375
    )
376

UNCOV
377
    @memoized_property
×
UNCOV
378
    def release_constraints(self) -> ConstraintsList:
×
379
        rcs = self._release_constraints
×
380
        if rcs is None or not rcs.strip():
×
381
            return ConstraintsList([])
×
382

383
        try:
×
384
            return ConstraintsList.parse(self._release_constraints or "")
×
385
        except ConstraintParseError as e:
×
386
            raise OptionsError(
×
387
                f"The `[{PBSPythonProviderSubsystem.options_scope}].release_constraints option` is not valid: {e}"
388
            ) from None
389

UNCOV
390
    def get_user_supplied_pbs_pythons(self) -> PBSVersionsT:
×
UNCOV
391
        user_supplied_pythons: dict[str, dict[str, dict[str, PBSPythonInfo]]] = {}
×
392

UNCOV
393
        for version_info in self.known_python_versions or []:
×
UNCOV
394
            version_parts = [x.strip() for x in version_info.split("|")]
×
UNCOV
395
            if len(version_parts) not in (3, 5):
×
396
                raise ExternalToolError(
×
397
                    f"Each value for the `[{PBSPythonProviderSubsystem.options_scope}].known_python_versions` option "
398
                    "must be a set of three or five values separated by `|` characters as follows:\n\n"
399
                    "- 3 fields: URL|SHA256|FILE_SIZE\n\n"
400
                    "- 5 fields: PYTHON_VERSION+PBS_RELEASE|PLATFORM|SHA256|FILE_SIZE|URL\n\n"
401
                    "\n\nIf 3 fields are provided, Pants will attempt to infer values based on the URL which must "
402
                    "follow the PBS naming conventions.\n\n"
403
                    f"Instead, the following value was provided: {version_info}"
404
                )
405

UNCOV
406
            info = (
×
407
                _parse_from_three_fields(version_parts, orig_value=version_info)
408
                if len(version_parts) == 3
409
                else _parse_from_five_fields(version_parts, orig_value=version_info)
410
            )
411

UNCOV
412
            py_version: str = str(info.py_version)
×
UNCOV
413
            pbs_release_tag: str = str(info.pbs_release_tag)
×
414

UNCOV
415
            if py_version not in user_supplied_pythons:
×
UNCOV
416
                user_supplied_pythons[py_version] = {}
×
UNCOV
417
            if pbs_release_tag not in user_supplied_pythons[py_version]:
×
UNCOV
418
                user_supplied_pythons[py_version][pbs_release_tag] = {}
×
419

UNCOV
420
            pbs_python_info = PBSPythonInfo(url=info.url, sha256=info.sha256, size=info.size)
×
421

UNCOV
422
            user_supplied_pythons[py_version][pbs_release_tag][info.platform.value] = (
×
423
                pbs_python_info
424
            )
425

UNCOV
426
        return user_supplied_pythons
×
427

UNCOV
428
    def get_all_pbs_pythons(self) -> PBSVersionsT:
×
429
        all_pythons = load_pbs_pythons().copy()
×
430

431
        user_supplied_pythons: PBSVersionsT = self.get_user_supplied_pbs_pythons()
×
432

433
        for py_version, release_metadatas_for_py_version in user_supplied_pythons.items():
×
434
            for (
×
435
                release_tag,
436
                platform_metadata_for_releases,
437
            ) in release_metadatas_for_py_version.items():
438
                for platform_name, platform_metadata in platform_metadata_for_releases.items():
×
439
                    if py_version not in all_pythons:
×
440
                        all_pythons[py_version] = {}
×
441
                    if release_tag not in all_pythons[py_version]:
×
442
                        all_pythons[py_version][release_tag] = {}
×
443
                    all_pythons[py_version][release_tag][platform_name] = platform_metadata
×
444

445
        return all_pythons
×
446

447

UNCOV
448
class PBSPythonProvider(PythonProvider):
×
UNCOV
449
    pass
×
450

451

UNCOV
452
def _choose_python(
×
453
    interpreter_constraints: InterpreterConstraints,
454
    universe: Iterable[str],
455
    pbs_versions: Mapping[str, Mapping[str, Mapping[str, PBSPythonInfo]]],
456
    platform: Platform,
457
    release_constraints: ConstraintSatisfied,
458
) -> tuple[str, Version, PBSPythonInfo]:
459
    """Choose the highest supported patchlevel of the lowest supported major/minor version
460
    consistent with any PBS release constraint."""
461

462
    # Construct a list of candidate PBS releases.
UNCOV
463
    candidate_pbs_releases: list[tuple[tuple[int, int, int], Version, PBSPythonInfo]] = []
×
UNCOV
464
    supported_python_triplets = interpreter_constraints.enumerate_python_versions(universe)
×
UNCOV
465
    for triplet in supported_python_triplets:
×
UNCOV
466
        triplet_str = ".".join(map(str, triplet))
×
UNCOV
467
        pbs_version_metadata = pbs_versions.get(triplet_str)
×
UNCOV
468
        if not pbs_version_metadata:
×
UNCOV
469
            continue
×
470

UNCOV
471
        for tag, platform_metadata in pbs_version_metadata.items():
×
UNCOV
472
            if not release_constraints.is_satisified(Version(tag)):
×
UNCOV
473
                continue
×
474

UNCOV
475
            pbs_version_platform_metadata = platform_metadata.get(platform.value)
×
UNCOV
476
            if not pbs_version_platform_metadata:
×
477
                continue
×
478

UNCOV
479
            candidate_pbs_releases.append((triplet, Version(tag), pbs_version_platform_metadata))
×
480

UNCOV
481
    if not candidate_pbs_releases:
×
UNCOV
482
        raise Exception(
×
483
            softwrap(
484
                f"""\
485
                Failed to find a supported Python Build Standalone for Interpreter Constraint: {interpreter_constraints.description}
486

487
                Supported versions are currently: {sorted(pbs_versions)}.
488

489
                You can teach Pants about newer Python versions supported by Python Build Standalone
490
                by setting the `known_python_versions` option in the {PBSPythonProviderSubsystem.name}
491
                subsystem. Run `{bin_name()} help-advanced {PBSPythonProviderSubsystem.options_scope}`
492
                for more info.
493
                """
494
            )
495
        )
496

497
    # Choose the highest supported patchlevel of the lowest supported major/minor version
498
    # by searching until the major/minor version increases or the search ends (in which case the
499
    # last candidate is the one).
500
    #
501
    # This also sorts by release tag in ascending order. So it chooses the highest available PBS
502
    # release for that chosen Python version.
UNCOV
503
    candidate_pbs_releases.sort(key=lambda x: (x[0], x[1]))
×
UNCOV
504
    for i, (py_version_triplet, pbs_version, metadata) in enumerate(candidate_pbs_releases):
×
UNCOV
505
        if (
×
506
            # Last candidate, we're good!
507
            i == len(candidate_pbs_releases) - 1
508
            # Next candidate is the next major/minor version, so this is the highest patchlevel.
509
            or candidate_pbs_releases[i + 1][0][0:2] != py_version_triplet[0:2]
510
        ):
UNCOV
511
            return (".".join(map(str, py_version_triplet)), pbs_version, metadata)
×
512

513
    raise AssertionError("The loop should have returned the final item.")
×
514

515

UNCOV
516
@rule
×
UNCOV
517
async def get_python(
×
518
    request: PBSPythonProvider,
519
    python_setup: PythonSetup,
520
    pbs_subsystem: PBSPythonProviderSubsystem,
521
    platform: Platform,
522
    named_caches_dir: NamedCachesDirOption,
523
    sh: ShBinary,
524
    mkdir: MkdirBinary,
525
    cp: CpBinary,
526
    mv: MvBinary,
527
    rm: RmBinary,
528
    test: TestBinary,
529
) -> PythonExecutable:
530
    versions_info = pbs_subsystem.get_all_pbs_pythons()
×
531

532
    python_version, _pbs_version, pbs_py_info = _choose_python(
×
533
        request.interpreter_constraints,
534
        python_setup.interpreter_versions_universe,
535
        versions_info,
536
        platform,
537
        pbs_subsystem.release_constraints,
538
    )
539

540
    downloaded_python = await download_external_tool(
×
541
        ExternalToolRequest(
542
            DownloadFile(
543
                pbs_py_info["url"],
544
                FileDigest(
545
                    pbs_py_info["sha256"],
546
                    pbs_py_info["size"],
547
                ),
548
            ),
549
            "python/bin/python3",
550
        )
551
    )
552

553
    sandbox_cache_dir = PurePath(PBS_SANDBOX_NAME)
×
554

555
    # The final desired destination within the named cache:
556
    persisted_destination = sandbox_cache_dir / python_version
×
557

558
    # Temporary directory (on the same filesystem as the persisted destination) to copy to,
559
    # incorporating uniqueness so that we don't collide with concurrent invocations
560
    temp_dir = sandbox_cache_dir / "tmp" / f"pbs-copier-{uuid.uuid4()}"
×
561
    copy_target = temp_dir / python_version
×
562

563
    await fallible_to_exec_result_or_raise(
×
564
        **implicitly(
565
            Process(
566
                [
567
                    sh.path,
568
                    "-euc",
569
                    # Atomically-copy the downloaded files into the named cache, in 5 steps:
570
                    #
571
                    # 1. Check if the target directory already exists, skipping all the work if it does
572
                    #    (the atomic creation means this will be fully created by an earlier execution,
573
                    #    no torn state).
574
                    #
575
                    # 2. Copy the files into a temporary directory within the persistent named cache. Copying
576
                    #    into a temporary directory ensures that we don't end up with partial state if this
577
                    #    process is interrupted. Placing the temporary directory within the persistent named
578
                    #    cache ensures it is on the same filesystem as the final destination, allowing for
579
                    #    atomic mv.
580
                    #
581
                    # 3. Actually move the temporary directory to the final destination, failing if it
582
                    #    already exists (which would indicate a concurrent execution), but squashing that
583
                    #    error. Note that this is specifically moving to the parent directory of the target,
584
                    #    i.e. something like:
585
                    #
586
                    #        mv .python_build_standalone/tmp/pbs-copier-.../3.10.11 .python_build_standalone
587
                    #
588
                    #    which detects `.python_build_standalone` is a directory and thus attempts to create
589
                    #    `.python_build_standalone/3.10.11`. This fails if that target already exists. The
590
                    #    alternative of explicitly passing the final target like
591
                    #    `mv ... .python_build_standalone/3.10.11` will (incorrectly) create nested
592
                    #    directory `.python_build_standalone/3.10.11/3.10.11` if the target already exists.
593
                    #
594
                    # 4. Check it worked. In particular, mv might fail for a different reason than the final
595
                    #    destination already existing: in those cases, we won't have put things in the right
596
                    #    place, and downstream code won't have the Python it needs. So, we check that the
597
                    #    final destination exists and fail if it doesn't, surfacing any errors to the user.
598
                    #
599
                    # 5. Clean-up the temporary files
600
                    f"""
601
                    # Step 1: check and skip
602
                    if {test.path} -d {persisted_destination}; then
603
                        echo "{persisted_destination} already exists, fully created by earlier execution" >&2
604
                        exit 0
605
                    fi
606

607
                    # Step 2: copy from the digest into the named cache
608
                    {mkdir.path} -p "{temp_dir}"
609
                    {cp.path} -R python "{copy_target}"
610

611
                    # Step 3: attempt to move, squashing the error
612
                    {mv.path} "{copy_target}" "{persisted_destination.parent}" || echo "mv failed: $?" >&2
613

614
                    # Step 4: confirm and clean-up
615
                    if ! {test.path} -d "{persisted_destination}"; then
616
                        echo "Failed to create {persisted_destination}" >&2
617
                        exit 1
618
                    fi
619

620
                    # Step 5: remove the temporary directory
621
                    {rm.path} -rf "{temp_dir}"
622
                    """,
623
                ],
624
                level=LogLevel.DEBUG,
625
                input_digest=downloaded_python.digest,
626
                description=f"Install Python {python_version}",
627
                append_only_caches=PBS_APPEND_ONLY_CACHES,
628
                # Don't cache, we want this to always be run so that we can assume for the rest of the
629
                # session the named_cache destination for this Python is valid, as the Python ecosystem
630
                # mainly assumes absolute paths for Python interpreters.
631
                cache_scope=ProcessCacheScope.PER_SESSION,
632
            ),
633
        ),
634
    )
635

636
    python_path = named_caches_dir.val / PBS_NAMED_CACHE_NAME / python_version / "bin" / "python3"
×
637
    return PythonExecutable(
×
638
        path=str(python_path),
639
        fingerprint=None,
640
        # One would normally set append_only_caches=PBS_APPEND_ONLY_CACHES
641
        # here, but it is already going to be injected into the pex
642
        # environment by PythonBuildStandaloneBinary
643
    )
644

645

UNCOV
646
def rules():
×
UNCOV
647
    return (
×
648
        *collect_rules(),
649
        *pex_rules(),
650
        *external_tools_rules(),
651
        UnionRule(PythonProvider, PBSPythonProvider),
652
    )
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