feat: provide access to arbitrary interpreters (#2507)

There are some use cases that folks want to cover here. They are
discussed in [this Slack thread][1]. The high-level summary is:
1. Users want to run the exact same interpreter that Bazel is running
   to minimize environmental issues.
2. It is useful to pass a target label to third-party tools like mypy
   so that they can use the correct interpreter.

This patch adds to @rickeylev's work from #2359 by adding docs
and a few integration tests.

[1]: https://bazelbuild.slack.com/archives/CA306CEV6/p1730095371089259

---------

Co-authored-by: Richard Levasseur <rlevasseur@google.com>
diff --git a/docs/api/rules_python/python/bin/index.md b/docs/api/rules_python/python/bin/index.md
new file mode 100644
index 0000000..ad6a4e7
--- /dev/null
+++ b/docs/api/rules_python/python/bin/index.md
@@ -0,0 +1,41 @@
+:::{default-domain} bzl
+:::
+:::{bzl:currentfile} //python/bin:BUILD.bazel
+:::
+
+# //python/bin
+
+:::{bzl:target} python
+
+A target to directly run a Python interpreter.
+
+By default, it uses the Python version that toolchain resolution matches
+(typically the one marked `is_default=True` in `MODULE.bazel`).
+
+This runs a Python interpreter in a similar manner as when running `python3`
+on the command line. It can be invoked using `bazel run`. Remember that in
+order to pass flags onto the program `--` must be specified to separate
+Bazel flags from the program flags.
+
+An example that will run Python 3.12 and have it print the version
+
+```
+bazel run @rules_python//python/bin:python \
+  `--@rule_python//python/config_settings:python_verion=3.12 \
+  -- \
+  --version
+```
+
+::::{seealso}
+The {flag}`--python_src` flag for using the intepreter a binary/test uses.
+::::
+
+::::{versionadded} VERSION_NEXT_FEATURE
+::::
+:::
+
+:::{bzl:flag} python_src
+
+The target (one providing `PyRuntimeInfo`) whose python interpreter to use for
+{obj}`:python`.
+:::
diff --git a/docs/toolchains.md b/docs/toolchains.md
index 6eaa244..3294c17 100644
--- a/docs/toolchains.md
+++ b/docs/toolchains.md
@@ -396,7 +396,7 @@
 
 This is typically implemented using {obj}`py_cc_toolchain()`, which provides
 {obj}`ToolchainInfo` with the field `py_cc_toolchain` set, which is a
-{obj}`PyCcToolchainInfo` provider instance. 
+{obj}`PyCcToolchainInfo` provider instance.
 
 This toolchain type is intended to hold only _target configuration_ values
 relating to the C/C++ information for the Python runtime. As such, when defining
@@ -556,4 +556,45 @@
 Currently the following flags are used to influence toolchain selection:
 * {obj}`--@rules_python//python/config_settings:py_linux_libc` for selecting the Linux libc variant.
 * {obj}`--@rules_python//python/config_settings:py_freethreaded` for selecting
-  the freethreaded experimental Python builds available from `3.13.0` onwards.
\ No newline at end of file
+  the freethreaded experimental Python builds available from `3.13.0` onwards.
+
+## Running the underlying interpreter
+
+To run the interpreter that Bazel will use, you can use the
+`@rules_python//python/bin:python` target. This is a binary target with
+the executable pointing at the `python3` binary plus its relevent runfiles.
+
+```console
+$ bazel run @rules_python//python/bin:python
+Python 3.11.1 (main, Jan 16 2023, 22:41:20) [Clang 15.0.7 ] on linux
+Type "help", "copyright", "credits" or "license" for more information.
+>>>
+$ bazel run @rules_python//python/bin:python --@rules_python//python/config_settings:python_version=3.12
+Python 3.12.0 (main, Oct  3 2023, 01:27:23) [Clang 17.0.1 ] on linux
+Type "help", "copyright", "credits" or "license" for more information.
+>>>
+```
+
+You can also access a specific binary's interpreter this way by using the
+`@rules_python//python/bin:python_src` target. In the example below, it is
+assumed that the `@rules_python//tools/publish:twine` binary is fixed at Python
+3.11.
+
+```console
+$ bazel run @rules_python//python/bin:python --@rules_python//python/bin:interpreter_src=@rules_python//tools/publish:twine
+Python 3.11.1 (main, Jan 16 2023, 22:41:20) [Clang 15.0.7 ] on linux
+Type "help", "copyright", "credits" or "license" for more information.
+>>>
+$ bazel run @rules_python//python/bin:python --@rules_python//python/bin:interpreter_src=@rules_python//tools/publish:twine --@rules_python//python/config_settings:python_version=3.12
+Python 3.11.1 (main, Jan 16 2023, 22:41:20) [Clang 15.0.7 ] on linux
+Type "help", "copyright", "credits" or "license" for more information.
+>>>
+```
+Despite setting the Python version explicitly to 3.12 in the example above, the
+interpreter comes from the `@rules_python//tools/publish:twine` binary. That is
+a fixed version.
+
+:::{note}
+The `python` target does not provide access to any modules from `py_*`
+targets on its own. Please file a feature request if this is desired.
+:::
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index 5c6c6a4..c52e772 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -35,6 +35,7 @@
     name = "distribution",
     srcs = glob(["**"]) + [
         "//python/api:distribution",
+        "//python/bin:distribution",
         "//python/cc:distribution",
         "//python/config_settings:distribution",
         "//python/constraints:distribution",
diff --git a/python/bin/BUILD.bazel b/python/bin/BUILD.bazel
new file mode 100644
index 0000000..57bee34
--- /dev/null
+++ b/python/bin/BUILD.bazel
@@ -0,0 +1,24 @@
+load("//python/private:interpreter.bzl", _interpreter_binary = "interpreter_binary")
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["**"]),
+    visibility = ["//:__subpackages__"],
+)
+
+_interpreter_binary(
+    name = "python",
+    binary = ":python_src",
+    target_compatible_with = select({
+        "@platforms//os:windows": ["@platforms//:incompatible"],
+        "//conditions:default": [],
+    }),
+    visibility = ["//visibility:public"],
+)
+
+# The user can modify this flag to source different interpreters for the
+# `python` target above.
+label_flag(
+    name = "python_src",
+    build_setting_default = "//python:none",
+)
diff --git a/python/private/common.bzl b/python/private/common.bzl
index b6a5453..137f0d2 100644
--- a/python/private/common.bzl
+++ b/python/private/common.bzl
@@ -543,3 +543,20 @@
         if ctx.target_platform_has_constraint(constraint_value):
             return True
     return False
