feat: expose interpreter files-to-run on PyRuntimeInfo (#3795)

Rules that execute a py_runtime interpreter in an action need the
interpreter executable together with its runfiles metadata. The existing
PyRuntimeInfo fields identify the interpreter file and runtime files,
but do not preserve the target's FilesToRunProvider for executable
interpreter targets.

Add PyRuntimeInfo.interpreter_files_to_run for runtimes created from an
executable interpreter target, and validate that direct provider
construction keeps the FilesToRunProvider executable aligned with the
interpreter field. Direct file interpreters and platform runtimes
continue to leave this field unset, preserving existing py_runtime
behavior.

Document the new public provider field and add focused analysis-test
coverage for executable, file-only, platform, and invalid constructor
cases.

One example is
[rules_pycross](https://github.com/jvolkman/rules_pycross) wheel
building with an in-build executable Python runtime. pycross needs to
run the selected Python interpreter in an action, but PyRuntimeInfo
previously exposed only the interpreter File and runtime files, not the
interpreter target's FilesToRunProvider. For executable in-build
runtimes, the FilesToRunProvider is the Bazel-native handle that carries
both the executable and the runfiles metadata needed to stage it as an
action tool. Exposing it lets pycross consume the runtime interpreter
directly for wheel-build actions instead of reconstructing or
approximating the interpreter's runtime closure from separate fields.

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7efdd66..05daf8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,6 +119,8 @@
 
 {#v0-0-0-added}
 ### Added
+* (toolchain) Added {obj}`PyRuntimeInfo.interpreter_files_to_run` so action
+  consumers can execute an in-build runtime interpreter with its runfiles.
 * (toolchains) Support dynamically fetching and registering Python runtimes
   from a python-build-standalone manifest file using
   `python.override(add_runtime_manifest_urls = ..., runtime_manifest_sha = ...)`.
diff --git a/python/private/py_runtime_info.bzl b/python/private/py_runtime_info.bzl
index d94f469..64fef39 100644
--- a/python/private/py_runtime_info.bzl
+++ b/python/private/py_runtime_info.bzl
@@ -55,6 +55,7 @@
         interpreter_path = None,
         interpreter = None,
         files = None,
+        interpreter_files_to_run = None,
         coverage_tool = None,
         coverage_files = None,
         pyc_tag = None,
@@ -74,6 +75,15 @@
     if interpreter_path and files != None:
         fail("cannot specify 'files' if 'interpreter_path' is given")
 
+    if interpreter_path and interpreter_files_to_run:
+        fail("cannot specify 'interpreter_files_to_run' if 'interpreter_path' is given")
+
+    if interpreter_files_to_run:
+        if not interpreter_files_to_run.executable:
+            fail("'interpreter_files_to_run' must have an executable")
+        if interpreter_files_to_run.executable != interpreter:
+            fail("'interpreter_files_to_run.executable' must match 'interpreter'")
+
     if (coverage_tool and not coverage_files) or (not coverage_tool and coverage_files):
         fail(
             "coverage_tool and coverage_files must both be set or neither must be set, " +
@@ -112,6 +122,7 @@
         "files": files,
         "implementation_name": implementation_name,
         "interpreter": interpreter,
+        "interpreter_files_to_run": interpreter_files_to_run,
         "interpreter_path": interpreter_path,
         "interpreter_version_info": interpreter_version_info_struct_from_dict(interpreter_version_info),
         "pyc_tag": pyc_tag,
@@ -240,6 +251,19 @@
 interpreter. Otherwise, this is `None`. Note that an in-build runtime can use
 either a prebuilt, checked-in interpreter or an interpreter built from source.
 """,
+        "interpreter_files_to_run": """
+:type: None | FilesToRunProvider
+
+The `FilesToRunProvider` for the interpreter target when this runtime was
+created from an executable target. This includes the interpreter executable and
+the runfiles metadata needed to use it as an action tool. Rules that execute the
+interpreter in an action should use this field so Bazel can stage the
+interpreter together with its runfiles. This is `None` for platform runtimes
+using `interpreter_path` and for file-only interpreter targets.
+
+:::{versionadded} VERSION_NEXT_FEATURE
+:::
+""",
         "interpreter_path": """
 :type: str | None
 
diff --git a/python/private/py_runtime_rule.bzl b/python/private/py_runtime_rule.bzl
index dfc915f..4863738 100644
--- a/python/private/py_runtime_rule.bzl
+++ b/python/private/py_runtime_rule.bzl
@@ -39,6 +39,7 @@
     runfiles = ctx.runfiles()
 
     hermetic = bool(interpreter)
+    interpreter_files_to_run = None
     if not hermetic:
         if runtime_files:
             fail("if 'interpreter_path' is given then 'files' must be empty")
@@ -46,9 +47,23 @@
             fail("interpreter_path must be an absolute path")
     else:
         interpreter_di = interpreter[DefaultInfo]
+        interpreter_file = None
 
-        if interpreter_di.files_to_run and interpreter_di.files_to_run.executable:
-            interpreter = interpreter_di.files_to_run.executable
+        if _is_singleton_depset(interpreter_di.files):
+            interpreter_file = interpreter_di.files.to_list()[0]
+
+        is_executable_source_file = (
+            interpreter_file and
+            interpreter_file.is_source and
+            interpreter_di.files_to_run and
+            interpreter_di.files_to_run.executable == interpreter_file
+        )
+
+        if is_executable_source_file:
+            # Source files Bazel treats as executable, e.g. direct file labels
+            # and filegroups: preserve historical runtime-file expansion, but
+            # do not expose a FilesToRunProvider.
+            interpreter = interpreter_file
             runfiles = runfiles.merge(interpreter_di.default_runfiles)
 
             runtime_files = depset(transitive = [
@@ -56,8 +71,22 @@
                 interpreter_di.default_runfiles.files,
                 runtime_files,
             ])
-        elif _is_singleton_depset(interpreter_di.files):
-            interpreter = interpreter_di.files.to_list()[0]
+        elif interpreter_di.files_to_run and interpreter_di.files_to_run.executable:
+            # Executable rule target: use the executable and preserve the full
+            # FilesToRunProvider so action consumers can stage its runfiles.
+            interpreter = interpreter_di.files_to_run.executable
+            interpreter_files_to_run = interpreter_di.files_to_run
+            runfiles = runfiles.merge(interpreter_di.default_runfiles)
+
+            runtime_files = depset(transitive = [
+                interpreter_di.files,
+                interpreter_di.default_runfiles.files,
+                runtime_files,
+            ])
+        elif interpreter_file:
+            # Non-executable rule with exactly one output: preserve the
+            # historical file-only interpreter behavior.
+            interpreter = interpreter_file
         else:
             fail("interpreter must be an executable target or must produce exactly one file.")
 
@@ -124,6 +153,7 @@
     py_runtime_info_kwargs.update(dict(
         implementation_name = ctx.attr.implementation_name,
         interpreter_version_info = interpreter_version_info,
+        interpreter_files_to_run = interpreter_files_to_run,
         pyc_tag = pyc_tag,
         stage2_bootstrap_template = ctx.file.stage2_bootstrap_template,
         zip_main_template = ctx.file.zip_main_template,
diff --git a/tests/py_runtime/py_runtime_tests.bzl b/tests/py_runtime/py_runtime_tests.bzl
index b8aa1f3..ac80c85 100644
--- a/tests/py_runtime/py_runtime_tests.bzl
+++ b/tests/py_runtime/py_runtime_tests.bzl
@@ -42,6 +42,20 @@
     executable = True,
 )
 
+def _source_file_wrapper_impl(ctx):
+    return [DefaultInfo(
+        files = depset([ctx.file.src]),
+        runfiles = ctx.runfiles(files = ctx.files.data),
+    )]
+
+_source_file_wrapper = rule(
+    implementation = _source_file_wrapper_impl,
+    attrs = {
+        "data": attr.label_list(allow_files = True),
+        "src": attr.label(allow_single_file = True, mandatory = True),
+    },
+)
+
 def _test_bootstrap_template(name):
     rt_util.helper_target(
         py_runtime,
@@ -195,11 +209,54 @@
 def _test_in_build_interpreter_impl(env, target):
     info = env.expect.that_target(target).provider(PyRuntimeInfo, factory = py_runtime_info_subject)
     info.python_version().equals("PY3")
-    info.files().contains_predicate(matching.file_basename_equals("file1.txt"))
+    info.files().contains_exactly([
+        "{package}/fake_interpreter",
+        "{package}/file1.txt",
+    ])
     info.interpreter().path().contains("fake_interpreter")
+    env.expect.that_bool(info.actual.interpreter_files_to_run == None).equals(True)
 
 _tests.append(_test_in_build_interpreter)
 
+def _test_non_executable_source_file_interpreter_keeps_file_only_behavior(name):
+    rt_util.helper_target(
+        _source_file_wrapper,
+        name = name + "_wrapped_interpreter",
+        src = "fake_interpreter",
+        data = ["runfile.txt"],
+    )
+
+    rt_util.helper_target(
+        py_runtime,
+        name = name + "_subject",
+        interpreter = name + "_wrapped_interpreter",
+        python_version = "PY3",
+        files = ["file1.txt"],
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_non_executable_source_file_interpreter_keeps_file_only_behavior_impl,
+    )
+
+def _test_non_executable_source_file_interpreter_keeps_file_only_behavior_impl(env, target):
+    target = env.expect.that_target(target)
+    py_runtime_info = target.provider(
+        PyRuntimeInfo,
+        factory = py_runtime_info_subject,
+    )
+    py_runtime_info.interpreter().short_path_equals("{package}/fake_interpreter")
+    env.expect.that_bool(py_runtime_info.actual.interpreter_files_to_run == None).equals(True)
+    py_runtime_info.files().contains_exactly([
+        "{package}/file1.txt",
+    ])
+
+    target.default_outputs().contains_exactly([
+        "{package}/file1.txt",
+    ])
+
+_tests.append(_test_non_executable_source_file_interpreter_keeps_file_only_behavior)
+
 def _test_interpreter_binary_with_multiple_outputs(name):
     rt_util.helper_target(
         _simple_binary,
@@ -227,6 +284,9 @@
         factory = py_runtime_info_subject,
     )
     py_runtime_info.interpreter().short_path_equals("{package}/{test_name}_built_interpreter")
+    py_runtime_info.interpreter_files_to_run().executable().short_path_equals(
+        "{package}/{test_name}_built_interpreter",
+    )
     py_runtime_info.files().contains_exactly([
         "{package}/extra_default_output.txt",
         "{package}/runfile.txt",
@@ -272,6 +332,9 @@
         factory = py_runtime_info_subject,
     )
     py_runtime_info.interpreter().short_path_equals("{package}/{test_name}_built_interpreter")
+    py_runtime_info.interpreter_files_to_run().executable().short_path_equals(
+        "{package}/{test_name}_built_interpreter",
+    )
     py_runtime_info.files().contains_exactly([
         "{package}/runfile.txt",
         "{package}/{test_name}_built_interpreter",
@@ -327,10 +390,12 @@
     )
 
 def _test_system_interpreter_impl(env, target):
-    env.expect.that_target(target).provider(
+    info = env.expect.that_target(target).provider(
         PyRuntimeInfo,
         factory = py_runtime_info_subject,
-    ).interpreter_path().equals("/system/python")
+    )
+    info.interpreter_path().equals("/system/python")
+    env.expect.that_bool(info.actual.interpreter_files_to_run == None).equals(True)
 
 _tests.append(_test_system_interpreter)
 
diff --git a/tests/py_runtime_info/py_runtime_info_tests.bzl b/tests/py_runtime_info/py_runtime_info_tests.bzl
index a44fb60..6b9ecac 100644
--- a/tests/py_runtime_info/py_runtime_info_tests.bzl
+++ b/tests/py_runtime_info/py_runtime_info_tests.bzl
@@ -15,7 +15,9 @@
 
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
 load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching")
 load("//python:py_runtime_info.bzl", "PyRuntimeInfo")
+load("//tests/support:py_runtime_info_subject.bzl", "py_runtime_info_subject")
 
 def _create_py_runtime_info_without_interpreter_version_info_impl(ctx):
     return [PyRuntimeInfo(
@@ -35,6 +37,57 @@
     },
 )
 
+def _simple_binary_impl(ctx):
+    executable = ctx.actions.declare_file(ctx.label.name)
+    ctx.actions.write(executable, "", is_executable = True)
+    return [DefaultInfo(
+        executable = executable,
+        files = depset([executable]),
+    )]
+
+_simple_binary = rule(
+    implementation = _simple_binary_impl,
+    executable = True,
+)
+
+def _file_target_impl(ctx):
+    output = ctx.actions.declare_file(ctx.label.name + ".txt")
+    ctx.actions.write(output, "")
+    return [DefaultInfo(files = depset([output]))]
+
+_file_target = rule(
+    implementation = _file_target_impl,
+)
+
+def _create_py_runtime_info_with_interpreter_files_to_run_impl(ctx):
+    files_to_run = ctx.attr.files_to_run[DefaultInfo].files_to_run
+    kwargs = dict(
+        bootstrap_template = ctx.file.bootstrap_template,
+        interpreter_files_to_run = files_to_run,
+        python_version = "PY3",
+    )
+    if ctx.attr.use_interpreter_path:
+        kwargs["interpreter_path"] = "/python"
+    else:
+        kwargs["files"] = depset()
+        kwargs["interpreter"] = ctx.executable.interpreter
+
+    return [PyRuntimeInfo(**kwargs)]
+
+_create_py_runtime_info_with_interpreter_files_to_run = rule(
+    implementation = _create_py_runtime_info_with_interpreter_files_to_run_impl,
+    attrs = {
+        "bootstrap_template": attr.label(allow_single_file = True, default = "bootstrap.txt"),
+        "files_to_run": attr.label(mandatory = True),
+        "interpreter": attr.label(
+            cfg = "target",
+            executable = True,
+            mandatory = True,
+        ),
+        "use_interpreter_path": attr.bool(),
+    },
+)
+
 _tests = []
 
 def _test_can_create_py_runtime_info_without_interpreter_version_info(name):
@@ -53,6 +106,112 @@
 
 _tests.append(_test_can_create_py_runtime_info_without_interpreter_version_info)
 
+def _test_interpreter_files_to_run_with_interpreter(name):
+    _simple_binary(
+        name = name + "_interpreter",
+    )
+    _create_py_runtime_info_with_interpreter_files_to_run(
+        name = name + "_subject",
+        files_to_run = name + "_interpreter",
+        interpreter = name + "_interpreter",
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_interpreter_files_to_run_with_interpreter_impl,
+    )
+
+def _test_interpreter_files_to_run_with_interpreter_impl(env, target):
+    info = env.expect.that_target(target).provider(
+        PyRuntimeInfo,
+        factory = py_runtime_info_subject,
+    )
+    info.interpreter().short_path_equals("{package}/{test_name}_interpreter")
+    info.interpreter_files_to_run().executable().short_path_equals(
+        "{package}/{test_name}_interpreter",
+    )
+
+_tests.append(_test_interpreter_files_to_run_with_interpreter)
+
+def _test_interpreter_files_to_run_disallows_interpreter_path(name):
+    _simple_binary(
+        name = name + "_interpreter",
+    )
+    _create_py_runtime_info_with_interpreter_files_to_run(
+        name = name + "_subject",
+        files_to_run = name + "_interpreter",
+        interpreter = name + "_interpreter",
+        tags = ["manual"],
+        use_interpreter_path = True,
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_interpreter_files_to_run_disallows_interpreter_path_impl,
+        expect_failure = True,
+    )
+
+def _test_interpreter_files_to_run_disallows_interpreter_path_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("*interpreter_files_to_run*interpreter_path*"),
+    )
+
+_tests.append(_test_interpreter_files_to_run_disallows_interpreter_path)
+
+def _test_interpreter_files_to_run_requires_executable(name):
+    _simple_binary(
+        name = name + "_interpreter",
+    )
+    _file_target(
+        name = name + "_files_to_run",
+    )
+    _create_py_runtime_info_with_interpreter_files_to_run(
+        name = name + "_subject",
+        files_to_run = name + "_files_to_run",
+        interpreter = name + "_interpreter",
+        tags = ["manual"],
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_interpreter_files_to_run_requires_executable_impl,
+        expect_failure = True,
+    )
+
+def _test_interpreter_files_to_run_requires_executable_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("*interpreter_files_to_run*executable*"),
+    )
+
+_tests.append(_test_interpreter_files_to_run_requires_executable)
+
+def _test_interpreter_files_to_run_requires_matching_interpreter(name):
+    _simple_binary(
+        name = name + "_interpreter",
+    )
+    _simple_binary(
+        name = name + "_other_interpreter",
+    )
+    _create_py_runtime_info_with_interpreter_files_to_run(
+        name = name + "_subject",
+        files_to_run = name + "_other_interpreter",
+        interpreter = name + "_interpreter",
+        tags = ["manual"],
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_interpreter_files_to_run_requires_matching_interpreter_impl,
+        expect_failure = True,
+    )
+
+def _test_interpreter_files_to_run_requires_matching_interpreter_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("*interpreter_files_to_run.executable*interpreter*"),
+    )
+
+_tests.append(_test_interpreter_files_to_run_requires_matching_interpreter)
+
 def py_runtime_info_test_suite(name):
     test_suite(
         name = name,
diff --git a/tests/support/py_runtime_info_subject.bzl b/tests/support/py_runtime_info_subject.bzl
index 541d4d9..0c6c672 100644
--- a/tests/support/py_runtime_info_subject.bzl
+++ b/tests/support/py_runtime_info_subject.bzl
@@ -37,6 +37,9 @@
         coverage_tool = lambda *a, **k: _py_runtime_info_subject_coverage_tool(self, *a, **k),
         files = lambda *a, **k: _py_runtime_info_subject_files(self, *a, **k),
         interpreter = lambda *a, **k: _py_runtime_info_subject_interpreter(self, *a, **k),
+        interpreter_files_to_run = lambda *a, **k: (
+            _py_runtime_info_subject_interpreter_files_to_run(self, *a, **k)
+        ),
         interpreter_path = lambda *a, **k: _py_runtime_info_subject_interpreter_path(self, *a, **k),
         interpreter_version_info = lambda *a, **k: _py_runtime_info_subject_interpreter_version_info(self, *a, **k),
         python_version = lambda *a, **k: _py_runtime_info_subject_python_version(self, *a, **k),
@@ -84,6 +87,15 @@
         meta = self.meta.derive("interpreter()"),
     )
 
+def _py_runtime_info_subject_interpreter_files_to_run(self):
+    return subjects.struct(
+        self.actual.interpreter_files_to_run,
+        attrs = dict(
+            executable = subjects.file,
+        ),
+        meta = self.meta.derive("interpreter_files_to_run()"),
+    )
+
 def _py_runtime_info_subject_interpreter_path(self):
     return subjects.str(
         self.actual.interpreter_path,