Skip to content

Commit fb06c86

Browse files
authored
feat(toolchains): let local toolchains point to a label (#3304)
Currently, the local toolchain code requires using a path (or program name) to find the Python interpreter. This comes up short when using Bazel to download an arbitrary runtime (or otherwise manage the creation of it, e.g. downloading Python and building it from source in a repo rule). In such cases, the file system location of the interpreter isn't known (it'll be in some Bazel cache directory). To fix, add the `interpreter_target` attribute to `local_runtime_repo`, which it looks up the path for, then continues on as normal. As an example, the test uses a custom repository rule to download a particular version of Python appropriate to the OS.
1 parent ecc3390 commit fb06c86

File tree

9 files changed

+264
-18
lines changed

9 files changed

+264
-18
lines changed

CHANGELOG.md

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

113114
{#v1-6-3}
114115
## [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: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,37 @@ a system having the necessary Python installed.
200200
doc = """
201201
An absolute path or program name on the `PATH` env var.
202202
203+
*Mutually exclusive with `interpreter_target`.*
204+
203205
Values with slashes are assumed to be the path to a program. Otherwise, it is
204206
treated as something to search for on `PATH`
205207
206208
Note that, when a plain program name is used, the path to the interpreter is
207209
resolved at repository evalution time, not runtime of any resulting binaries.
210+
211+
If not set, defaults to `python3`.
212+
213+
:::{seealso}
214+
The {obj}`interpreter_target` attribute for getting the interpreter from
215+
a label
216+
:::
217+
""",
218+
default = "",
219+
),
220+
"interpreter_target": attr.label(
221+
doc = """
222+
A label to a Python interpreter executable.
223+
224+
*Mutually exclusive with `interpreter_path`.*
225+
226+
On Windows, if the path doesn't exist, various suffixes will be tried to
227+
find a usable path.
228+
229+
:::{seealso}
230+
The {obj}`interpreter_path` attribute for getting the interpreter from
231+
a path or PATH environment lookup.
232+
:::
208233
""",
209-
default = "python3",
210234
),
211235
"on_failure": attr.string(
212236
default = _OnFailure.SKIP,
@@ -247,6 +271,37 @@ def _expand_incompatible_template():
247271
os = "@platforms//:incompatible",
248272
)
249273

274+
def _find_python_exe_from_target(rctx):
275+
base_path = rctx.path(rctx.attr.interpreter_target)
276+
if base_path.exists:
277+
return base_path, None
278+
attempted_paths = [base_path]
279+
280+
# Try to convert a unix-y path to a Windows path. On Linux/Mac,
281+
# the path is usually `bin/python3`. On Windows, it's simply
282+
# `python.exe`.
283+
basename = base_path.basename.rstrip("3")
284+
path = base_path.dirname.dirname.get_child(basename)
285+
path = rctx.path("{}.exe".format(path))
286+
if path.exists:
287+
return path, None
288+
attempted_paths.append(path)
289+
290+
# Try adding .exe to the base path
291+
path = rctx.path("{}.exe".format(base_path))
292+
if path.exists:
293+
return path, None
294+
attempted_paths.append(path)
295+
296+
describe_failure = lambda: (
297+
"Target '{target}' could not be resolved to a valid path. " +
298+
"Attempted paths: {paths}"
299+
).format(
300+
target = rctx.attr.interpreter_target,
301+
paths = "\n".join([str(p) for p in attempted_paths]),
302+
)
303+
return None, describe_failure
304+
250305
def _resolve_interpreter_path(rctx):
251306
"""Find the absolute path for an interpreter.
252307
@@ -260,20 +315,27 @@ def _resolve_interpreter_path(rctx):
260315
returns a description of why it couldn't be resolved
261316
A path object or None. The path may not exist.
262317
"""
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
318+
if rctx.attr.interpreter_path and rctx.attr.interpreter_target:
319+
fail("interpreter_path and interpreter_target are mutually exclusive")
320+
321+
if rctx.attr.interpreter_target:
322+
resolved_path, describe_failure = _find_python_exe_from_target(rctx)
270323
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))
324+
interpreter_path = rctx.attr.interpreter_path or "python3"
325+
if "/" not in interpreter_path and "\\" not in interpreter_path:
326+
# Provide a bit nicer integration with pyenv: recalculate the runtime if the
327+
# user changes the python version using e.g. `pyenv shell`
328+
repo_utils.getenv(rctx, "PYENV_VERSION")
329+
result = repo_utils.which_unchecked(rctx, interpreter_path)
330+
resolved_path = result.binary
331+
describe_failure = result.describe_failure
275332
else:
276-
describe_failure = None
333+
rctx.watch(interpreter_path)
334+
resolved_path = rctx.path(interpreter_path)
335+
if not resolved_path.exists:
336+
describe_failure = lambda: "Path not found: {}".format(repr(interpreter_path))
337+
else:
338+
describe_failure = None
277339

