Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ END_UNRELEASED_TEMPLATE
{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
Expand Down
4 changes: 4 additions & 0 deletions docs/toolchains.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,10 @@ local_runtime_toolchains_repo(
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.
Expand Down
88 changes: 75 additions & 13 deletions python/private/local_runtime_repo.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,37 @@ a system having the necessary Python installed.
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 = "",
),
"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.
:::
""",
default = "python3",
),
"on_failure": attr.string(
default = _OnFailure.SKIP,
Expand Down Expand Up @@ -247,6 +271,37 @@ def _expand_incompatible_template():
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.

Expand All @@ -260,20 +315,27 @@ def _resolve_interpreter_path(rctx):
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,
Expand Down
22 changes: 20 additions & 2 deletions tests/integration/local_toolchains/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,37 @@ load("@rules_python//python:py_test.bzl", "py_test")
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 = {
":py": "local",
},
)

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(
Expand Down
46 changes: 45 additions & 1 deletion tests/integration/local_toolchains/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,76 @@ local_path_override(
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")
49 changes: 47 additions & 2 deletions tests/integration/local_toolchains/WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ local_repository(

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")

Expand All @@ -21,10 +25,51 @@ local_runtime_repo(
# 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
Expand Down
53 changes: 53 additions & 0 deletions tests/integration/local_toolchains/pbs_archive.bzl
Original file line number Diff line number Diff line change
@@ -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.
""",
)
19 changes: 19 additions & 0 deletions tests/integration/local_toolchains/repo_runtime_test.py
Original file line number Diff line number Diff line change
@@ -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()