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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff2257a..ed859d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -109,6 +109,7 @@
{obj}`py_cc_toolchain.headers_abi3`, and {obj}`PyCcToolchainInfo.headers_abi3`.
* {obj}`//python:features.bzl%features.headers_abi3` can be used to
feature-detect the presense of the above.
+* (toolchains) Local toolchains can use a label for the interpreter to use.
{#v1-6-3}
## [1.6.3] - 2025-09-21
diff --git a/docs/toolchains.md b/docs/toolchains.md
index 52e619a..186ad11 100644
--- a/docs/toolchains.md
+++ b/docs/toolchains.md
@@ -460,6 +460,10 @@
register_toolchains("@local_toolchains//:all", dev_dependency = True)
```
+In the example above, `interpreter_path` is used to find Python via `PATH`
+lookups. Alternatively, {obj}`interpreter_target` can be set, which can
+refer to a Python in an arbitrary Bazel repository.
+
:::{important}
Be sure to set `dev_dependency = True`. Using a local toolchain only makes sense
for the root module.
diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl
index 27c90b1..583926b 100644
--- a/python/private/local_runtime_repo.bzl
+++ b/python/private/local_runtime_repo.bzl
@@ -200,13 +200,37 @@
doc = """
An absolute path or program name on the `PATH` env var.
+*Mutually exclusive with `interpreter_target`.*
+
Values with slashes are assumed to be the path to a program. Otherwise, it is
treated as something to search for on `PATH`
Note that, when a plain program name is used, the path to the interpreter is
resolved at repository evalution time, not runtime of any resulting binaries.
+
+If not set, defaults to `python3`.
+
+:::{seealso}
+The {obj}`interpreter_target` attribute for getting the interpreter from
+a label
+:::
""",
- default = "python3",
+ default = "",
+ ),
+ "interpreter_target": attr.label(
+ doc = """
+A label to a Python interpreter executable.
+
+*Mutually exclusive with `interpreter_path`.*
+
+On Windows, if the path doesn't exist, various suffixes will be tried to
+find a usable path.
+
+:::{seealso}
+The {obj}`interpreter_path` attribute for getting the interpreter from
+a path or PATH environment lookup.
+:::
+""",
),
"on_failure": attr.string(
default = _OnFailure.SKIP,
@@ -247,6 +271,37 @@
os = "@platforms//:incompatible",
)
+def _find_python_exe_from_target(rctx):
+ base_path = rctx.path(rctx.attr.interpreter_target)
+ if base_path.exists:
+ return base_path, None
+ attempted_paths = [base_path]
+
+ # Try to convert a unix-y path to a Windows path. On Linux/Mac,
+ # the path is usually `bin/python3`. On Windows, it's simply
+ # `python.exe`.
+ basename = base_path.basename.rstrip("3")
+ path = base_path.dirname.dirname.get_child(basename)
+ path = rctx.path("{}.exe".format(path))
+ if path.exists:
+ return path, None
+ attempted_paths.append(path)
+
+ # Try adding .exe to the base path
+ path = rctx.path("{}.exe".format(base_path))
+ if path.exists:
+ return path, None
+ attempted_paths.append(path)
+
+ describe_failure = lambda: (
+ "Target '{target}' could not be resolved to a valid path. " +
+ "Attempted paths: {paths}"
+ ).format(
+ target = rctx.attr.interpreter_target,
+ paths = "\n".join([str(p) for p in attempted_paths]),
+ )
+ return None, describe_failure
+
def _resolve_interpreter_path(rctx):
"""Find the absolute path for an interpreter.
@@ -260,20 +315,27 @@
returns a description of why it couldn't be resolved
A path object or None. The path may not exist.
"""
- if "/" not in rctx.attr.interpreter_path and "\\" not in rctx.attr.interpreter_path:
- # Provide a bit nicer integration with pyenv: recalculate the runtime if the
- # user changes the python version using e.g. `pyenv shell`
- repo_utils.getenv(rctx, "PYENV_VERSION")
- result = repo_utils.which_unchecked(rctx, rctx.attr.interpreter_path)
- resolved_path = result.binary
- describe_failure = result.describe_failure
+ if rctx.attr.interpreter_path and rctx.attr.interpreter_target:
+ fail("interpreter_path and interpreter_target are mutually exclusive")
+
+ if rctx.attr.interpreter_target:
+ resolved_path, describe_failure = _find_python_exe_from_target(rctx)
else:
- rctx.watch(rctx.attr.interpreter_path)
- resolved_path = rctx.path(rctx.attr.interpreter_path)
- if not resolved_path.exists:
- describe_failure = lambda: "Path not found: {}".format(repr(rctx.attr.interpreter_path))
+ interpreter_path = rctx.attr.interpreter_path or "python3"
+ if "/" not in interpreter_path and "\\" not in interpreter_path:
+ # Provide a bit nicer integration with pyenv: recalculate the runtime if the
+ # user changes the python version using e.g. `pyenv shell`
+ repo_utils.getenv(rctx, "PYENV_VERSION")
+ result = repo_utils.which_unchecked(rctx, interpreter_path)
+ resolved_path = result.binary
+ describe_failure = result.describe_failure
else:
- describe_failure = None
+ rctx.watch(interpreter_path)
+ resolved_path = rctx.path(interpreter_path)
+ if not resolved_path.exists:
+ describe_failure = lambda: "Path not found: {}".format(repr(interpreter_path))
+ else:
+ describe_failure = None
return struct(
resolved_path = resolved_path,
diff --git a/tests/integration/local_toolchains/BUILD.bazel b/tests/integration/local_toolchains/BUILD.bazel
index a0cb2b1..bf47316 100644
--- a/tests/integration/local_toolchains/BUILD.bazel
+++ b/tests/integration/local_toolchains/BUILD.bazel
@@ -18,12 +18,23 @@
load(":py_extension.bzl", "py_extension")
py_test(
- name = "test",
- srcs = ["test.py"],
+ name = "local_runtime_test",
+ srcs = ["local_runtime_test.py"],
+ config_settings = {
+ "//:py": "local",
+ },
# Make this test better respect pyenv
env_inherit = ["PYENV_VERSION"],
)
+py_test(
+ name = "repo_runtime_test",
+ srcs = ["repo_runtime_test.py"],
+ config_settings = {
+ "//:py": "repo",
+ },
+)
+
config_setting(
name = "is_py_local",
flag_values = {
@@ -31,6 +42,13 @@
},
)
+config_setting(
+ name = "is_py_repo",
+ flag_values = {
+ ":py": "repo",
+ },
+)
+
# Set `--//:py=local` to use the local toolchain
# (This is set in this example's .bazelrc)
string_flag(
diff --git a/tests/integration/local_toolchains/MODULE.bazel b/tests/integration/local_toolchains/MODULE.bazel
index e81c012..6c821c5 100644
--- a/tests/integration/local_toolchains/MODULE.bazel
+++ b/tests/integration/local_toolchains/MODULE.bazel
@@ -23,32 +23,76 @@
path = "../../..",
)
+# Step 1: Define the python runtime
local_runtime_repo = use_repo_rule("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_repo")
local_runtime_toolchains_repo = use_repo_rule("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_toolchains_repo")
+# This will use `python3` from the environment
local_runtime_repo(
name = "local_python3",
interpreter_path = "python3",
on_failure = "fail",
)
+pbs_archive = use_repo_rule("//:pbs_archive.bzl", "pbs_archive")
+
+pbs_archive(
+ name = "pbs_runtime",
+ sha256 = {
+ "linux": "0a01bad99fd4a165a11335c29eb43015dfdb8bd5ba8e305538ebb54f3bf3146d",
+ "mac os x": "4fb42ffc8aad2a42ca7646715b8926bc6b2e0d31f13d2fec25943dc236a6fd60",
+ "windows": "005cb2abf4cfa4aaa48fb10ce4e33fe4335ea4d1f55202dbe4e20c852e45e0f9",
+ },
+ urls = {
+ "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",
+ "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",
+ "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",
+ },
+)
+
+# This will use Python from the `pbs_runtime` repository.
+# The pbs_runtime is just an example; the repo just needs to be a valid Python
+# installation.
+local_runtime_repo(
+ name = "repo_python3",
+ interpreter_target = "@pbs_runtime//:python/bin/python",
+ on_failure = "fail",
+)
+
+# Step 2: Create toolchains for the runtimes
+# Below, we configure them to only activate if the `//:py` flag has particular
+# values.
local_runtime_toolchains_repo(
name = "local_toolchains",
- runtimes = ["local_python3"],
+ runtimes = [
+ "local_python3",
+ "repo_python3",
+ ],
target_compatible_with = {
"local_python3": [
"HOST_CONSTRAINTS",
],
+ "repo_python3": [
+ "HOST_CONSTRAINTS",
+ ],
},
target_settings = {
"local_python3": [
"@//:is_py_local",
],
+ "repo_python3": [
+ "@//:is_py_repo",
+ ],
},
)
+config = use_extension("@rules_python//python/extensions:config.bzl", "config")
+config.add_transition_setting(setting = "//:py")
+
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
+python.toolchain(python_version = "3.13")
use_repo(python, "rules_python_bzlmod_debug")
+# Step 3: Register the toolchains
register_toolchains("@local_toolchains//:all")
diff --git a/tests/integration/local_toolchains/WORKSPACE b/tests/integration/local_toolchains/WORKSPACE
index 480cd27..159f16d 100644
--- a/tests/integration/local_toolchains/WORKSPACE
+++ b/tests/integration/local_toolchains/WORKSPACE
@@ -9,7 +9,11 @@
load("@rules_python//python:repositories.bzl", "py_repositories")
-py_repositories()
+py_repositories(
+ transition_settings = [
+ "@//:py",
+ ],
+)
load("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_repo", "local_runtime_toolchains_repo")
@@ -21,10 +25,51 @@
# or interpreter_path = "C:\\path\\to\\python.exe"
)
+load("//:pbs_archive.bzl", "pbs_archive")
+
+pbs_archive(
+ name = "pbs_runtime",
+ sha256 = {
+ "linux": "0a01bad99fd4a165a11335c29eb43015dfdb8bd5ba8e305538ebb54f3bf3146d",
+ "mac os x": "4fb42ffc8aad2a42ca7646715b8926bc6b2e0d31f13d2fec25943dc236a6fd60",
+ "windows": "005cb2abf4cfa4aaa48fb10ce4e33fe4335ea4d1f55202dbe4e20c852e45e0f9",
+ },
+ urls = {
+ "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",
+ "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",
+ "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",
+ },
+)
+
+local_runtime_repo(
+ name = "repo_python3",
+ interpreter_target = "@pbs_runtime//:python/bin/python3",
+ on_failure = "fail",
+)
+
# Step 2: Create toolchains for the runtimes
local_runtime_toolchains_repo(
name = "local_toolchains",
- runtimes = ["local_python3"],
+ runtimes = [
+ "local_python3",
+ "repo_python3",
+ ],
+ target_compatible_with = {
+ "local_python3": [
+ "HOST_CONSTRAINTS",
+ ],
+ "repo_python3": [
+ "HOST_CONSTRAINTS",
+ ],
+ },
+ target_settings = {
+ "local_python3": [
+ "@//:is_py_local",
+ ],
+ "repo_python3": [
+ "@//:is_py_repo",
+ ],
+ },
)
# Step 3: Register the toolchains
diff --git a/tests/integration/local_toolchains/test.py b/tests/integration/local_toolchains/local_runtime_test.py
similarity index 100%
rename from tests/integration/local_toolchains/test.py
rename to tests/integration/local_toolchains/local_runtime_test.py
diff --git a/tests/integration/local_toolchains/pbs_archive.bzl b/tests/integration/local_toolchains/pbs_archive.bzl
new file mode 100644
index 0000000..8bd0c1e
--- /dev/null
+++ b/tests/integration/local_toolchains/pbs_archive.bzl
@@ -0,0 +1,53 @@
+"""A repository rule to download and extract a Python runtime archive."""
+
+BUILD_BAZEL = """
+# Generated by pbs_archive.bzl
+
+package(
+ default_visibility = ["//visibility:public"],
+)
+
+exports_files(glob(["**"]))
+"""
+
+def _pbs_archive_impl(repository_ctx):
+ """Implementation of the python_build_standalone_archive rule."""
+ os_name = repository_ctx.os.name.lower()
+ urls = repository_ctx.attr.urls
+ sha256s = repository_ctx.attr.sha256
+
+ if os_name not in urls:
+ fail("Unsupported OS: '{}'. Available OSs are: {}".format(
+ os_name,
+ ", ".join(urls.keys()),
+ ))
+
+ url = urls[os_name]
+ sha256 = sha256s.get(os_name, "")
+
+ repository_ctx.download_and_extract(
+ url = url,
+ sha256 = sha256,
+ )
+
+ repository_ctx.file("BUILD.bazel", BUILD_BAZEL)
+
+pbs_archive = repository_rule(
+ implementation = _pbs_archive_impl,
+ attrs = {
+ "sha256": attr.string_dict(
+ doc = "A dictionary of SHA256 checksums for the archives, keyed by OS name.",
+ mandatory = True,
+ ),
+ "urls": attr.string_dict(
+ doc = "A dictionary of URLs to the runtime archives, keyed by OS name (e.g., 'linux', 'windows').",
+ mandatory = True,
+ ),
+ },
+ doc = """
+Downloads and extracts a Python runtime archive for the current OS.
+
+This rule selects a URL from the `urls` attribute based on the host OS,
+downloads the archive, and extracts it.
+""",
+)
diff --git a/tests/integration/local_toolchains/repo_runtime_test.py b/tests/integration/local_toolchains/repo_runtime_test.py
new file mode 100644
index 0000000..4614407
--- /dev/null
+++ b/tests/integration/local_toolchains/repo_runtime_test.py
@@ -0,0 +1,19 @@
+import os.path
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+class RepoToolchainTest(unittest.TestCase):
+ maxDiff = None
+
+ def test_python_from_repo_used(self):
+ actual = os.path.realpath(sys._base_executable.lower())
+ # Normalize case: Windows may have case differences
+ self.assertIn("pbs_runtime", actual.lower())
+
+
+if __name__ == "__main__":
+ unittest.main()