278340
return struct(
279341
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: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,32 +23,76 @@ local_path_override(
2323
path = "../../..",
2424
)
2525

26+
# Step 1: Define the python runtime
2627
local_runtime_repo = use_repo_rule("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_repo")
2728

2829
local_runtime_toolchains_repo = use_repo_rule("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_toolchains_repo")
2930

31+
# This will use `python3` from the environment
3032
local_runtime_repo(
3133
name = "local_python3",
3234
interpreter_path = "python3",
3335
on_failure = "fail",
3436
)
3537

38+
pbs_archive = use_repo_rule("//:pbs_archive.bzl", "pbs_archive")
39+
40+
pbs_archive(
41+
name = "pbs_runtime",
42+
sha256 = {
43+
"linux": "0a01bad99fd4a165a11335c29eb43015dfdb8bd5ba8e305538ebb54f3bf3146d",
44+
"mac os x": "4fb42ffc8aad2a42ca7646715b8926bc6b2e0d31f13d2fec25943dc236a6fd60",
45+
"windows": "005cb2abf4cfa4aaa48fb10ce4e33fe4335ea4d1f55202dbe4e20c852e45e0f9",
46+
},
47+
urls = {
48+
"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",
49+
"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",
50+
"windows server 2022": "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",
51+
},
52+
)
53+
54+
# This will use Python from the `pbs_runtime` repository.
55+
# The pbs_runtime is just an example; the repo just needs to be a valid Python
56+
# installation.
57+
local_runtime_repo(
58+
name = "repo_python3",
59+
interpreter_target = "@pbs_runtime//:python/bin/python",
60+
on_failure = "fail",
61+
)
62+
63+
# Step 2: Create toolchains for the runtimes
64+
# Below, we configure them to only activate if the `//:py` flag has particular
65+
# values.
3666
local_runtime_toolchains_repo(
3767
name = "local_toolchains",
38-
runtimes = ["local_python3"],
68+
runtimes = [
69+
"local_python3",
70+
"repo_python3",
71+
],
3972
target_compatible_with = {
4073
"local_python3": [
4174
"HOST_CONSTRAINTS",
4275
],
76+
"repo_python3": [
77+
"HOST_CONSTRAINTS",
78+
],
4379
},
4480
target_settings = {
4581
"local_python3": [
4682
"@//:is_py_local",
4783
],
84+
"repo_python3": [
85+
"@//:is_py_repo",
86+
],
4887
},
4988
)
5089

90+
config = use_extension("@rules_python//python/extensions:config.bzl", "config")
91+
config.add_transition_setting(setting = "//:py")
92+
5193
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
94+
python.toolchain(python_version = "3.13")
5295
use_repo(python, "rules_python_bzlmod_debug")
5396

97+
# Step 3: Register the toolchains
5498
register_toolchains("@local_toolchains//:all")

tests/integration/local_toolchains/WORKSPACE

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ local_repository(
99

1010
load("@rules_python//python:repositories.bzl", "py_repositories")
1111

12-
py_repositories()
12+
py_repositories(
13+
transition_settings = [
14+
"@//:py",
15+
],
16+
)
1317

1418
load("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_repo", "local_runtime_toolchains_repo")
1519

@@ -21,10 +25,51 @@ local_runtime_repo(
2125
# or interpreter_path = "C:\\path\\to\\python.exe"
2226
)
2327

28+
load("//:pbs_archive.bzl", "pbs_archive")
29+
30+
pbs_archive(
31+
name = "pbs_runtime",
32+
sha256 = {
33+
"linux": "0a01bad99fd4a165a11335c29eb43015dfdb8bd5ba8e305538ebb54f3bf3146d",
34+
"mac os x": "4fb42ffc8aad2a42ca7646715b8926bc6b2e0d31f13d2fec25943dc236a6fd60",
35+
"windows": "005cb2abf4cfa4aaa48fb10ce4e33fe4335ea4d1f55202dbe4e20c852e45e0f9",
36+
},
37+
urls = {
38+
"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",
39+
"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",
40+
"windows server 2022": "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",
41+
},
42+
)
43+
44+
local_runtime_repo(
45+
name = "repo_python3",
46+
interpreter_target = "@pbs_runtime//:python/bin/python3",
47+
on_failure = "fail",
48+
)
49+
2450
# Step 2: Create toolchains for the runtimes
2551
local_runtime_toolchains_repo(
2652
name = "local_toolchains",
27-
runtimes = ["local_python3"],
53+
runtimes = [
54+
"local_python3",
55+
"repo_python3",
56+
],
57+
target_compatible_with = {
58+
"local_python3": [
59+
"HOST_CONSTRAINTS",
60+
],
61+
"repo_python3": [
62+
"HOST_CONSTRAINTS",
63+
],
64+
},
65+
target_settings = {
66+
"local_python3": [
67+
"@//:is_py_local",
68+
],
69+
"repo_python3": [
70+
"@//:is_py_repo",
71+
],
72+
},
2873
)
2974

3075
# Step 3: Register the toolchains
File renamed without changes.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""A repository rule to download and extract a Python runtime archive."""
2+
3+
BUILD_BAZEL = """
4+
# Generated by pbs_archive.bzl
5+
6+
package(
7+
default_visibility = ["//visibility:public"],
8+
)
9+
10+
exports_files(glob(["**"]))
11+
"""
12+
13+
def _pbs_archive_impl(repository_ctx):
14+
"""Implementation of the python_build_standalone_archive rule."""
15+
os_name = repository_ctx.os.name.lower()
16+
urls = repository_ctx.attr.urls
17+
sha256s = repository_ctx.attr.sha256
18+
19+
if os_name not in urls:
20+
fail("Unsupported OS: '{}'. Available OSs are: {}".format(
21+
os_name,
22+
", ".join(urls.keys()),
23+
))
24+
25+
url = urls[os_name]
26+
sha256 = sha256s.get(os_name, "")
27+
28+
repository_ctx.download_and_extract(
29+
url = url,
30+
sha256 = sha256,
31+
)
32+
33+
repository_ctx.file("BUILD.bazel", BUILD_BAZEL)
34+
35+
pbs_archive = repository_rule(
36+
implementation = _pbs_archive_impl,
37+
attrs = {
38+
"sha256": attr.string_dict(
39+
doc = "A dictionary of SHA256 checksums for the archives, keyed by OS name.",
40+
mandatory = True,
41+
),
42+
"urls": attr.string_dict(
43+
doc = "A dictionary of URLs to the runtime archives, keyed by OS name (e.g., 'linux', 'windows').",
44+
mandatory = True,
45+
),
46+
},
47+
doc = """
48+
Downloads and extracts a Python runtime archive for the current OS.
49+
50+
This rule selects a URL from the `urls` attribute based on the host OS,
51+
downloads the archive, and extracts it.
52+
""",
53+
)
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)