internal: repos to create a toolchain from a locally installed Python (#2000)

This adds the primitives for defining a toolchain based on a locally
installed Python.
Doing this consists of two parts:

* A repo rule to define a Python runtime pointing to a local Python
installation.
* A repo rule to define toolchains for those runtimes.

The runtime repos create platform runtimes, i.e, it sets
py_runtime.interpreter_path.
This means the runtime isn't included in the runfiles.

Note that these repo rules are largely implementation details, and are
definitely not
stable API-wise. Creating public APIs to use them through WORKSPACE or
bzlmod will
be done in a separate change (there's a few design and behavior
questions to discuss).

This is definitely experimental quality. In particular, the code that
tries
to figure out the C headers/libraries is very finicky. I couldn't find
solid docs about
how to do this, and there's a lot of undocumented settings, so what's
there is what
I was able to piece together from my laptop's behavior.

Misc other changes:
* Also fixes a bug if a pyenv-backed interpreter path is used for
precompiling:
pyenv uses `$0` to determine what to re-exec. The
`:current_interpreter_executable`
  target used its own name, which pyenv didn't understand.
* The repo logger now also accepts a string. This should help prevent
accidentally
passing a string causing an error. It's also just a bit more convenient
when
  doing development.
* Repo loggers will automatically include their rule name and repo name.
This
  makes following logging output easier.
* Makes `repo_utils.execute()` report progress.
* Adds `repo_utils.getenv`, `repo_utils.watch`, and
`repo_utils.watch_tree`:
  backwards compatibility functions for their `rctx` equivalents.
* Adds `repo_utils.which_unchecked`: calls `which`, but allows for
failure.
* Adds `repo_utils.get_platforms_os_name()`: Returns the name used in
`@platforms` for
  the OS reported by `rctx`.
* Makes several repo util functions call `watch()` or `getenv()`, if
available. This
  makes repository rules better respect environmental changes.
* Adds more detail to the definition of an in-build vs platform runtime
* Adds a README for the integration tests directory. Setting up and
using one is a bit
  more involved than other tests, so some docs help.
* Allows integration tests to specify bazel versions to use.
diff --git a/python/private/full_version.bzl b/python/private/full_version.bzl
index 68c9694..98eeee5 100644
--- a/python/private/full_version.bzl
+++ b/python/private/full_version.bzl
@@ -40,4 +40,4 @@
             ),
         )
     else:
