Skip to content

Commit 57479a5

Browse files
committed
feat(toolchains): support dynamic registration from remote manifest
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.
1 parent 10e1f7c commit 57479a5

22 files changed

Lines changed: 817 additions & 5 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/modules/another_module
4344
common --deleted_packages=tests/modules/other

‎CHANGELOG.md‎

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

105105
{#v0-0-0-added}
106106
### Added
107+
* (toolchains) Support dynamically fetching and registering Python runtimes
108+
from a python-build-standalone manifest file using
109+
`python.override(add_runtime_manifest_urls = ..., runtime_manifest_sha = ...)`.
107110
* (toolchain) Added {obj}`python.override.toolchain_target_settings` to allow
108111
adding `config_setting` labels to all registered toolchains.
109112
* (windows) Full venv support for Windows is available. Set

‎docs/toolchains.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ 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 remote manifest file
246+
via {attr}`python.override.add_runtime_manifest_urls`.
245247

246248
### Registering custom runtimes
247249

@@ -310,6 +312,65 @@ Added support for custom platform names, `target_compatible_with`, and
310312
`target_settings` with `single_version_platform_override`.
311313
:::
312314

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

315376
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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
Example: cpython-3.10.20+20260414-x86_64_v2-unknown-linux-musl-lto-full.tar.zst
7+
8+
Args:
9+
filename: The filename or URL of the python-build-standalone release asset.
10+
11+
Returns:
12+
A dictionary of parsed components if parsed successfully, else None.
13+
"""
14+
basename = filename.rpartition("/")[-1]
15+
if basename.endswith(".tar.zst"):
16+
name = basename.removesuffix(".tar.zst")
17+
elif basename.endswith(".tar.gz"):
18+
name = basename.removesuffix(".tar.gz")
19+
else:
20+
return None
21+
22+
if not name.startswith("cpython-"):
23+
return None
24+
name = name.removeprefix("cpython-")
25+
26+
left, plus, tail = name.partition("+")
27+
if plus:
28+
python_version = left
29+
build_version, sep, rest = tail.partition("-")
30+
if not sep:
31+
return None
32+
else:
33+
python_version, sep, rest = left.partition("-")
34+
if not sep:
35+
return None
36+
build_version = ""
37+
38+
arch, sep, rest = rest.partition("-")
39+
if not sep:
40+
return None
41+
42+
microarch = ""
43+
arch_base, sep_v, microarch_num = arch.partition("_v")
44+
if sep_v:
45+
arch = arch_base
46+
microarch = "v" + microarch_num
47+
48+
vendor, sep, rest = rest.partition("-")
49+
if not sep:
50+
return None
51+
52+
os, sep, rest = rest.partition("-")
53+
if not sep:
54+
return None
55+
56+
libc = ""
57+
next_part, _, remaining = rest.partition("-")
58+
if os == "linux" and next_part in ["gnu", "musl"]:
59+
libc = next_part
60+
flavor = remaining
61+
elif os == "windows" and next_part == "msvc":
62+
libc = next_part
63+
flavor = remaining
64+
else:
65+
libc = ""
66+
flavor = rest
67+
68+
return {
69+
"arch": arch,
70+
"build_version": build_version,
71+
"flavor": flavor,
72+
"libc": libc,
73+
"location": filename,
74+
"microarch": microarch,
75+
"os": os,
76+
"python_version": python_version,
77+
"vendor": vendor,
78+
}
79+
80+
def parse_sha_manifest(content):
81+
"""Parses the SHA256SUMS file content into a list of structs.
82+
83+
Args:
84+
content: The raw content of the manifest file.
85+
86+
Returns:
87+
A list of structs capturing the parsed components of each valid entry.
88+
Each struct contains the following fields:
89+
- arch: CPU architecture (e.g., "x86_64").
90+
- build_version: Standalone release date (e.g., "20260414").
91+
- location: Full package filename or URL (e.g., "cpython-3.11.15..." or "https://...").
92+
- flavor: Build configuration flavor (e.g., "install_only").
93+
- libc: C library type (e.g., "gnu", "musl", "msvc", or "").
94+
- microarch: Microarchitecture level (e.g., "v2", "v3", or "").
95+
- os: Operating system (e.g., "linux", "darwin", "windows").
96+
- python_version: Python semver version (e.g., "3.11.15").
97+
- sha256: SHA256 integrity hash of the release asset.
98+
- vendor: Platform vendor (e.g., "unknown", "apple").
99+
"""
100+
results = []
101+
for line in content.split("\n"):
102+
line = line.strip()
103+
if not line:
104+
continue
105+
parts = [p for p in line.split(" ") if p]
106+
if len(parts) != 2:
107+
continue
108+
sha256, filename = parts
109+
110+
parsed = parse_filename(filename)
111+
if parsed:
112+
results.append(struct(
113+
sha256 = sha256,
114+
**parsed
115+
))
116+
return results

‎python/private/python.bzl‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ load("@bazel_features//:features.bzl", "bazel_features")
1818
load("//python:versions.bzl", "DEFAULT_RELEASE_BASE_URL", "PLATFORMS", "TOOL_VERSIONS")
1919
load(":auth.bzl", "AUTH_ATTRS")
2020
load(":full_version.bzl", "full_version")
21+
load(":pbs_manifest.bzl", "parse_sha_manifest")
2122
load(":platform_info.bzl", "platform_info")
2223
load(":python_register_toolchains.bzl", "python_register_toolchains")
2324
load(":pythons_hub.bzl", "hub_repo")
@@ -76,7 +77,7 @@ def parse_modules(*, module_ctx, logger = None, _fail = fail):
7677
# Map of string Major.Minor or Major.Minor.Patch to the toolchain_info struct
7778
global_toolchain_versions = {}
7879

79-
config = _get_toolchain_config(modules = module_ctx.modules, _fail = _fail)
80+
config = _get_toolchain_config(mctx = module_ctx, modules = module_ctx.modules, _fail = _fail)
8081

8182
default_python_version = _compute_default_python_version(module_ctx)
8283

@@ -741,10 +742,71 @@ def _override_defaults(*overrides, modules, _fail = fail, default):
741742

742743
override.fn(tag = tag, _fail = _fail, default = default)
743744

744-
def _get_toolchain_config(*, modules, _fail = fail):
745+
def _populate_from_pbs_manifest(*, mctx, add_runtime_manifest_urls, runtime_manifest_sha = "", available_versions, _fail):
746+
manifest_path = mctx.path("runtime_manifest")
747+
result = mctx.download(
748+
url = add_runtime_manifest_urls,
749+
output = manifest_path,
750+
sha256 = runtime_manifest_sha,
751+
)
752+
if not result.success:
753+
_fail("Failed to download manifest from {}: {}".format(add_runtime_manifest_urls, result))
754+
return
755+
756+
content = mctx.read(manifest_path)
757+
base_download_urls = [url.rpartition("/")[0] for url in add_runtime_manifest_urls]
758+
759+
parsed_entries = parse_sha_manifest(content)
760+
761+
for entry in parsed_entries:
762+
location = entry.location
763+
sha256 = entry.sha256
764+
py_version = entry.python_version
765+
766+
# Fallback to matching against PLATFORMS keys as before to ensure compatibility
767+
# with rules_python expected platform keys.
768+
matched_platform = None
769+
for platform in PLATFORMS.keys():
770+
if platform in location:
771+
matched_platform = platform
772+
break
773+
774+
if not matched_platform:
775+
continue
776+
777+
expects_full = matched_platform in [
778+
"aarch64-apple-darwin",
779+
"aarch64-unknown-linux-gnu",
780+
"ppc64le-unknown-linux-gnu",
781+
"riscv64-unknown-linux-gnu",
782+
"s390x-unknown-linux-gnu",
783+
"x86_64-apple-darwin",
784+
"x86_64-pc-windows-msvc",
785+
"x86_64-unknown-linux-gnu",
786+
"x86_64-unknown-linux-musl",
787+
]
788+
is_full = entry.flavor.endswith("-full")
789+
if expects_full != is_full:
790+
continue
791+
792+
if "://" in location:
793+
urls = [location]
794+
else:
795+
urls = ["{}/{}".format(base_url, location) for base_url in base_download_urls]
796+
797+
v_dict = available_versions.setdefault(py_version, {})
798+
v_dict.setdefault("sha256", {})[matched_platform] = sha256
799+
v_dict.setdefault("url", {})[matched_platform] = urls
800+
if is_full:
801+
v_dict.setdefault("strip_prefix", {})[matched_platform] = "python/install"
802+
else:
803+
v_dict.setdefault("strip_prefix", {})[matched_platform] = "python"
804+
805+
def _get_toolchain_config(*, mctx, modules, _fail = fail):
745806
"""Computes the configs for toolchains.
746807
747808
Args:
809+
mctx: The module context.
748810
modules: The modules from module_ctx
749811
_fail: Function to call for failing; only used for testing.
750812
@@ -786,6 +848,19 @@ def _get_toolchain_config(*, modules, _fail = fail):
786848
else:
787849
available_versions[py_version]["url"] = dict(url)
788850

851+
# Check for add_runtime_manifest_urls in override tags in root module
852+
root_module = modules[0] if modules else None
853+
if root_module and root_module.is_root:
854+
for tag in root_module.tags.override:
855+
if tag.add_runtime_manifest_urls:
856+
_populate_from_pbs_manifest(
857+
mctx = mctx,
858+
add_runtime_manifest_urls = tag.add_runtime_manifest_urls,
859+
runtime_manifest_sha = tag.runtime_manifest_sha,
860+
available_versions = available_versions,
861+
_fail = _fail,
862+
)
863+
789864
default = {
790865
"base_url": DEFAULT_RELEASE_BASE_URL,
791866
"platforms": dict(PLATFORMS), # Copy so it's mutable.
@@ -1111,6 +1186,21 @@ _override = tag_class(
11111186
:::
11121187
""",
11131188
attrs = {
1189+
"add_runtime_manifest_urls": attr.string_list(
1190+
mandatory = False,
1191+
doc = """
1192+
URLs pointing to python-build-standalone manifest files (e.g., SHA256SUMS).
1193+
1194+
Example:
1195+
`https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS`
1196+
1197+
Note that `/latest/` can be used in place of a specific release date (e.g., `20260414`) to automatically use the latest release:
1198+
`https://github.com/astral-sh/python-build-standalone/releases/latest/download/SHA256SUMS`
1199+
1200+
:::{versionadded} VERSION_NEXT_FEATURE
1201+
:::
1202+
""",
1203+
),
11141204
"add_target_settings": attr.string_list(
11151205
mandatory = False,
11161206
doc = """\
@@ -1180,6 +1270,15 @@ The values in this mapping override the default values and do not replace them.
11801270
default = {},
11811271
),
11821272
"register_all_versions": attr.bool(default = False, doc = "Add all versions"),
1273+
"runtime_manifest_sha": attr.string(
1274+
mandatory = False,
1275+
doc = """
1276+
SHA256 hash for the add_runtime_manifest_urls.
1277+
1278+
:::{versionadded} VERSION_NEXT_FEATURE
1279+
:::
1280+
""",
1281+
),
11831282
} | AUTH_ATTRS,
11841283
)
11851284

‎tests/integration/BUILD.bazel‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ rules_python_integration_test(
101101
workspace_path = "py_cc_toolchain_registered",
102102
)
103103

104+
rules_python_integration_test(
105+
name = "runtime_manifests_test",
106+
)
107+
104108
rules_python_integration_test(
105109
name = "custom_commands_test",
106110
py_main = "custom_commands_test.py",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copy of fast-tests config
2+
common:fast-tests --build_tests_only=true
3+
common:fast-tests --build_tag_filters=-large,-enormous,-integration-test
4+
common:fast-tests --test_tag_filters=-large,-enormous,-integration-test

0 commit comments

Comments
 (0)