Skip to content

Commit a93fcaf

Browse files
committed
feat(toolchains): let local toolchains point to a label
By letting a local toolchain point to a label, it allows Bazel repository rules to manage the download and creation of the Python runtime itself. This makes it easy to customize where a runtime is coming from.
1 parent 5dfd199 commit a93fcaf

File tree

8 files changed

+196
-18
lines changed

8 files changed

+196
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ END_UNRELEASED_TEMPLATE
107107
{obj}`py_cc_toolchain.headers_abi3`, and {obj}`PyCcToolchainInfo.headers_abi3`.
108108
* {obj}`//python:features.bzl%features.headers_abi3` can be used to
109109
feature-detect the presense of the above.
110+
* (toolchains) Local toolchains can use a label for the interpreter to use.
110111

111112
{#v1-6-3}
112113
## [1.6.3] - 2025-09-21

docs/toolchains.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ local_runtime_toolchains_repo(
460460
register_toolchains("@local_toolchains//:all", dev_dependency = True)
461461
```
462462

463+
In the example above, `interpreter_path` is used to find Python via `PATH`
464+
lookups. Alternatively, {obj}`interpreter_target` can be set, which can
465+
refer to a Python in an arbitrary Bazel repository.
466+
463467
:::{important}
464468
Be sure to set `dev_dependency = True`. Using a local toolchain only makes sense
465469
for the root module.

python/private/local_runtime_repo.bzl

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,41 @@ create thousands of files for every `py_test`), at the risk of having to rely on
196196
a system having the necessary Python installed.
197197
""",
198198
attrs = {
199+
"interpreter_target": attr.label(
200+
doc = """
201+
A label to a Python interpreter executable.
202+
203+
*Mutually exclusive with `interpreter_path`.*
204+
205+
On Windows, if the path doesn't exist, various suffixes will be tried to
206+
find a usable path.
207+
208+
:::{seealso}
209+
The {obj}`interpreter_path` attribute for getting the interpreter from
210+
a path or PATH environment lookup.
211+
:::
212+
"""
213+
),
199214
"interpreter_path": attr.string(
200215
doc = """
201216
An absolute path or program name on the `PATH` env var.
202217
218+
*Mutually exclusive with `interpreter_target`.*
219+
203220
Values with slashes are assumed to be the path to a program. Otherwise, it is
204221
treated as something to search for on `PATH`
205222
206223
Note that, when a plain program name is used, the path to the interpreter is
207224
resolved at repository evalution time, not runtime of any resulting binaries.
225+
226+
If not set, defaults to `python3`.
227+
228+
:::{seealso}
229+
The {obj}`interpreter_target` attribute for getting the interpreter from
230+
a label
231+
:::
208232
""",
209-
default = "python3",
233+
default = "",
210234
),
211235
"on_failure": attr.string(
212236
default = _OnFailure.SKIP,
@@ -260,20 +284,34 @@ def _resolve_interpreter_path(rctx):
260284
returns a description of why it couldn't be resolved
261285
A path object or None. The path may not exist.
262286
"""
263-
if "/" not in rctx.attr.interpreter_path and "\\" not in rctx.attr.interpreter_path:
264-
# Provide a bit nicer integration with pyenv: recalculate the runtime if the
265-
# user changes the python version using e.g. `pyenv shell`
266-
repo_utils.getenv(rctx, "PYENV_VERSION")
267-
result = repo_utils.which_unchecked(rctx, rctx.attr.interpreter_path)
268-
resolved_path = result.binary
269-
describe_failure = result.describe_failure
287+
if rctx.attr.interpreter_path and rctx.attr.interpreter_target:
288+
fail("interpreter_path and interpreter_target are mutually exclusive")
289+
290+
if rctx.attr.interpreter_target:
291+
path = rctx.path(rctx.attr.interpreter_target)
292+
if path.exists:
293+
resolved_path = path
294+
describe_failure = None
295+
else:
296+
resolved_path = None
297+
describe_failure = lambda: "Target '{}' could not be resolved to a file that exists".format(rctx.attr.interpreter_target)
298+
270299
else:
271-
rctx.watch(rctx.attr.interpreter_path)
272-
resolved_path = rctx.path(rctx.attr.interpreter_path)
273-
if not resolved_path.exists:
274-
describe_failure = lambda: "Path not found: {}".format(repr(rctx.attr.interpreter_path))
300+
interpreter_path = rctx.attr.interpreter_path or "python3"
301+
if "/" not in interpreter_path and "\\" not in interpreter_path:
302+
# Provide a bit nicer integration with pyenv: recalculate the runtime if the
303+
# user changes the python version using e.g. `pyenv shell`
304+
repo_utils.getenv(rctx, "PYENV_VERSION")
305+
result = repo_utils.which_unchecked(rctx, interpreter_path)
306+
resolved_path = result.binary
307+
describe_failure = result.describe_failure
275308
else:
276-
describe_failure = None
309+
rctx.watch(interpreter_path)
310+
resolved_path = rctx.path(interpreter_path)
311+
if not resolved_path.exists:
312+
describe_failure = lambda: "Path not found: {}".format(repr(interpreter_path))
313+
else:
314+
describe_failure = None
277315

278316
return struct(
279317
resolved_path = resolved_path,

tests/integration/local_toolchains/BUILD.bazel

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,37 @@ load("@rules_python//python:py_test.bzl", "py_test")
1818
load(":py_extension.bzl", "py_extension")
1919

2020
py_test(
21-
name = "test",
22-
srcs = ["test.py"],
21+
name = "local_runtime_test",
22+
srcs = ["local_runtime_test.py"],
23+
config_settings = {
24+
"//:py": "local",
25+
},
2326
# Make this test better respect pyenv
2427
env_inherit = ["PYENV_VERSION"],
2528
)
2629

30+
py_test(
31+
name = "repo_runtime_test",
32+
srcs = ["repo_runtime_test.py"],
33+
config_settings = {
34+
"//:py": "repo",
35+
},
36+
)
37+
2738
config_setting(
2839
name = "is_py_local",
2940
flag_values = {
3041
":py": "local",
3142
},
3243
)
3344

45+
config_setting(
46+
name = "is_py_repo",
47+
flag_values = {
48+
":py": "repo",
49+
},
50+
)
51+
3452
# Set `--//:py=local` to use the local toolchain
3553
# (This is set in this example's .bazelrc)
3654
string_flag(

tests/integration/local_toolchains/MODULE.bazel

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,57 @@ local_runtime_repo(
3333
on_failure = "fail",
3434
)
3535

36+
pbs_archive = use_repo_rule("//:pbs_archive.bzl", "pbs_archive")
37+
38+
pbs_archive(
39+
name = "pbs_runtime",
40+
sha256 = {
41+
"windows": "005cb2abf4cfa4aaa48fb10ce4e33fe4335ea4d1f55202dbe4e20c852e45e0f9",
42+
"linux": "0a01bad99fd4a165a11335c29eb43015dfdb8bd5ba8e305538ebb54f3bf3146d",
43+
"mac osx": "4fb42ffc8aad2a42ca7646715b8926bc6b2e0d31f13d2fec25943dc236a6fd60",
44+
},
45+
urls = {
46+
"windows": "https://github.com/astral-sh/python-build-standalone/releases/download/20250918/cpython-3.13.7+20250918-x86_64-pc-windows-msvc-install_only.tar.gz",
47+
"linux": "https://github.com/astral-sh/python-build-standalone/releases/download/20250918/cpython-3.13.7+20250918-x86_64-unknown-linux-gnu-install_only.tar.gz",
48+
"mac os x": "https://github.com/astral-sh/python-build-standalone/releases/download/20250918/cpython-3.13.7+20250918-x86_64-apple-darwin-install_only.tar.gz",
49+
},
50+
)
51+
52+
local_runtime_repo(
53+
name = "repo_python3",
54+
interpreter_target = "@pbs_runtime//:python/bin/python3",
55+
on_failure = "fail",
56+
)
57+
3658
local_runtime_toolchains_repo(
3759
name = "local_toolchains",
38-
runtimes = ["local_python3"],
60+
runtimes = [
61+
"local_python3",
62+
"repo_python3",
63+
],
3964
target_compatible_with = {
4065
"local_python3": [
4166
"HOST_CONSTRAINTS",
4267
],
68+
"repo_python3": [
69+
"HOST_CONSTRAINTS",
70+
],
4371
},
4472
target_settings = {
4573
"local_python3": [
4674
"@//:is_py_local",
4775
],
76+
"repo_python3": [
77+
"@//:is_py_repo",
78+
],
4879
},
4980
)
5081

82+
config = use_extension("@rules_python//python/extensions:config.bzl", "config")
83+
config.add_transition_setting(setting = "//:py")
84+
5185
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
52-
use_repo(python, "rules_python_bzlmod_debug")
86+
python.toolchain(python_version = "3.13")
87+
use_repo(python, "python_3_13_host", "rules_python_bzlmod_debug")
5388

5489
register_toolchains("@local_toolchains//:all")

tests/integration/local_toolchains/test.py renamed to tests/integration/local_toolchains/local_runtime_test.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
import unittest
77

88

9+
import config
10+
11+
print(f"{config.CONFIG=}")
12+
913
class LocalToolchainTest(unittest.TestCase):
1014
maxDiff = None
1115

@@ -28,7 +32,7 @@ def test_python_from_path_used(self):
2832
import sys
2933
print(sys.executable)
3034
print(sys._base_executable)
31-
"""
35+
=
3236
)
3337
f.flush()
3438
output_lines = (
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""A repository rule to download and extract a Python runtime archive."""
2+
3+
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
4+
5+
BUILD_BAZEL = """
6+
# Generated by pbs_archive.bzl
7+
8+
package(
9+
default_visibility = ["//visibility:public"],
10+
)
11+
12+
exports_files(glob(["**"]))
13+
"""
14+
15+
def _pbs_archive_impl(repository_ctx):
16+
"""Implementation of the python_build_standalone_archive rule."""
17+
os_name = repository_ctx.os.name.lower()
18+
urls = repository_ctx.attr.urls
19+
sha256s = repository_ctx.attr.sha256
20+
21+
if os_name not in urls:
22+
fail("Unsupported OS: '{}'. Available OSs are: {}".format(
23+
os_name,
24+
", ".join(urls.keys()),
25+
))
26+
27+
url = urls[os_name]
28+
sha256 = sha256s.get(os_name)
29+
30+
repository_ctx.download_and_extract(
31+
url = url,
32+
sha256 = sha256,
33+
strip_prefix = repository_ctx.attr.strip_prefix,
34+
)
35+
36+
repository_ctx.file("BUILD.bazel", BUILD_BAZEL)
37+
38+
pbs_archive = repository_rule(
39+
implementation = _pbs_archive_impl,
40+
attrs = {
41+
"urls": attr.string_dict(
42+
doc = "A dictionary of URLs to the runtime archives, keyed by OS name (e.g., 'linux', 'windows').",
43+
mandatory = True,
44+
),
45+
"sha256": attr.string_dict(
46+
doc = "A dictionary of SHA256 checksums for the archives, keyed by OS name.",
47+
mandatory = True,
48+
),
49+
"strip_prefix": attr.string(
50+
doc = "The prefix to strip from the archive.",
51+
),
52+
},
53+
doc = """
54+
Downloads and extracts a Python runtime archive for the current OS.
55+
56+
This rule selects a URL from the `urls` attribute based on the host OS,
57+
downloads the archive, and extracts it.
58+
""",
59+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import os.path
2+
import shutil
3+
import subprocess
4+
import sys
5+
import tempfile
6+
import unittest
7+
8+
9+
class RepoToolchainTest(unittest.TestCase):
10+
maxDiff = None
11+
12+
def test_python_from_repo_used(self):
13+
actual = os.path.realpath(sys._base_executable.lower())
14+
# Normalize case: Windows may have case differences
15+
self.assertIn("pbs_runtime", actual.lower())
16+
17+
18+
if __name__ == "__main__":
19+
unittest.main()

0 commit comments

Comments
 (0)