-        fail("Unknown version format: {}".format(version))
+        fail("Unknown version format: '{}'".format(version))
diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py
new file mode 100644
index 0000000..0207f56
--- /dev/null
+++ b/python/private/get_local_runtime_info.py
@@ -0,0 +1,49 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import sys
+import sysconfig
+
+data = {
+    "major": sys.version_info.major,
+    "minor": sys.version_info.minor,
+    "micro": sys.version_info.micro,
+    "include": sysconfig.get_path("include"),
+    "implementation_name": sys.implementation.name,
+}
+
+config_vars = [
+    # The libpythonX.Y.so file. Usually?
+    # It might be a static archive (.a) file instead.
+    "LDLIBRARY",
+    # The directory with library files. Supposedly.
+    # It's not entirely clear how to get the directory with libraries.
+    # There's several types of libraries with different names and a plethora
+    # of settings.
+    # https://stackoverflow.com/questions/47423246/get-pythons-lib-path
+    # For now, it seems LIBDIR has what is needed, so just use that.
+    "LIBDIR",
+    # The versioned libpythonX.Y.so.N file. Usually?
+    # It might be a static archive (.a) file instead.
+    "INSTSONAME",
+    # The libpythonX.so file. Usually?
+    # It might be a static archive (a.) file instead.
+    "PY3LIBRARY",
+    # The platform-specific filename suffix for library files.
+    # Includes the dot, e.g. `.so`
+    "SHLIB_SUFFIX",
+]
+data.update(zip(config_vars, sysconfig.get_config_vars(*config_vars)))
+print(json.dumps(data))
diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl
new file mode 100644
index 0000000..f6bca6c
--- /dev/null
+++ b/python/private/local_runtime_repo.bzl
@@ -0,0 +1,252 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Create a repository for a locally installed Python runtime."""
+
+load("//python/private:enum.bzl", "enum")
+load(":repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils")
+
+# buildifier: disable=name-conventions
+_OnFailure = enum(
+    SKIP = "skip",
+    WARN = "warn",
+    FAIL = "fail",
+)
+
+_TOOLCHAIN_IMPL_TEMPLATE = """\
+# Generated by python/private/local_runtime_repo.bzl
+
+load("@rules_python//python/private:local_runtime_repo_setup.bzl", "define_local_runtime_toolchain_impl")
+
+define_local_runtime_toolchain_impl(
+    name = "local_runtime",
+    lib_ext = "{lib_ext}",
+    major = "{major}",
+    minor = "{minor}",
+    micro = "{micro}",
+    interpreter_path = "{interpreter_path}",
+    implementation_name = "{implementation_name}",
+    os = "{os}",
+)
+"""
+
+def _local_runtime_repo_impl(rctx):
+    logger = repo_utils.logger(rctx)
+    on_failure = rctx.attr.on_failure
+
+    platforms_os_name = repo_utils.get_platforms_os_name(rctx)
+    if not platforms_os_name:
+        if on_failure == "fail":
+            fail("Unrecognized host platform '{}': cannot determine OS constraint".format(
+                rctx.os.name,
+            ))
+
+        if on_failure == "warn":
+            logger.warn(lambda: "Unrecognized host platform '{}': cannot determine OS constraint".format(
+                rctx.os.name,
+            ))
+
+        # else, on_failure must be skip
+        rctx.file("BUILD.bazel", _expand_incompatible_template())
+        return
+
+    result = _resolve_interpreter_path(rctx)
+    if not result.resolved_path:
+        if on_failure == "fail":
+            fail("interpreter not found: {}".format(result.describe_failure()))
+
+        if on_failure == "warn":
+            logger.warn(lambda: "interpreter not found: {}".format(result.describe_failure()))
+
+        # else, on_failure must be skip
+        rctx.file("BUILD.bazel", _expand_incompatible_template())
+        return
+    else:
+        interpreter_path = result.resolved_path
+
+    logger.info(lambda: "resolved interpreter {} to {}".format(rctx.attr.interpreter_path, interpreter_path))
+
+    exec_result = repo_utils.execute_unchecked(
+        rctx,
+        op = "local_runtime_repo.GetPythonInfo({})".format(rctx.name),
+        arguments = [
+            interpreter_path,
+            rctx.path(rctx.attr._get_local_runtime_info),
+        ],
+        quiet = True,
+    )
+    if exec_result.return_code != 0:
+        if on_failure == "fail":
+            fail("GetPythonInfo failed: {}".format(exec_result.describe_failure()))
+        if on_failure == "warn":
+            logger.warn(lambda: "GetPythonInfo failed: {}".format(exec_result.describe_failure()))
+
+        # else, on_failure must be skip
+        rctx.file("BUILD.bazel", _expand_incompatible_template())
+        return
+
+    info = json.decode(exec_result.stdout)
+    logger.info(lambda: _format_get_info_result(info))
+
+    # NOTE: Keep in sync with recursive glob in define_local_runtime_toolchain_impl
+    repo_utils.watch_tree(rctx, rctx.path(info["include"]))
+
+    # The cc_library.includes values have to be non-absolute paths, otherwise
+    # the toolchain will give an error. Work around this error by making them
+    # appear as part of this repo.
+    rctx.symlink(info["include"], "include")
+
+    shared_lib_names = [
+        info["PY3LIBRARY"],
+        info["LDLIBRARY"],
+        info["INSTSONAME"],
+    ]
+
+    # In some cases, the value may be empty. Not clear why.
+    shared_lib_names = [v for v in shared_lib_names if v]
+
+    # In some cases, the same value is returned for multiple keys. Not clear why.
+    shared_lib_names = {v: None for v in shared_lib_names}.keys()
+    shared_lib_dir = info["LIBDIR"]
+
+    # The specific files are symlinked instead of the whole directory
+    # because it can point to a directory that has more than just
+    # the Python runtime shared libraries, e.g. /usr/lib, or a Python
+    # specific directory with pip-installed shared libraries.
+    rctx.report_progress("Symlinking external Python shared libraries")
+    for name in shared_lib_names:
+        origin = rctx.path("{}/{}".format(shared_lib_dir, name))
+
+        # The reported names don't always exist; it depends on the particulars
+        # of the runtime installation.
+        if origin.exists:
+            repo_utils.watch(rctx, origin)
+            rctx.symlink(origin, "lib/" + name)
+
+    rctx.file("WORKSPACE", "")
+    rctx.file("MODULE.bazel", "")
+    rctx.file("REPO.bazel", "")
+    rctx.file("BUILD.bazel", _TOOLCHAIN_IMPL_TEMPLATE.format(
+        major = info["major"],
+        minor = info["minor"],
+        micro = info["micro"],
+        interpreter_path = interpreter_path,
+        lib_ext = info["SHLIB_SUFFIX"],
+        implementation_name = info["implementation_name"],
+        os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)),
+    ))
+
+local_runtime_repo = repository_rule(
+    implementation = _local_runtime_repo_impl,
+    doc = """
+Use a locally installed Python runtime as a toolchain implementation.
+
+Note this uses the runtime as a *platform runtime*. A platform runtime means
+means targets don't include the runtime itself as part of their runfiles or
+inputs. Instead, users must assure that where the targets run have the runtime
+pre-installed or otherwise available.
+
+This results in lighter weight binaries (in particular, Bazel doesn't have to
+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_path": attr.string(
+            doc = """
+An absolute path or program name on the `PATH` env var.
+
+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.
+""",
+            default = "python3",
+        ),
+        "on_failure": attr.string(
+            default = _OnFailure.SKIP,
+            values = sorted(_OnFailure.__members__.values()),
+            doc = """
+How to handle errors when trying to automatically determine settings.
+
+* `skip` will silently skip creating a runtime. Instead, a non-functional
+  runtime will be generated and marked as incompatible so it cannot be used.
+  This is best if a local runtime is known not to work or be available
+  in certain cases and that's OK. e.g., one use windows paths when there
+  are people running on linux.
+* `warn` will print a warning message. This is useful when you expect
+  a runtime to be available, but are OK with it missing and falling back
+  to some other runtime.
+* `fail` will result in a failure. This is only recommended if you must
+  ensure the runtime is available.
+""",
+        ),
+        "_get_local_runtime_info": attr.label(
+            allow_single_file = True,
+            default = "//python/private:get_local_runtime_info.py",
+        ),
+        "_rule_name": attr.string(default = "local_runtime_repo"),
+    },
+    environ = ["PATH", REPO_DEBUG_ENV_VAR],
+)
+
+def _expand_incompatible_template():
+    return _TOOLCHAIN_IMPL_TEMPLATE.format(
+        interpreter_path = "/incompatible",
+        implementation_name = "incompatible",
+        lib_ext = "incompatible",
+        major = "0",
+        minor = "0",
+        micro = "0",
+        os = "@platforms//:incompatible",
+    )
+
+def _resolve_interpreter_path(rctx):
+    """Find the absolute path for an interpreter.
+
+    Args:
+        rctx: A repository_ctx object
+
+    Returns:
+        `struct` with the following fields:
+        * `resolved_path`: `path` object of a path that exists
+        * `describe_failure`: `Callable | None`. If a path that doesn't exist,
+          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
+    else:
+        repo_utils.watch(rctx, 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))
+        else:
+            describe_failure = None
+
+    return struct(
+        resolved_path = resolved_path,
+        describe_failure = describe_failure,
+    )
+
+def _format_get_info_result(info):
+    lines = ["GetPythonInfo result:"]
+    for key, value in sorted(info.items()):
+        lines.append("  {}: {}".format(key, value if value != "" else "<empty string>"))
+    return "\n".join(lines)
diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl
new file mode 100644
index 0000000..23fa99d
--- /dev/null
+++ b/python/private/local_runtime_repo_setup.bzl
@@ -0,0 +1,141 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Setup code called by the code generated by `local_runtime_repo`."""
+
+load("@bazel_skylib//lib:selects.bzl", "selects")
+load("@rules_cc//cc:defs.bzl", "cc_library")
+load("@rules_python//python:py_runtime.bzl", "py_runtime")
+load("@rules_python//python:py_runtime_pair.bzl", "py_runtime_pair")
+load("@rules_python//python/cc:py_cc_toolchain.bzl", "py_cc_toolchain")
+load("@rules_python//python/private:py_exec_tools_toolchain.bzl", "py_exec_tools_toolchain")
+
+_PYTHON_VERSION_FLAG = Label("@rules_python//python/config_settings:python_version")
+
+def define_local_runtime_toolchain_impl(
+        name,
+        lib_ext,
+        major,
+        minor,
+        micro,
+        interpreter_path,
+        implementation_name,
+        os):
+    """Defines a toolchain implementation for a local Python runtime.
+
+    Generates public targets:
+    * `python_runtimes`: The target toolchain type implementation
+    * `py_exec_tools_toolchain`: The exec tools toolchain type implementation
+    * `py_cc_toolchain`: The py cc toolchain type implementation
+    * `os`: A constraint (or alias to one) for the `target_compatible_with` this
+      toolchain is compatible with.
+    * `is_matching_python_version`: A `config_setting` for `target_settings`
+      this toolchain is compatible with.
+
+    Args:
+        name: `str` Only present to satisfy tooling
+        lib_ext: `str` The file extension for the `libpython` shared libraries
+        major: `str` The major Python version, e.g. `3` of `3.9.1`.
+        minor: `str` The minor Python version, e.g. `9` of `3.9.1`.
+        micro: `str` The micro Python version, e.g. "1" of `3.9.1`.
+        interpreter_path: `str` Absolute path to the interpreter.
+        implementation_name: `str` The implementation name, as returned by
+            `sys.implementation.name`.
+        os: `str` A label to the OS constraint (e.g. `@platforms//os:linux`) for
+            this runtime.
+    """
+    major_minor = "{}.{}".format(major, minor)
+    major_minor_micro = "{}.{}".format(major_minor, micro)
+
+    cc_library(
+        name = "_python_headers",
+        # NOTE: Keep in sync with watch_tree() called in local_runtime_repo
+        srcs = native.glob(["include/**/*.h"]),
+        includes = ["include"],
+    )
+
+    cc_library(
+        name = "_libpython",
+        # Don't use a recursive glob because the lib/ directory usually contains
+        # a subdirectory of the stdlib -- lots of unrelated files
+        srcs = native.glob([
+            "lib/*{}".format(lib_ext),  # Match libpython*.so
+            "lib/*{}*".format(lib_ext),  # Also match libpython*.so.1.0
+        ]),
+        hdrs = [":_python_headers"],
+    )
+
+    py_runtime(
+        name = "_py3_runtime",
+        interpreter_path = interpreter_path,
+        python_version = "PY3",
+        interpreter_version_info = {
+            "major": major,
+            "micro": micro,
+            "minor": minor,
+        },
+        implementation_name = implementation_name,
+    )
+
+    py_runtime_pair(
+        name = "python_runtimes",
+        py2_runtime = None,
+        py3_runtime = ":_py3_runtime",
+        visibility = ["//visibility:public"],
+    )
+
+    py_exec_tools_toolchain(
+        name = "py_exec_tools_toolchain",
+        visibility = ["//visibility:public"],
+        precompiler = "@rules_python//tools/precompiler:precompiler",
+    )
+
+    py_cc_toolchain(
+        name = "py_cc_toolchain",
+        headers = ":_python_headers",
+        libs = ":_libpython",
+        python_version = major_minor_micro,
+        visibility = ["//visibility:public"],
+    )
+
+    native.alias(
+        name = "os",
+        # Call Label() to force the string to evaluate in the context of
+        # rules_python, not the calling BUILD-file code. This is because
+        # the value is an `@platforms//foo` string, which @rules_python has
+        # visibility to, but the calling repo may not.
+        actual = Label(os),
+        visibility = ["//visibility:public"],
+    )
+
+    native.config_setting(
+        name = "_is_major_minor",
+        flag_values = {
+            _PYTHON_VERSION_FLAG: major_minor,
+        },
+    )
+    native.config_setting(
+        name = "_is_major_minor_micro",
+        flag_values = {
+            _PYTHON_VERSION_FLAG: major_minor_micro,
+        },
+    )
+    selects.config_setting_group(
+        name = "is_matching_python_version",
+        match_any = [
+            ":_is_major_minor",
+            ":_is_major_minor_micro",
+        ],
+        visibility = ["//visibility:public"],
+    )
diff --git a/python/private/local_runtime_toolchains_repo.bzl b/python/private/local_runtime_toolchains_repo.bzl
new file mode 100644
index 0000000..880fbfe
--- /dev/null
+++ b/python/private/local_runtime_toolchains_repo.bzl
@@ -0,0 +1,93 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Create a repository to hold a local Python toolchain definitions."""
+
+load("//python/private:text_util.bzl", "render")
+load(":repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils")
+
+_TOOLCHAIN_TEMPLATE = """
+# Generated by local_runtime_toolchains_repo.bzl
+
+load("@rules_python//python/private:py_toolchain_suite.bzl", "define_local_toolchain_suites")
+
+define_local_toolchain_suites(
+    name = "toolchains",
+    version_aware_repo_names = {version_aware_names},
+    version_unaware_repo_names = {version_unaware_names},
+)
+"""
+
+def _local_runtime_toolchains_repo(rctx):
+    logger = repo_utils.logger(rctx)
+    rctx.file("WORKSPACE", "")
+    rctx.file("MODULE.bazel", "")
+    rctx.file("REPO.bazel", "")
+
+    logger.info(lambda: _format_toolchains_for_logging(rctx))
+
+    rctx.file("BUILD.bazel", _TOOLCHAIN_TEMPLATE.format(
+        version_aware_names = render.list(rctx.attr.runtimes),
+        version_unaware_names = render.list(rctx.attr.default_runtimes or rctx.attr.runtimes),
+    ))
+
+local_runtime_toolchains_repo = repository_rule(
+    implementation = _local_runtime_toolchains_repo,
+    doc = """
+Create a repo of toolchains definitions for local runtimes.
+
+This is intended to be used on the toolchain implemenations generated by
+`local_runtime_repo`.
+
+NOTE: This does not call `native.register_toolchains` -- the caller is
+responsible for registering the toolchains this defines.
+""",
+    attrs = {
+        "default_runtimes": attr.string_list(
+            doc = """
+The repo names of `local_runtime_repo` repos to define as toolchains.
+
+These will be defined as *version-unaware* toolchains. This means they will
+match any Python version. As such, they are registered after the version-aware
+toolchains defined by the `runtimes` attribute.
+
+Note that order matters: it determines the toolchain priority within the
+package.
+""",
+        ),
+        "runtimes": attr.string_list(
+            doc = """
+The repo names of `local_runtime_repo` repos to define as toolchains.
+
+These will be defined as *version-aware* toolchains. This means they require the
+`--//python/config_settings:python_version` to be set in order to match. These
+are registered before `default_runtimes`.
+
+Note that order matters: it determines the toolchain priority within the
+package.
+""",
+        ),
+        "_rule_name": attr.string(default = "local_toolchains_repo"),
+    },
+    environ = [REPO_DEBUG_ENV_VAR],
+)
+
+def _format_toolchains_for_logging(rctx):
+    lines = ["Local toolchain priority order:"]
+    i = 0
+    for i, name in enumerate(rctx.attr.runtimes, start = i):
+        lines.append("  {}: {} (version aware)".format(i, name))
+    for i, name in enumerate(rctx.attr.default_runtimes, start = i):
+        lines.append("  {}: {} (version unaware)".format(i, name))
+    return "\n".join(lines)
diff --git a/python/private/py_exec_tools_toolchain.bzl b/python/private/py_exec_tools_toolchain.bzl
index b3d0fb2..a4516d8 100644
--- a/python/private/py_exec_tools_toolchain.bzl
+++ b/python/private/py_exec_tools_toolchain.bzl
@@ -14,6 +14,7 @@
 
 """Rule that defines a toolchain for build tools."""
 
+load("@bazel_skylib//lib:paths.bzl", "paths")
 load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
 load("//python/private:toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE")
 load(":py_exec_tools_info.bzl", "PyExecToolsInfo")
@@ -53,11 +54,15 @@
 def _current_interpreter_executable_impl(ctx):
     toolchain = ctx.toolchains[TARGET_TOOLCHAIN_TYPE]
     runtime = toolchain.py3_runtime
+
+    # NOTE: We name the output filename after the underlying file name
+    # because of things like pyenv: they use $0 to determine what to
+    # re-exec. If it's not a recognized name, then they fail.
     if runtime.interpreter:
-        executable = ctx.actions.declare_file(ctx.label.name)
+        executable = ctx.actions.declare_file(runtime.interpreter.basename)
         ctx.actions.symlink(output = executable, target_file = runtime.interpreter, is_executable = True)
     else:
-        executable = ctx.actions.declare_symlink(ctx.label.name)
+        executable = ctx.actions.declare_symlink(paths.basename(runtime.interpreter_path))
         ctx.actions.symlink(output = executable, target_path = runtime.interpreter_path)
     return [
         toolchain,
diff --git a/python/private/py_toolchain_suite.bzl b/python/private/py_toolchain_suite.bzl
index 564e0ee..3fead95 100644
--- a/python/private/py_toolchain_suite.bzl
+++ b/python/private/py_toolchain_suite.bzl
@@ -15,6 +15,7 @@
 """Create the toolchain defs in a BUILD.bazel file."""
 
 load("@bazel_skylib//lib:selects.bzl", "selects")
+load("//python/private:text_util.bzl", "render")
 load(
     ":toolchain_types.bzl",
     "EXEC_TOOLS_TOOLCHAIN_TYPE",
@@ -24,7 +25,15 @@
 
 _IS_EXEC_TOOLCHAIN_ENABLED = Label("//python/config_settings:is_exec_tools_toolchain_enabled")
 
-def py_toolchain_suite(*, prefix, user_repository_name, python_version, set_python_version_constraint, flag_values, **kwargs):
+# buildifier: disable=unnamed-macro
+def py_toolchain_suite(
+        *,
+        prefix,
+        user_repository_name,
+        python_version,
+        set_python_version_constraint,
+        flag_values,
+        target_compatible_with = []):
     """For internal use only.
 
     Args:
@@ -33,8 +42,7 @@
         python_version: The full (X.Y.Z) version of the interpreter.
         set_python_version_constraint: True or False as a string.
         flag_values: Extra flag values to match for this toolchain.
-        **kwargs: extra args passed to the `toolchain` calls.
-
+        target_compatible_with: list constraints the toolchains are compatible with.
     """
 
     # We have to use a String value here because bzlmod is passing in a
@@ -82,30 +90,38 @@
             repr(set_python_version_constraint),
         ))
 
+    _internal_toolchain_suite(
+        prefix = prefix,
+        runtime_repo_name = user_repository_name,
+        target_settings = target_settings,
+        target_compatible_with = target_compatible_with,
+    )
+
+def _internal_toolchain_suite(prefix, runtime_repo_name, target_compatible_with, target_settings):
     native.toolchain(
         name = "{prefix}_toolchain".format(prefix = prefix),
-        toolchain = "@{user_repository_name}//:python_runtimes".format(
-            user_repository_name = user_repository_name,
+        toolchain = "@{runtime_repo_name}//:python_runtimes".format(
+            runtime_repo_name = runtime_repo_name,
         ),
         toolchain_type = TARGET_TOOLCHAIN_TYPE,
         target_settings = target_settings,
-        **kwargs
+        target_compatible_with = target_compatible_with,
     )
 
     native.toolchain(
         name = "{prefix}_py_cc_toolchain".format(prefix = prefix),
-        toolchain = "@{user_repository_name}//:py_cc_toolchain".format(
-            user_repository_name = user_repository_name,
+        toolchain = "@{runtime_repo_name}//:py_cc_toolchain".format(
+            runtime_repo_name = runtime_repo_name,
         ),
         toolchain_type = PY_CC_TOOLCHAIN_TYPE,
         target_settings = target_settings,
-        **kwargs
+        target_compatible_with = target_compatible_with,
     )
 
     native.toolchain(
         name = "{prefix}_py_exec_tools_toolchain".format(prefix = prefix),
-        toolchain = "@{user_repository_name}//:py_exec_tools_toolchain".format(
-            user_repository_name = user_repository_name,
+        toolchain = "@{runtime_repo_name}//:py_exec_tools_toolchain".format(
+            runtime_repo_name = runtime_repo_name,
         ),
         toolchain_type = EXEC_TOOLS_TOOLCHAIN_TYPE,
         target_settings = select({
@@ -118,10 +134,46 @@
             # the RHS must be a `config_setting`.
             "//conditions:default": [_IS_EXEC_TOOLCHAIN_ENABLED],
         }),
-        exec_compatible_with = kwargs.get("target_compatible_with"),
+        exec_compatible_with = target_compatible_with,
     )
 
     # NOTE: When adding a new toolchain, for WORKSPACE builds to see the
     # toolchain, the name must be added to the native.register_toolchains()
     # call in python/repositories.bzl. Bzlmod doesn't need anything; it will
     # register `:all`.
+
+def define_local_toolchain_suites(name, version_aware_repo_names, version_unaware_repo_names):
+    """Define toolchains for `local_runtime_repo` backed toolchains.
+
+    This generates `toolchain` targets that can be registered using `:all`. The
+    specific names of the toolchain targets are not defined. The priority order
+    of the toolchains is the order that is passed in, with version-aware having
+    higher priority than version-unaware.
+
+    Args:
+        name: `str` Unused; only present to satisfy tooling.
+        version_aware_repo_names: `list[str]` of the repo names that will have
+            version-aware toolchains defined.
+        version_unaware_repo_names: `list[str]` of the repo names that will have
+            version-unaware toolchains defined.
+    """
+    i = 0
+    for i, repo in enumerate(version_aware_repo_names, start = i):
+        prefix = render.left_pad_zero(i, 4)
+        _internal_toolchain_suite(
+            prefix = prefix,
+            runtime_repo_name = repo,
+            target_compatible_with = ["@{}//:os".format(repo)],
+            target_settings = ["@{}//:is_matching_python_version".format(repo)],
+        )
+
+    # The version unaware entries must go last because they will match any Python
+    # version.
+    for i, repo in enumerate(version_unaware_repo_names, start = i + 1):
+        prefix = render.left_pad_zero(i, 4)
+        _internal_toolchain_suite(
+            prefix = prefix,
+            runtime_repo_name = repo,
+            target_settings = [],
+            target_compatible_with = ["@{}//:os".format(repo)],
+        )
diff --git a/python/private/repo_utils.bzl b/python/private/repo_utils.bzl
index 54ad45c..9d76e19 100644
--- a/python/private/repo_utils.bzl
+++ b/python/private/repo_utils.bzl
@@ -29,7 +29,7 @@
     Returns:
         True if enabled, False if not.
     """