+
+def runfiles_root_path(ctx, short_path):
+    """Compute a runfiles-root relative path from `File.short_path`
+
+    Args:
+        ctx: current target ctx
+        short_path: str, a main-repo relative path from `File.short_path`
+
+    Returns:
+        {type}`str`, a runflies-root relative path
+    """
+
+    # The ../ comes from short_path is for files in other repos.
+    if short_path.startswith("../"):
+        return short_path[3:]
+    else:
+        return "{}/{}".format(ctx.workspace_name, short_path)
diff --git a/python/private/interpreter.bzl b/python/private/interpreter.bzl
new file mode 100644
index 0000000..c66d3dc
--- /dev/null
+++ b/python/private/interpreter.bzl
@@ -0,0 +1,82 @@
+# Copyright 2025 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.
+
+"""Implementation of the rules to access the underlying Python interpreter."""
+
+load("@bazel_skylib//lib:paths.bzl", "paths")
+load("//python:py_runtime_info.bzl", "PyRuntimeInfo")
+load(":common.bzl", "runfiles_root_path")
+load(":sentinel.bzl", "SentinelInfo")
+load(":toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE")
+
+def _interpreter_binary_impl(ctx):
+    if SentinelInfo in ctx.attr.binary:
+        toolchain = ctx.toolchains[TARGET_TOOLCHAIN_TYPE]
+        runtime = toolchain.py3_runtime
+    else:
+        runtime = ctx.attr.binary[PyRuntimeInfo]
+
+    # 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:
+        # In order for this to work both locally and remotely, we create a
+        # shell script here that re-exec's into the real interpreter. Ideally,
+        # we'd just use a symlink, but that breaks under certain conditions. If
+        # we use a ctx.actions.symlink(target=...) then it fails under remote
+        # execution. If we use ctx.actions.symlink(target_path=...) then it
+        # behaves differently inside the runfiles tree and outside the runfiles
+        # tree.
+        #
+        # This currently does not work on Windows. Need to find a way to enable
+        # that.
+        executable = ctx.actions.declare_file(runtime.interpreter.basename)
+        ctx.actions.expand_template(
+            template = ctx.file._template,
+            output = executable,
+            substitutions = {
+                "%target_file%": runfiles_root_path(ctx, runtime.interpreter.short_path),
+            },
+            is_executable = True,
+        )
+    else:
+        executable = ctx.actions.declare_symlink(paths.basename(runtime.interpreter_path))
+        ctx.actions.symlink(output = executable, target_path = runtime.interpreter_path)
+
+    return [
+        DefaultInfo(
+            executable = executable,
+            runfiles = ctx.runfiles([executable], transitive_files = runtime.files).merge_all([
+                ctx.attr._bash_runfiles[DefaultInfo].default_runfiles,
+            ]),
+        ),
+    ]
+
+interpreter_binary = rule(
+    implementation = _interpreter_binary_impl,
+    toolchains = [TARGET_TOOLCHAIN_TYPE],
+    executable = True,
+    attrs = {
+        "binary": attr.label(
+            mandatory = True,
+        ),
+        "_bash_runfiles": attr.label(
+            default = "@bazel_tools//tools/bash/runfiles",
+        ),
+        "_template": attr.label(
+            default = "//python/private:interpreter_tmpl.sh",
+            allow_single_file = True,
+        ),
+    },
+)
diff --git a/python/private/interpreter_tmpl.sh b/python/private/interpreter_tmpl.sh
new file mode 100644
index 0000000..cfe85ec
--- /dev/null
+++ b/python/private/interpreter_tmpl.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+# --- begin runfiles.bash initialization v3 ---
+# Copy-pasted from the Bazel Bash runfiles library v3.
+set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+# shellcheck disable=SC1090
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v3 ---
+
+set +e # allow us to check for errors more easily
+readonly TARGET_FILE="%target_file%"
+MAIN_BIN=$(rlocation "$TARGET_FILE")
+
+if [[ -z "$MAIN_BIN" || ! -e "$MAIN_BIN" ]]; then
+  echo "ERROR: interpreter executable not found: $MAIN_BIN (from $TARGET_FILE)"
+  exit 1
+fi
+exec "${MAIN_BIN}" "$@"
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index 2b2bf66..a2ccdc6 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -48,6 +48,7 @@
     "filter_to_py_srcs",
     "get_imports",
     "is_bool",
