Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -107,6 +107,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
64 changes: 51 additions & 13 deletions python/private/local_runtime_repo.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,41 @@ create thousands of files for every `py_test`), at the risk of having to rely on
a system having the necessary Python installed.
""",
attrs = {
"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.
:::
"""
),
"interpreter_path": attr.string(
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 = "",
),
"on_failure": attr.string(
default = _OnFailure.SKIP,
Expand Down Expand Up @@ -260,20 +284,34 @@ 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:
path = rctx.path(rctx.attr.interpreter_target)
if path.exists:
resolved_path = path
describe_failure = None
else:
resolved_path = None
describe_failure = lambda: "Target '{}' could not be resolved to a file that exists".format(rctx.attr.interpreter_target)

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
39 changes: 37 additions & 2 deletions tests/integration/local_toolchains/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,57 @@ local_runtime_repo(
on_failure = "fail",
)

pbs_archive = use_repo_rule("//:pbs_archive.bzl", "pbs_archive")

pbs_archive(
name = "pbs_runtime",
sha256 = {
"windows": "005cb2abf4cfa4aaa48fb10ce4e33fe4335ea4d1f55202dbe4e20c852e45e0f9",
"linux": "0a01bad99fd4a165a11335c29eb43015dfdb8bd5ba8e305538ebb54f3bf3146d",
"mac osx": "4fb42ffc8aad2a42ca7646715b8926bc6b2e0d31f13d2fec25943dc236a6fd60",
},
urls = {
"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",
"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",
},
)

local_runtime_repo(
name = "repo_python3",
interpreter_target = "@pbs_runtime//:python/bin/python3",
on_failure = "fail",
)

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")
use_repo(python, "rules_python_bzlmod_debug")
python.toolchain(python_version = "3.13")
use_repo(python, "python_3_13_host", "rules_python_bzlmod_debug")

register_toolchains("@local_toolchains//:all")
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import unittest


import config

print(f"{config.CONFIG=}")

class LocalToolchainTest(unittest.TestCase):
maxDiff = None

Expand All @@ -28,7 +32,7 @@ def test_python_from_path_used(self):
import sys
print(sys.executable)
print(sys._base_executable)
"""
=
)
f.flush()
output_lines = (
Expand Down
59 changes: 59 additions & 0 deletions tests/integration/local_toolchains/pbs_archive.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""A repository rule to download and extract a Python runtime archive."""

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_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,
strip_prefix = repository_ctx.attr.strip_prefix,
)

repository_ctx.file("BUILD.bazel", BUILD_BAZEL)

pbs_archive = repository_rule(
implementation = _pbs_archive_impl,
attrs = {
"urls": attr.string_dict(
doc = "A dictionary of URLs to the runtime archives, keyed by OS name (e.g., 'linux', 'windows').",
mandatory = True,
),
"sha256": attr.string_dict(
doc = "A dictionary of SHA256 checksums for the archives, keyed by OS name.",
mandatory = True,
),
"strip_prefix": attr.string(
doc = "The prefix to strip from the archive.",
),
},
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()