-    return rctx.os.environ.get(REPO_DEBUG_ENV_VAR) == "1"
+    return _getenv(rctx, REPO_DEBUG_ENV_VAR) == "1"
 
 def _debug_print(rctx, message_cb):
     """Prints a message if repo debugging is enabled.
@@ -46,7 +46,8 @@
     """Creates a logger instance for printing messages.
 
     Args:
-        rctx: repository_ctx object.
+        rctx: repository_ctx object. If the attribute `_rule_name` is
+            present, it will be included in log messages.
 
     Returns:
         A struct with attributes logging: trace, debug, info, warn, fail.
@@ -65,11 +66,20 @@
         "TRACE": 3,
     }.get(verbosity_level, 0)
 
-    def _log(enabled_on_verbosity, level, message_cb):
+    def _log(enabled_on_verbosity, level, message_cb_or_str):
         if verbosity < enabled_on_verbosity:
             return
+        rule_name = getattr(rctx.attr, "_rule_name", "?")
+        if type(message_cb_or_str) == "string":
+            message = message_cb_or_str
+        else:
+            message = message_cb_or_str()
 
-        print("\nrules_python: {}: ".format(level.upper()), message_cb())  # buildifier: disable=print
+        print("\nrules_python:{}(@@{}) {}:".format(
+            rule_name,
+            rctx.name,
+            level.upper(),
+        ), message)  # buildifier: disable=print
 
     return struct(
         trace = lambda message_cb: _log(3, "TRACE", message_cb),
@@ -117,6 +127,7 @@
         env_str = _env_to_str(environment),
     ))
 