+    "runfiles_root_path",
     "target_platform_has_any_constraint",
     "union_attrs",
 )
@@ -447,7 +448,7 @@
     )
 
 def _create_zip_main(ctx, *, stage2_bootstrap, runtime_details, venv):
-    python_binary = _runfiles_root_path(ctx, venv.interpreter.short_path)
+    python_binary = runfiles_root_path(ctx, venv.interpreter.short_path)
     python_binary_actual = venv.interpreter_actual_path
 
     # The location of this file doesn't really matter. It's added to
@@ -522,7 +523,7 @@
 
     if not venvs_use_declare_symlink_enabled:
         if runtime.interpreter:
-            interpreter_actual_path = _runfiles_root_path(ctx, runtime.interpreter.short_path)
+            interpreter_actual_path = runfiles_root_path(ctx, runtime.interpreter.short_path)
         else:
             interpreter_actual_path = runtime.interpreter_path
 
@@ -543,11 +544,11 @@
         # may choose to write what symlink() points to instead.
         interpreter = ctx.actions.declare_symlink("{}/bin/{}".format(venv, py_exe_basename))
 
-        interpreter_actual_path = _runfiles_root_path(ctx, runtime.interpreter.short_path)
+        interpreter_actual_path = runfiles_root_path(ctx, runtime.interpreter.short_path)
         rel_path = relative_path(
             # dirname is necessary because a relative symlink is relative to
             # the directory the symlink resides within.
-            from_ = paths.dirname(_runfiles_root_path(ctx, interpreter.short_path)),
+            from_ = paths.dirname(runfiles_root_path(ctx, interpreter.short_path)),
             to = interpreter_actual_path,
         )
 
