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

pantsbuild / pants / 19250292619

11 Nov 2025 12:09AM UTC coverage: 77.865% (-2.4%) from 80.298%
19250292619

push

github

web-flow
flag non-runnable targets used with `code_quality_tool` (#22875)

2 of 5 new or added lines in 2 files covered. (40.0%)

1487 existing lines in 72 files now uncovered.

71448 of 91759 relevant lines covered (77.86%)

3.22 hits per line

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

67.67
/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).
3
from __future__ import annotations
4✔
4

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

18
from packaging.version import InvalidVersion
4✔
19

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

62
logger = logging.getLogger(__name__)
4✔
63

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

68
_T = TypeVar("_T")  # Define type variable "T"
4✔
69

70

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

76

77
PBSVersionsT = dict[str, dict[str, dict[str, PBSPythonInfo]]]
4✔
78

79

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

89

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

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

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

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

113
    return py_version, pbs_release_tag
1✔
114

115

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

120
    base_path_no_prefix = base_path.removeprefix("cpython-")
1✔
121
    if base_path_no_prefix == base_path:
1✔
122
        raise ValueError(
1✔
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

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

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

161
    return py_version, pbs_release_tag, platform
1✔
162

163

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

168
    try:
1✔
169
        py_version, pbs_release_tag, platform = _parse_pbs_url(url)
1✔
170
    except ValueError as e:
1✔
171
        raise ExternalToolError(
1✔
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

176
    return _ParsedPBSPython(
1✔
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

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

190
    try:
1✔
191
        maybe_py_version, maybe_pbs_release_tag = _parse_py_version_and_pbs_release_tag(
1✔
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

201
    maybe_platform: Platform | None = None
1✔
202
    if not platform_str:
1✔
203
        pass
1✔
204
    elif platform_str in (
1✔
205
        Platform.linux_x86_64.value,
206
        Platform.linux_arm64.value,
207
        Platform.macos_x86_64.value,
208
        Platform.macos_arm64.value,
209
    ):
210
        maybe_platform = Platform(platform_str)
1✔
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

219
    if len(sha256) != 64 or not re.match("^[a-zA-Z0-9]+$", sha256):
1✔
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

226
    try:
1✔
227
        filesize: int = int(filesize_str)
1✔
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

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

247
    def _validate_inferred(
1✔
248
        *, explicit: _T | None, inferred: _T | None, description: str, field_pos: str
249
    ) -> _T:
250
        if explicit is None:
1✔
251
            if inferred is None:
1✔
252
                raise ExternalToolError(
1✔
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:
258
                return inferred
1✔
259
        else:
260
            if inferred is not None and explicit != inferred:
1✔
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
                )
266
            return explicit
1✔
267

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

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

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

289
    return _ParsedPBSPython(
1✔
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

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

305

306
class PBSPythonProviderSubsystem(Subsystem):
4✔
307
    options_scope = "python-build-standalone-python-provider"
4✔
308
    name = "python-build-standalone"
4✔
309
    help = softwrap(
4✔
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

332
    known_python_versions = StrListOption(
4✔
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

366
    _release_constraints = StrOption(
4✔
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

377
    @memoized_property
4✔
378
    def release_constraints(self) -> ConstraintsList:
4✔
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

390
    def get_user_supplied_pbs_pythons(self) -> PBSVersionsT:
4✔
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

428
    def get_all_pbs_pythons(self) -> PBSVersionsT:
4✔
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

448
class PBSPythonProvider(PythonProvider):
4✔
449
    pass
4✔
450

451

452
def _choose_python(
4✔
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

516
@rule
4✔
517
async def get_python(
4✔
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

646
def rules():
4✔
647
    return (
3✔
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