+    rctx.report_progress("Running {}".format(op))
     result = rctx.execute(arguments, environment = environment, **kwargs)
 
     if fail_on_error and result.return_code != 0:
@@ -150,7 +161,18 @@
             output = _outputs_to_str(result),
         ))
 
-    return result
+    result_kwargs = {k: getattr(result, k) for k in dir(result)}
+    return struct(
+        describe_failure = lambda: _execute_describe_failure(
+            op = op,
+            arguments = arguments,
+            result = result,
+            rctx = rctx,
+            kwargs = kwargs,
+            environment = environment,
+        ),
+        **result_kwargs
+    )
 
 def _execute_unchecked(*args, **kwargs):
     """Execute a subprocess.
@@ -185,6 +207,25 @@
     """Calls execute_checked, but only returns the stdout value."""
     return _execute_checked(*args, **kwargs).stdout
 
+def _execute_describe_failure(*, op, arguments, result, rctx, kwargs, environment):
+    return (
+        "repo.execute: {op}: failure:\n" +
+        "  command: {cmd}\n" +
+        "  return code: {return_code}\n" +
+        "  working dir: {cwd}\n" +
+        "  timeout: {timeout}\n" +
+        "  environment:{env_str}\n" +
+        "{output}"
+    ).format(
+        op = op,
+        cmd = _args_to_str(arguments),
+        return_code = result.return_code,
+        cwd = _cwd_to_str(rctx, kwargs),
+        timeout = _timeout_to_str(kwargs),
+        env_str = _env_to_str(environment),
+        output = _outputs_to_str(result),
+    )
+
 def _which_checked(rctx, binary_name):
     """Tests to see if a binary exists, and otherwise fails with a message.
 
