Skip to content

Commit 42c8e75

Browse files
authored
feat(toolchains): support runtime registration from manifest (#3802)
Currently, all supported Python runtime versions and their platform-specific metadata (URLs, SHA256s, strip_prefix) must be hardcoded in `python/versions.bzl`. This makes it slow and difficult to adopt new Python versions or custom builds without updating `rules_python` itself. This PR introduces the ability to dynamically fetch and register Python runtimes from a remote python-build-standalone (PBS) manifest file (e.g., `SHA256SUMS`). This is supported via two new attributes in `python.override`: - `add_runtime_manifest_urls`: A list of URLs pointing to manifest files to parse and register. - `runtime_manifest_sha`: The SHA256 hash of the manifest file. The manifest file format is the python-build-standalone SHA256SUMS format (`SHA FILENAME`), extended to allow arbitrary URLs for the filename (to allow more arbitrary locations).
1 parent c0fef46 commit 42c8e75

20 files changed

Lines changed: 862 additions & 4 deletions

.bazelrc.deleted_packages

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ common --deleted_packages=tests/integration/pip_parse
3838
common --deleted_packages=tests/integration/pip_parse/empty
3939
common --deleted_packages=tests/integration/pip_parse_isolated
4040
common --deleted_packages=tests/integration/py_cc_toolchain_registered
41+
common --deleted_packages=tests/integration/runtime_manifests
4142
common --deleted_packages=tests/integration/toolchain_target_settings
4243
common --deleted_packages=tests/integration/uv_lock
4344
common --deleted_packages=tests/modules/another_module

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ END_UNRELEASED_TEMPLATE
114114

115115
{#v0-0-0-added}
116116
### Added
117+
* (toolchains) Support dynamically fetching and registering Python runtimes
118+
from a python-build-standalone manifest file using
119+
`python.override(add_runtime_manifest_urls = ..., runtime_manifest_sha = ...)`.
117120
* (toolchain) Added {obj}`python.override.toolchain_target_settings` to allow
118121
adding `config_setting` labels to all registered toolchains.
119122
* (windows) Full venv support for Windows is available. Set

docs/toolchains.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ existing attributes:
242242
{attr}`python.single_version_platform_override.coverage_tool`.
243243
* Adding additional Python versions via {bzl:obj}`python.single_version_override` or
244244
{bzl:obj}`python.single_version_platform_override`.
245+
* Adding additional Python versions dynamically from a manifest file or URL
246+
via {attr}`python.override.add_runtime_manifest_files` or
247+
{attr}`python.override.add_runtime_manifest_urls`.
245248

246249
### Registering custom runtimes
247250

@@ -310,6 +313,73 @@ Added support for custom platform names, `target_compatible_with`, and
310313
`target_settings` with `single_version_platform_override`.
311314
:::
312315

316+
### Registering runtimes from a manifest
317+
318+
If you want to register multiple custom runtimes or versions at once, you can
319+
use a python-build-standalone manifest file. This is useful if you want to
320+
adopt new versions that are not yet built into `rules_python` without having
321+
to manually define each one using `single_version_platform_override`.
322+
323+
To do this, specify the `add_runtime_manifest_files` or
324+
`add_runtime_manifest_urls` (and `runtime_manifest_sha`) attributes in
325+
`python.override` in your `MODULE.bazel`.
326+
327+
In the example below, we register all runtimes available in a specific local
328+
or remote PBS release manifest:
329+
330+
```starlark
331+
# File: MODULE.bazel
332+
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
333+
python.override(
334+
add_runtime_manifest_files = [
335+
"@//:SHA256SUMS",
336+
],
337+
add_runtime_manifest_urls = [
338+
"https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS",
339+
],
340+
base_url = "https://example.com/downloads",
341+
runtime_manifest_sha = "ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f",
342+
)
343+
```
344+
345+
#### Manifest file format
346+
347+
The manifest must be a plain text file where each line contains the SHA256 hash
348+
and the location of a runtime archive, separated by whitespace:
349+
350+
```
351+
<sha256> <location>
352+
```
353+
354+
The `<location>` can be either:
355+
- A relative filename (e.g.,
356+
`cpython-3.10.20+20260414-x86_64-unknown-linux-gnu-install_only.tar.zst`).
357+
In this case, the download URL is constructed by appending the filename to
358+
the `base_url` attribute (if using `add_runtime_manifest_files`) or to the
359+
parent directory of each URL in `add_runtime_manifest_urls` (treating them
360+
as mirrors).
361+
- An absolute URL (e.g.,
362+
`https://example.com/downloads/cpython-3.10.20+20260414-x86_64-unknown-linux-gnu-install_only.tar.zst`).
363+
In this case, the URL is used directly to download the archive.
364+
365+
In both cases, the filename or the last path segment of the URL must follow
366+
the standard python-build-standalone naming convention. `rules_python` parses
367+
this name to extract runtime metadata (such as Python version, target
368+
architecture, operating system, and libc).
369+
370+
Notes:
371+
- `rules_python` will read or download the manifest, parse it, and
372+
automatically register toolchains for all valid Python runtimes found in it
373+
that match supported platforms.
374+
- Only runtimes matching known platforms in `rules_python` will be registered.
375+
376+
:::{versionadded} VERSION_NEXT_FEATURE
377+
Added support for registering runtimes from a manifest using
378+
`add_runtime_manifest_files`, `add_runtime_manifest_urls`, and
379+
`runtime_manifest_sha` in `python.override`.
380+
:::
381+
382+
313383
### Using defined toolchains from WORKSPACE
314384

315385
It is possible to use toolchains defined in `MODULE.bazel` in `WORKSPACE`. For example,

python/private/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,11 @@ bzl_library(
252252
srcs = ["normalize_name.bzl"],
253253
)
254254

255+
bzl_library(
256+
name = "pbs_manifest_bzl",
257+
srcs = ["pbs_manifest.bzl"],
258+
)
259+
255260
bzl_library(
256261
name = "precompile_bzl",
257262
srcs = ["precompile.bzl"],
@@ -274,6 +279,7 @@ bzl_library(
274279
srcs = ["python.bzl"],
275280
deps = [
276281
":full_version_bzl",
282+
":pbs_manifest_bzl",
277283
":platform_info_bzl",
278284
":python_register_toolchains_bzl",
279285
":pythons_hub_bzl",

python/private/pbs_manifest.bzl

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Helper functions to parse python-build-standalone manifests."""
2+
3+
def parse_filename(filename):
4+
"""Parses a python-build-standalone filename (or URL) into its components.
5+
6+
See https://gregoryszorc.com/docs/python-build-standalone/main/running.html
7+
8+
Example: cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst
9+
10+
Args:
11+
filename: The filename or URL of the python-build-standalone release asset.
12+
13+
Returns:
14+
A dictionary of parsed components if parsed successfully, else None.
15+
"""
16+
basename = filename.rpartition("/")[-1]
17+
if basename.endswith(".tar.zst"):
18+
name = basename.removesuffix(".tar.zst")
19+
elif basename.endswith(".tar.gz"):
20+
name = basename.removesuffix(".tar.gz")
21+
else:
22+
return None
23+
24+
if not name.startswith("cpython-"):
25+
return None
26+
name = name.removeprefix("cpython-")
27+
28+
left, plus, tail = name.partition("+")
29+
if plus:
30+
python_version = left
31+
build_version, sep, rest = tail.partition("-")
32+
if not sep:
33+
return None
34+
else:
35+
python_version, sep, rest = left.partition("-")
36+
if not sep:
37+
return None
38+
build_version = ""
39+
40+
arch, sep, rest = rest.partition("-")
41+
if not sep:
42+
return None
43+
44+
microarch = ""
45+
arch_base, sep_v, microarch_num = arch.partition("_v")
46+
if sep_v:
47+
arch = arch_base
48+
microarch = "v" + microarch_num
49+
50+
vendor, sep, rest = rest.partition("-")
51+
if not sep:
52+
return None
53+
54+
os, sep, rest = rest.partition("-")
55+
if not sep:
56+
return None
57+
58+
libc = ""
59+
next_part, _, remaining = rest.partition("-")
60+
if os == "linux" and next_part in ["gnu", "musl"]:
61+
libc = next_part
62+
flavor = remaining
63+
elif os == "windows" and next_part == "msvc":
64+
libc = next_part
65+
flavor = remaining
66+
else:
67+
libc = ""
68+
flavor = rest
69+
70+
freethreaded = False
71+
if flavor.startswith("freethreaded+"):
72+
freethreaded = True
73+
flavor = flavor.removeprefix("freethreaded+")
74+
elif flavor.startswith("freethreaded-"):
75+
freethreaded = True
76+
flavor = flavor.removeprefix("freethreaded-")
77+
elif flavor == "freethreaded":
78+
freethreaded = True
79+
flavor = ""
80+
81+
archive_flavor = ""
82+
if flavor.endswith("-full"):
83+
archive_flavor = "full"
84+
flavor = flavor.removesuffix("-full")
85+
elif flavor == "full":
86+
archive_flavor = "full"
87+
flavor = ""
88+
elif flavor.endswith("-install_only_stripped"):
89+
archive_flavor = "install_only_stripped"
90+
flavor = flavor.removesuffix("-install_only_stripped")
91+
elif flavor == "install_only_stripped":
92+
archive_flavor = "install_only_stripped"
93+
flavor = ""
94+
elif flavor.endswith("-install_only"):
95+
archive_flavor = "install_only"
96+
flavor = flavor.removesuffix("-install_only")
97+
elif flavor == "install_only":
98+
archive_flavor = "install_only"
99+
flavor = ""
100+
101+
return {
102+
"arch": arch,
103+
"archive_flavor": archive_flavor,
104+
"build_version": build_version,
105+
"flavor": flavor,
106+
"freethreaded": freethreaded,
107+
"libc": libc,
108+
"location": filename,
109+
"microarch": microarch,
110+
"os": os,
111+
"python_version": python_version,
112+
"vendor": vendor,
113+
}
114+
115+
def parse_sha_manifest(content):
116+
"""Parses the SHA256SUMS file content into a list of structs.
117+
118+
Args:
119+
content: The raw content of the manifest file.
120+
121+
Returns:
122+
A list of structs capturing the parsed components of each valid entry.
123+
Each struct contains the following fields:
124+
- arch: CPU architecture (e.g., "x86_64").
125+
- archive_flavor: Release asset archive type (e.g., "full", "install_only").
126+
- build_version: Standalone release date (e.g., "20260414").
127+
- location: Full package filename or URL (e.g., "cpython-3.11.15..." or "https://...").
128+
- flavor: Build configuration flavor (e.g., "install_only").
129+
- freethreaded: Whether the build is free-threaded (boolean).
130+
- libc: C library type (e.g., "gnu", "musl", "msvc", or "").
131+
- microarch: Microarchitecture level (e.g., "v2", "v3", or "").
132+
- os: Operating system (e.g., "linux", "darwin", "windows").
133+
- python_version: Python semver version (e.g., "3.11.15").
134+
- sha256: SHA256 integrity hash of the release asset.
135+
- vendor: Platform vendor (e.g., "unknown", "apple").
136+
"""
137+
results = []
138+
for line in content.split("\n"):
139+
line = line.strip()
140+
if not line:
141+
continue
142+
parts = [p for p in line.split(" ") if p]
143+
if len(parts) != 2:
144+
continue
145+
sha256, filename = parts
146+
147+
parsed = parse_filename(filename)
148+
if parsed:
149+
results.append(struct(
150+
sha256 = sha256,
151+
**parsed
152+
))
153+
return results

0 commit comments

Comments
 (0)