Skip to content

Commit 0ffa5d8

Browse files
committed
feat: support manifest_urls fallback mirrors and parsing in python.override
Introduces support for mirror fallback download urls inside python.override and parses python-build-standalone SHA manifests dynamically in Bzlmod. Features: - Added 'add_runtime_manifest_urls' (string_list) and 'runtime_manifest_sha' (string) tags inside python.override, allowing multiple mirrors for hermetic manifest and release asset downloads. - Implemented Starlark manifest parser in 'python/private/pbs_manifest.bzl' containing 'parse_filename' (filename components dictionary parser) and 'parse_sha_manifest' (spaces-robust manifest entries parser). - Resolved C header compilation issues in full hermetic builds by dynamically setting strip_prefix to 'python/install' for full builds and 'python' for stripped runtimes, and filtered flavor downloads to prevent install_only overrides. - Added 'tests/support/mocks/python_ext.bzl' mock helper automatically injecting default tag/module attributes to simplify Bzlmod extension mock unit tests. - Wrote comprehensive Starlark unit tests in 'tests/python_bzlmod_ext' and Bazel-in-Bazel integration tests verifying the hermetic interpreter path, exact version (3.11.15), and build date (20260414).
1 parent a9de4d5 commit 0ffa5d8

14 files changed

Lines changed: 616 additions & 2 deletions

File tree

.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

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

python/private/python.bzl

Lines changed: 98 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,68 @@ 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+
filename = entry.filename
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 filename:
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+
urls = ["{}/{}".format(base_url, filename) for base_url in base_download_urls]
793+
794+
v_dict = available_versions.setdefault(py_version, {})
795+
v_dict.setdefault("sha256", {})[matched_platform] = sha256
796+
v_dict.setdefault("url", {})[matched_platform] = urls
797+
if is_full:
798+
v_dict.setdefault("strip_prefix", {})[matched_platform] = "python/install"
799+
else:
800+
v_dict.setdefault("strip_prefix", {})[matched_platform] = "python"
801+
802+
def _get_toolchain_config(*, mctx, modules, _fail = fail):
745803
"""Computes the configs for toolchains.
746804
747805
Args:
806+
mctx: The module context.
748807
modules: The modules from module_ctx
749808
_fail: Function to call for failing; only used for testing.
750809
@@ -786,6 +845,19 @@ def _get_toolchain_config(*, modules, _fail = fail):
786845
else:
787846
available_versions[py_version]["url"] = dict(url)
788847

848+
# Check for add_runtime_manifest_urls in override tags in root module
849+
root_module = modules[0] if modules else None
850+
if root_module and root_module.is_root:
851+
for tag in root_module.tags.override:
852+
if tag.add_runtime_manifest_urls:
853+
_populate_from_pbs_manifest(
854+
mctx = mctx,
855+
add_runtime_manifest_urls = tag.add_runtime_manifest_urls,
856+
runtime_manifest_sha = tag.runtime_manifest_sha,
857+
available_versions = available_versions,
858+
_fail = _fail,
859+
)
860+
789861
default = {
790862
"base_url": DEFAULT_RELEASE_BASE_URL,
791863
"platforms": dict(PLATFORMS), # Copy so it's mutable.
@@ -1111,6 +1183,21 @@ _override = tag_class(
11111183
:::
11121184
""",
11131185
attrs = {
1186+
"add_runtime_manifest_urls": attr.string_list(
1187+
mandatory = False,
1188+
doc = """
1189+
URLs pointing to python-build-standalone manifest files (e.g., SHA256SUMS).
1190+
1191+
Example:
1192+
`https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS`
1193+
1194+
Note that `/latest/` can be used in place of a specific release date (e.g., `20260414`) to automatically use the latest release:
1195+
`https://github.com/astral-sh/python-build-standalone/releases/latest/download/SHA256SUMS`
1196+
1197+
:::{versionadded} VERSION_NEXT_FEATURE
1198+
:::
1199+
""",
1200+
),
11141201
"add_target_settings": attr.string_list(
11151202
mandatory = False,
11161203
doc = """\
@@ -1180,6 +1267,15 @@ The values in this mapping override the default values and do not replace them.
11801267
default = {},
11811268
),
11821269
"register_all_versions": attr.bool(default = False, doc = "Add all versions"),
1270+
"runtime_manifest_sha": attr.string(
1271+
mandatory = False,
1272+
doc = """
1273+
SHA256 hash for the add_runtime_manifest_urls.
1274+
1275+
:::{versionadded} VERSION_NEXT_FEATURE
1276+
:::
1277+
""",
1278+
),
11831279
} | AUTH_ATTRS,
11841280
)
11851281

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:py_test.bzl", "py_test")
2+
3+
py_test(
4+
name = "basic_test",
5+
srcs = ["basic_test.py"],
6+
python_version = "3.11",
7+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module(name = "runtime_manifests")
2+
3+
bazel_dep(name = "rules_python", version = "0.0.0")
4+
local_path_override(
5+
module_name = "rules_python",
6+
path = "../../..",
7+
)
8+
9+
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
10+
python.override(
11+
add_runtime_manifest_urls = [
12+
"https://github.com/astral-sh/python-build-standalone/releases/download/20260414/SHA256SUMS",
13+
],
14+
register_all_versions = True,
15+
runtime_manifest_sha = "ce18fdfd47c66830a40ea9b9e314a14b1636bbfd684501bc5ca1fc6d55a7933f",
16+
)
17+
python.toolchain(
18+
python_version = "3.11.15",
19+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Workspace boundary file required by rules_bazel_integration_test
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import datetime
2+
import platform
3+
import sys
4+
import unittest
5+
6+
7+
class BasicTest(unittest.TestCase):
8+
9+
def test_basic(self):
10+
print("Hello World from Python {}!".format(sys.version))
11+
print("Interpreter executable path: {}".format(sys.executable))
12+
13+
# Verify that the hermetic interpreter inside Bazel's output/sandbox tree is used
14+
self.assertIn(".cache/bazel", sys.executable)
15+
16+
# Verify that the exact custom version (3.11.15) parsed from the manifest is used
17+
self.assertEqual(sys.version_info[:3], (3, 11, 15))
18+
19+
# Verify that the exact build version (20260414) parsed from the manifest is used
20+
buildno, builddate = platform.python_build()
21+
date_str = " ".join(builddate.split()[:3])
22+
dt = datetime.datetime.strptime(date_str, "%b %d %Y")
23+
formatted_date = dt.strftime("%Y%m%d")
24+
self.assertEqual(formatted_date, "20260414")
25+
26+
27+
if __name__ == "__main__":
28+
unittest.main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
load(":parse_sha_manifest_tests.bzl", "parse_sha_manifest_test_suite")
2+
load(":runtime_manifests_tests.bzl", "runtime_manifests_test_suite")
3+
4+
parse_sha_manifest_test_suite(name = "parse_sha_manifest_tests")
5+
6+
runtime_manifests_test_suite(name = "runtime_manifests_tests")

0 commit comments

Comments
 (0)