@@ -195,16 +236,54 @@
     Returns:
         rctx.Path for the binary.
     """
+    result = _which_unchecked(rctx, binary_name)
+    if result.binary == None:
+        fail(result.describe_failure())
+    return result.binary
+
+def _which_unchecked(rctx, binary_name):
+    """Tests to see if a binary exists.
+
+    This is also watch the `PATH` environment variable.
+
+    Args:
+        binary_name: name of the binary to find.
+        rctx: repository context.
+
+    Returns:
+        `struct` with attributes:
+        * `binary`: `repository_ctx.Path`
+        * `describe_failure`: `Callable | None`; takes no args. If the
+          binary couldn't be found, provides a detailed error description.
+    """
+    path = _getenv(rctx, "PATH", "")
     binary = rctx.which(binary_name)
-    if binary == None:
-        fail((
-            "Unable to find the binary '{binary_name}' on PATH.\n" +
-            "  PATH = {path}"
-        ).format(
-            binary_name = binary_name,
-            path = rctx.os.environ.get("PATH"),
-        ))
-    return binary
+    if binary:
+        _watch(rctx, binary)
+        describe_failure = None
+    else:
+        describe_failure = lambda: _which_describe_failure(binary_name, path)
+
+    return struct(
+        binary = binary,
+        describe_failure = describe_failure,
+    )
+
+def _which_describe_failure(binary_name, path):
+    return (
+        "Unable to find the binary '{binary_name}' on PATH.\n" +
+        "  PATH = {path}"
+    ).format(
+        binary_name = binary_name,
+        path = path,
+    )
+
+def _getenv(rctx, name, default = None):
+    # Bazel 7+ API
+    if hasattr(rctx, "getenv"):
+        return rctx.getenv(name, default)
+    else:
+        return rctx.os.environ.get("PATH", default)
 
 def _args_to_str(arguments):
     return " ".join([_arg_repr(a) for a in arguments])
@@ -262,12 +341,50 @@
             lines.append("<{} empty>".format(name))
     return "\n".join(lines)
 
+def _get_platforms_os_name(rctx):
+    """Return the name in @platforms//os for the host os.
+
+    Args:
+        rctx: repository_ctx
+
+    Returns:
+        `str | None`. The target name if it maps to known platforms
+        value, otherwise None.
+    """
+    os = rctx.os.name.lower()
+    if "linux" in os:
+        return os
+    if "windows" in os:
+        return "windows"
+    if "mac" in os:
+        return "osx"
+
+    return None
+
+# TODO: Remove after Bazel 6 support dropped
+def _watch(rctx, *args, **kwargs):
+    """Calls rctx.watch, if available."""
+    if hasattr(rctx, "watch"):
+        rctx.watch(*args, **kwargs)
+
+# TODO: Remove after Bazel 6 support dropped
+def _watch_tree(rctx, *args, **kwargs):
+    """Calls rctx.watch_tree, if available."""
+    if hasattr(rctx, "watch_tree"):
+        rctx.watch_tree(*args, **kwargs)
+
 repo_utils = struct(
-    execute_checked = _execute_checked,
-    execute_unchecked = _execute_unchecked,
-    execute_checked_stdout = _execute_checked_stdout,
-    is_repo_debug_enabled = _is_repo_debug_enabled,
+    # keep sorted
     debug_print = _debug_print,
-    which_checked = _which_checked,
+    execute_checked = _execute_checked,
+    execute_checked_stdout = _execute_checked_stdout,
+    execute_unchecked = _execute_unchecked,
+    get_platforms_os_name = _get_platforms_os_name,
+    getenv = _getenv,
+    is_repo_debug_enabled = _is_repo_debug_enabled,
     logger = _logger,
+    watch = _watch,
+    watch_tree = _watch_tree,
+    which_checked = _which_checked,
+    which_unchecked = _which_unchecked,
 )
diff --git a/python/private/text_util.bzl b/python/private/text_util.bzl
index 8a018e7..38f2b0e 100644
--- a/python/private/text_util.bzl
+++ b/python/private/text_util.bzl
@@ -20,6 +20,15 @@
 
     return "\n".join([indent + line for line in text.splitlines()])
 
+def _hanging_indent(text, indent = " " * 4):
+    if "\n" not in text:
+        return text
+
+    lines = text.splitlines()
+    for i, line in enumerate(lines):
+        lines[i] = (indent if i != 0 else "") + line
+    return "\n".join(lines)
+
 def _render_alias(name, actual, *, visibility = None):
     args = [
         "name = \"{}\",".format(name),
@@ -67,14 +76,24 @@
 
     return "{}({})".format(name, args)
 
-def _render_list(items):
+def _render_list(items, *, hanging_indent = ""):
+    """Convert a list to formatted text.
+
+    Args:
+        items: list of items.
+        hanging_indent: str, indent to apply to second and following lines of
+            the formatted text.
+
+    Returns:
+        The list pretty formatted as a string.
+    """
     if not items:
         return "[]"
 
     if len(items) == 1:
         return "[{}]".format(repr(items[0]))
 
-    return "\n".join([
+    text = "\n".join([
         "[",
         _indent("\n".join([
             "{},".format(repr(item))
@@ -82,6 +101,12 @@
         ])),
         "]",
     ])
+    if hanging_indent:
+        text = _hanging_indent(text, hanging_indent)
+    return text
+
+def _render_str(value):
+    return repr(value)
 
 def _render_tuple(items, *, value_repr = repr):
     if not items:
@@ -116,10 +141,12 @@
 render = struct(
     alias = _render_alias,
     dict = _render_dict,
+    hanging_indent = _hanging_indent,
     indent = _indent,
     left_pad_zero = _left_pad_zero,
     list = _render_list,
     select = _render_select,
-    tuple = _render_tuple,
+    str = _render_str,
     toolchain_prefix = _toolchain_prefix,
+    tuple = _render_tuple,
 )