diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2257a06b..ed859d3fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/toolchains.md b/docs/toolchains.md index 52e619a120..186ad11e73 100644 --- a/docs/toolchains.md +++ b/docs/toolchains.md @@ -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. diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl index 27c90b1bc9..583926b15f 100644 --- a/python/private/local_runtime_repo.bzl +++ b/python/private/local_runtime_repo.bzl @@ -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, @@ -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. @@ -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, diff --git a/tests/integration/local_toolchains/BUILD.bazel b/tests/integration/local_toolchains/BUILD.bazel index a0cb2b164d..bf47316027 100644 --- a/tests/integration/local_toolchains/BUILD.bazel +++ b/tests/integration/local_toolchains/BUILD.bazel @@ -18,12 +18,23 @@ 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 = { @@ -31,6 +42,13 @@ config_setting( }, ) +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 e81c012c2d..6c821c5bb0 100644 --- a/tests/integration/local_toolchains/MODULE.bazel +++ b/tests/integration/local_toolchains/MODULE.bazel @@ -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") diff --git a/tests/integration/local_toolchains/WORKSPACE b/tests/integration/local_toolchains/WORKSPACE index 480cd2794a..159f16deab 100644 --- a/tests/integration/local_toolchains/WORKSPACE +++ b/tests/integration/local_toolchains/WORKSPACE @@ -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") @@ -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 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 0000000000..8bd0c1eb10 --- /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 0000000000..4614407c4e --- /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()