@@ -646,23 +647,6 @@
     )
     return output
 
-def _runfiles_root_path(ctx, short_path):
-    """Compute a runfiles-root relative path from `File.short_path`
-
-    Args:
-        ctx: current target ctx
-        short_path: str, a main-repo relative path from `File.short_path`
-
-    Returns:
-        {type}`str`, a runflies-root relative path
-    """
-
-    # The ../ comes from short_path is for files in other repos.
-    if short_path.startswith("../"):
-        return short_path[3:]
-    else:
-        return "{}/{}".format(ctx.workspace_name, short_path)
-
 def _create_stage1_bootstrap(
         ctx,
         *,
@@ -676,7 +660,7 @@
     runtime = runtime_details.effective_runtime
 
     if venv:
-        python_binary_path = _runfiles_root_path(ctx, venv.interpreter.short_path)
+        python_binary_path = runfiles_root_path(ctx, venv.interpreter.short_path)
     else:
         python_binary_path = runtime_details.executable_interpreter_path
 
diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py
index dcbd799..40fb4e4 100644
--- a/python/private/site_init_template.py
+++ b/python/private/site_init_template.py
@@ -163,7 +163,9 @@
         if cov_tool:
             _print_verbose_coverage(f"Using toolchain coverage_tool {cov_tool}")
         elif cov_tool := os.environ.get("PYTHON_COVERAGE"):
-            _print_verbose_coverage(f"Using env var coverage: PYTHON_COVERAGE={cov_tool}")
+            _print_verbose_coverage(
+                f"Using env var coverage: PYTHON_COVERAGE={cov_tool}"
+            )
 
         if cov_tool:
             if os.path.isabs(cov_tool):
diff --git a/tests/interpreter/BUILD.bazel b/tests/interpreter/BUILD.bazel
new file mode 100644
index 0000000..5d89ede
--- /dev/null
+++ b/tests/interpreter/BUILD.bazel
@@ -0,0 +1,52 @@
+# 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.
+
+load(":interpreter_tests.bzl", "PYTHON_VERSIONS_TO_TEST", "py_reconfig_interpreter_tests")
+
+# For this test the interpreter is sourced from the current configuration. That
+# means both the interpreter and the test itself are expected to run under the
+# same Python version.
+py_reconfig_interpreter_tests(
+    name = "interpreter_version_test",
+    srcs = ["interpreter_test.py"],
+    data = [
+        "//python/bin:python",
+    ],
+    env = {
+        "PYTHON_BIN": "$(rootpath //python/bin:python)",
+    },
+    main = "interpreter_test.py",
+    python_versions = PYTHON_VERSIONS_TO_TEST,
+)
+
+# For this test the interpreter is sourced from a binary pinned at a specific
+# Python version. That means the interpreter and the test itself can run
+# different Python versions.
+py_reconfig_interpreter_tests(
+    name = "python_src_test",
+    srcs = ["interpreter_test.py"],
+    data = [
+        "//python/bin:python",
+    ],
+    env = {
+        # Since we're grabbing the interpreter from a binary with a fixed
+        # version, we expect to always see that version. It doesn't matter what
+        # Python version the test itself is running with.
+        "EXPECTED_INTERPRETER_VERSION": "3.11",
+        "PYTHON_BIN": "$(rootpath //python/bin:python)",
+    },
+    main = "interpreter_test.py",
+    python_src = "//tools/publish:twine",
+    python_versions = PYTHON_VERSIONS_TO_TEST,
+)
diff --git a/tests/interpreter/interpreter_test.py b/tests/interpreter/interpreter_test.py
new file mode 100644
index 0000000..0971fa2
--- /dev/null
+++ b/tests/interpreter/interpreter_test.py
@@ -0,0 +1,80 @@
+# 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 os
+import subprocess
+import sys
+import unittest
+
+
+class InterpreterTest(unittest.TestCase):
+    def setUp(self):
+        super().setUp()
+        self.interpreter = os.environ["PYTHON_BIN"]
+
+        v = sys.version_info
+        self.version = f"{v.major}.{v.minor}"
+
+    def test_self_version(self):
+        """Performs a sanity check on the Python version used for this test."""
+        expected_version = os.environ["EXPECTED_SELF_VERSION"]
+        self.assertEqual(expected_version, self.version)
+
+    def test_interpreter_version(self):
+        """Validates that we can successfully execute arbitrary code from the CLI."""
+        expected_version = os.environ.get("EXPECTED_INTERPRETER_VERSION", self.version)
+
+        try:
+            result = subprocess.check_output(
+                [self.interpreter],
+                text=True,
+                stderr=subprocess.STDOUT,
+                input="\r".join(
+                    [
+                        "import sys",
+                        "v = sys.version_info",
+                        "print(f'version: {v.major}.{v.minor}')",
+                    ]
+                ),
+            ).strip()
+        except subprocess.CalledProcessError as error:
+            print("OUTPUT:", error.stdout)
+            raise
+
+        self.assertEqual(result, f"version: {expected_version}")
+
+    def test_json_tool(self):
+        """Validates that we can successfully invoke a module from the CLI."""
+        # Pass unformatted JSON to the json.tool module.
+        try:
+            result = subprocess.check_output(
+                [
+                    self.interpreter,
+                    "-m",
+                    "json.tool",
+                ],
+                text=True,
+                stderr=subprocess.STDOUT,
+                input='{"json":"obj"}',
+            ).strip()
+        except subprocess.CalledProcessError as error:
+            print("OUTPUT:", error.stdout)
+            raise
+
+        # Validate that we get formatted JSON back.
+        self.assertEqual(result, '{\n    "json": "obj"\n}')
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/interpreter/interpreter_tests.bzl b/tests/interpreter/interpreter_tests.bzl
new file mode 100644
index 0000000..ad94f43
--- /dev/null
+++ b/tests/interpreter/interpreter_tests.bzl
@@ -0,0 +1,54 @@
+# Copyright 2025 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.
+
+"""This file contains helpers for testing the interpreter rule."""
+
+load("//tests/support:sh_py_run_test.bzl", "py_reconfig_test")
+
+# The versions of Python that we want to run the interpreter tests against.
+PYTHON_VERSIONS_TO_TEST = (
+    "3.10",
+    "3.11",
+    "3.12",
+)
+
+def py_reconfig_interpreter_tests(name, python_versions, env = {}, **kwargs):
+    """Runs the specified test against each of the specified Python versions.
+
+    One test gets generated for each Python version. The following environment
+    variable gets set for the test:
+
+        EXPECTED_SELF_VERSION: Contains the Python version that the test itself
+            is running under.
+
+    Args:
+        name: Name of the test.
+        python_versions: A list of Python versions to test.
+        env: The environment to set on the test.
+        **kwargs: Passed to the underlying py_reconfig_test targets.
+    """
+    for python_version in python_versions:
+        py_reconfig_test(
+            name = "{}_{}".format(name, python_version),
+            env = env | {
+                "EXPECTED_SELF_VERSION": python_version,
+            },
+            python_version = python_version,
+            **kwargs
+        )
+
+    native.test_suite(
+        name = name,
+        tests = [":{}_{}".format(name, python_version) for python_version in python_versions],
+    )
diff --git a/tests/support/sh_py_run_test.bzl b/tests/support/sh_py_run_test.bzl
index a1da285..d116f04 100644
--- a/tests/support/sh_py_run_test.bzl
+++ b/tests/support/sh_py_run_test.bzl
@@ -35,12 +35,15 @@
         settings["//python/config_settings:bootstrap_impl"] = attr.bootstrap_impl
     if attr.extra_toolchains:
         settings["//command_line_option:extra_toolchains"] = attr.extra_toolchains
+    if attr.python_src:
+        settings["//python/bin:python_src"] = attr.python_src
     if attr.venvs_use_declare_symlink:
         settings["//python/config_settings:venvs_use_declare_symlink"] = attr.venvs_use_declare_symlink
     return settings
 
 _RECONFIG_INPUTS = [
     "//python/config_settings:bootstrap_impl",
+    "//python/bin:python_src",
     "//command_line_option:extra_toolchains",
     "//python/config_settings:venvs_use_declare_symlink",
 ]
@@ -62,6 +65,7 @@
 toolchain.
 """,
     ),
+    "python_src": attr.label(),
     "venvs_use_declare_symlink": attr.string(),
 }