feat(rules): add PyExecutableInfo (#2166)

The PyExecutableInfo provider exposes executable-specific information
that isn't easily accessible outside the target. The main purpose of
this provider is to facilitate packaging a binary or deriving a new
binary based upon the original.

Within rules_python, this will be used to pull the zip-building logic
out of executables and into separate rules. Within Google, this will be
used for a similar "package a binary" tool.

Along the way:
* Add runfiles references to sphinx inventory
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bca643f..dcd8576 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,9 @@
 * (gazelle) Correctly resolve deps that have top-level module overlap with a gazelle_python.yaml dep module
 
 ### Added
+* (rules) Executables provide {obj}`PyExecutableInfo`, which contains
+  executable-specific information useful for packaging an executable or
+  or deriving a new one from the original.
 * (py_wheel) Removed use of bash to avoid failures on Windows machines which do not
   have it installed.
 
diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel
index 4210876..f7f226a 100644
--- a/docs/BUILD.bazel
+++ b/docs/BUILD.bazel
@@ -84,6 +84,7 @@
         "//python:pip_bzl",
         "//python:py_binary_bzl",
         "//python:py_cc_link_params_info_bzl",
+        "//python:py_executable_info_bzl",
         "//python:py_library_bzl",
         "//python:py_runtime_bzl",
         "//python:py_runtime_info_bzl",
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index 878d20b..40880a1 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -140,6 +140,12 @@
 )
 
 bzl_library(
+    name = "py_executable_info_bzl",
+    srcs = ["py_executable_info.bzl"],
+    deps = ["//python/private:py_executable_info_bzl"],
+)
+
+bzl_library(
     name = "py_import_bzl",
     srcs = ["py_import.bzl"],
     deps = [":py_info_bzl"],
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 7362a4c..8ddcc09 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -221,6 +221,11 @@
 )
 
 bzl_library(
+    name = "py_executable_info_bzl",
+    srcs = ["py_executable_info.bzl"],
+)
+
+bzl_library(
     name = "py_interpreter_program_bzl",
     srcs = ["py_interpreter_program.bzl"],
     deps = ["@bazel_skylib//rules:common_settings"],
diff --git a/python/private/common/BUILD.bazel b/python/private/common/BUILD.bazel
index a415e05..805c002 100644
--- a/python/private/common/BUILD.bazel
+++ b/python/private/common/BUILD.bazel
@@ -132,9 +132,11 @@
         ":providers_bzl",
         ":py_internal_bzl",
         "//python/private:flags_bzl",
+        "//python/private:py_executable_info_bzl",
         "//python/private:rules_cc_srcs_bzl",
         "//python/private:toolchain_types_bzl",
         "@bazel_skylib//lib:dicts",
+        "@bazel_skylib//lib:structs",
         "@bazel_skylib//rules:common_settings",
     ],
 )
diff --git a/python/private/common/py_executable.bzl b/python/private/common/py_executable.bzl
index 9b8c77c..1437e2e 100644
--- a/python/private/common/py_executable.bzl
+++ b/python/private/common/py_executable.bzl
@@ -14,9 +14,11 @@
 """Common functionality between test/binary executables."""
 
 load("@bazel_skylib//lib:dicts.bzl", "dicts")
+load("@bazel_skylib//lib:structs.bzl", "structs")
 load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
 load("@rules_cc//cc:defs.bzl", "cc_common")
 load("//python/private:flags.bzl", "PrecompileAddToRunfilesFlag")
+load("//python/private:py_executable_info.bzl", "PyExecutableInfo")
 load("//python/private:reexports.bzl", "BuiltinPyRuntimeInfo")
 load(
     "//python/private:toolchain_types.bzl",
@@ -221,10 +223,14 @@
     extra_exec_runfiles = exec_result.extra_runfiles.merge(
         ctx.runfiles(transitive_files = exec_result.extra_files_to_build),
     )
-    runfiles_details = struct(
-        default_runfiles = runfiles_details.default_runfiles.merge(extra_exec_runfiles),
-        data_runfiles = runfiles_details.data_runfiles.merge(extra_exec_runfiles),
-    )
+
+    # Copy any existing fields in case of company patches.
+    runfiles_details = struct(**(
+        structs.to_dict(runfiles_details) | dict(
+            default_runfiles = runfiles_details.default_runfiles.merge(extra_exec_runfiles),
+            data_runfiles = runfiles_details.data_runfiles.merge(extra_exec_runfiles),
+        )
+    ))
 
     return _create_providers(
         ctx = ctx,
@@ -400,8 +406,8 @@
         semantics):
     """Returns the set of runfiles necessary prior to executable creation.
 
-    NOTE: The term "common runfiles" refers to the runfiles that both the
-    default and data runfiles have in common.
+    NOTE: The term "common runfiles" refers to the runfiles that are common to
+        runfiles_without_exe, default_runfiles, and data_runfiles.
 
     Args:
         ctx: The rule ctx.
@@ -418,6 +424,8 @@
         struct with attributes:
         * default_runfiles: The default runfiles
         * data_runfiles: The data runfiles
+        * runfiles_without_exe: The default runfiles, but without the executable
+          or files specific to the original program/executable.
     """
     common_runfiles_depsets = [main_py_files]
 
@@ -431,7 +439,6 @@
             common_runfiles_depsets.append(dep[PyInfo].transitive_pyc_files)
 
     common_runfiles = collect_runfiles(ctx, depset(
-        direct = [executable],
         transitive = common_runfiles_depsets,
     ))
     if extra_deps:
@@ -447,22 +454,27 @@
             runfiles = common_runfiles,
         )
 
+    # Don't include build_data.txt in the non-exe runfiles. The build data
+    # may contain program-specific content (e.g. target name).
+    runfiles_with_exe = common_runfiles.merge(ctx.runfiles([executable]))
+
     # Don't include build_data.txt in data runfiles. This allows binaries to
     # contain other binaries while still using the same fixed location symlink
     # for the build_data.txt file. Really, the fixed location symlink should be
     # removed and another way found to locate the underlying build data file.
-    data_runfiles = common_runfiles
+    data_runfiles = runfiles_with_exe
 
     if is_stamping_enabled(ctx, semantics) and semantics.should_include_build_data(ctx):
-        default_runfiles = common_runfiles.merge(_create_runfiles_with_build_data(
+        default_runfiles = runfiles_with_exe.merge(_create_runfiles_with_build_data(
             ctx,
             semantics.get_central_uncachable_version_file(ctx),
             semantics.get_extra_write_build_data_env(ctx),
         ))
     else:
-        default_runfiles = common_runfiles
+        default_runfiles = runfiles_with_exe
 
     return struct(
+        runfiles_without_exe = common_runfiles,
         default_runfiles = default_runfiles,
         data_runfiles = data_runfiles,
     )
@@ -814,6 +826,11 @@
         ),
         create_instrumented_files_info(ctx),
         _create_run_environment_info(ctx, inherited_environment),
+        PyExecutableInfo(
+            main = main_py,
+            runfiles_without_exe = runfiles_details.runfiles_without_exe,
+            interpreter_path = runtime_details.executable_interpreter_path,
+        ),
     ]
 
     # TODO(b/265840007): Make this non-conditional once Google enables
@@ -904,6 +921,7 @@
     if "py" not in fragments:
         # The list might be frozen, so use concatentation
         fragments = fragments + ["py"]
+    kwargs.setdefault("provides", []).append(PyExecutableInfo)
     return rule(
         # TODO: add ability to remove attrs, i.e. for imports attr
         attrs = dicts.add(EXECUTABLE_ATTRS, attrs),
diff --git a/python/private/py_executable_info.bzl b/python/private/py_executable_info.bzl
new file mode 100644
index 0000000..7fa2f18
--- /dev/null
+++ b/python/private/py_executable_info.bzl
@@ -0,0 +1,35 @@
+"""Implementation of PyExecutableInfo provider."""
+
+PyExecutableInfo = provider(
+    doc = """
+Information about an executable.
+
+This provider is for executable-specific information (e.g. tests and binaries).
+
+:::{versionadded} 0.36.0
+:::
+""",
+    fields = {
+        "interpreter_path": """
+:type: None | str
+
+Path to the Python interpreter to use for running the executable itself (not the
+bootstrap script). Either an absolute path (which means it is
+platform-specific), or a runfiles-relative path (which means the interpreter
+should be within `runtime_files`)
+""",
+        "main": """
+:type: File
+
+The user-level entry point file. Usually a `.py` file, but may also be `.pyc`
+file if precompiling is enabled.
+""",
+        "runfiles_without_exe": """
+:type: runfiles
+
+The runfiles the program needs, but without the original executable,
+files only added to support the original executable, or files specific to the
+original program.
+""",
+    },
+)
diff --git a/python/py_executable_info.bzl b/python/py_executable_info.bzl
new file mode 100644
index 0000000..59c0bb2
--- /dev/null
+++ b/python/py_executable_info.bzl
@@ -0,0 +1,12 @@
+"""Provider for executable-specific information.
+
+The `PyExecutableInfo` provider contains information about an executable that
+isn't otherwise available from its public attributes or other providers.
+
+It exposes information primarily useful for consumers to package the executable,
+or derive a new executable from the base binary.
+"""
+
+load("//python/private:py_executable_info.bzl", _PyExecutableInfo = "PyExecutableInfo")
+
+PyExecutableInfo = _PyExecutableInfo
diff --git a/sphinxdocs/inventories/bazel_inventory.txt b/sphinxdocs/inventories/bazel_inventory.txt
index caf5866..0daafb4 100644
--- a/sphinxdocs/inventories/bazel_inventory.txt
+++ b/sphinxdocs/inventories/bazel_inventory.txt
@@ -65,6 +65,13 @@
 native.package_relative_label bzl:function 1 rules/lib/toplevel/native#package_relative_label -
 native.repo_name bzl:function 1 rules/lib/toplevel/native#repo_name -
 native.repository_name bzl:function 1 rules/lib/toplevel/native#repository_name -
+runfiles bzl:type 1 rules/lib/builtins/runfiles -
+runfiles.empty_filenames bzl:type 1 rules/lib/builtins/runfiles#empty_filenames -
+runfiles.files bzl:type 1 rules/lib/builtins/runfiles#files -
+runfiles.merge bzl:type 1 rules/lib/builtins/runfiles#merge -
+runfiles.merge_all bzl:type 1 rules/lib/builtins/runfiles#merge_all -
+runfiles.root_symlinks bzl:type 1 rules/lib/builtins/runfiles#root_symlinks -
+runfiles.symlinks bzl:type 1 rules/lib/builtins/runfiles#symlinks -
 str bzl:type 1 rules/lib/string -
 struct bzl:type 1 rules/lib/builtins/struct -
 toolchain_type bzl:type 1 ules/lib/builtins/toolchain_type.html -
diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl
index eb1a1b6..1f805cb 100644
--- a/tests/base_rules/py_executable_base_tests.bzl
+++ b/tests/base_rules/py_executable_base_tests.bzl
@@ -18,9 +18,11 @@
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
 load("@rules_testing//lib:truth.bzl", "matching")
 load("@rules_testing//lib:util.bzl", rt_util = "util")
+load("//python:py_executable_info.bzl", "PyExecutableInfo")
 load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER")  # buildifier: disable=bzl-visibility
 load("//tests/base_rules:base_tests.bzl", "create_base_tests")
 load("//tests/base_rules:util.bzl", "WINDOWS_ATTR", pt_util = "util")
+load("//tests/support:py_executable_info_subject.bzl", "PyExecutableInfoSubject")
 load("//tests/support:support.bzl", "LINUX_X86_64", "WINDOWS_X86_64")
 
 _BuiltinPyRuntimeInfo = PyRuntimeInfo
@@ -132,11 +134,19 @@
         exe = ".exe"
     else:
         exe = ""
-
     env.expect.that_target(target).runfiles().contains_at_least([
         "{workspace}/{package}/{test_name}_subject" + exe,
     ])
 
+    if rp_config.enable_pystar:
+        py_exec_info = env.expect.that_target(target).provider(PyExecutableInfo, factory = PyExecutableInfoSubject.new)
+        py_exec_info.main().path().contains("_subject.py")
+        py_exec_info.interpreter_path().contains("python")
+        py_exec_info.runfiles_without_exe().contains_none_of([
+            "{workspace}/{package}/{test_name}_subject" + exe,
+            "{workspace}/{package}/{test_name}_subject",
+        ])
+
 def _test_default_main_can_be_generated(name, config):
     rt_util.helper_target(
         config.rule,
diff --git a/tests/support/py_executable_info_subject.bzl b/tests/support/py_executable_info_subject.bzl
new file mode 100644
index 0000000..97216ec
--- /dev/null
+++ b/tests/support/py_executable_info_subject.bzl
@@ -0,0 +1,70 @@
+# 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.
+"""PyExecutableInfo testing subject."""
+
+load("@rules_testing//lib:truth.bzl", "subjects")
+
+def _py_executable_info_subject_new(info, *, meta):
+    """Creates a new `PyExecutableInfoSubject` for a PyExecutableInfo provider instance.
+
+    Method: PyExecutableInfoSubject.new
+
+    Args:
+        info: The PyExecutableInfo object
+        meta: ExpectMeta object.
+
+    Returns:
+        A `PyExecutableInfoSubject` struct
+    """
+
+    # buildifier: disable=uninitialized
+    public = struct(
+        # go/keep-sorted start
+        actual = info,
+        interpreter_path = lambda *a, **k: _py_executable_info_subject_interpreter_path(self, *a, **k),
+        main = lambda *a, **k: _py_executable_info_subject_main(self, *a, **k),
+        runfiles_without_exe = lambda *a, **k: _py_executable_info_subject_runfiles_without_exe(self, *a, **k),
+        # go/keep-sorted end
+    )
+    self = struct(
+        actual = info,
+        meta = meta,
+    )
+    return public
+
+def _py_executable_info_subject_interpreter_path(self):
+    """Returns a subject for `PyExecutableInfo.interpreter_path`."""
+    return subjects.str(
+        self.actual.interpreter_path,
+        meta = self.meta.derive("interpreter_path()"),
+    )
+
+def _py_executable_info_subject_main(self):
+    """Returns a subject for `PyExecutableInfo.main`."""
+    return subjects.file(
+        self.actual.main,
+        meta = self.meta.derive("main()"),
+    )
+
+def _py_executable_info_subject_runfiles_without_exe(self):
+    """Returns a subject for `PyExecutableInfo.runfiles_without_exe`."""
+    return subjects.runfiles(
+        self.actual.runfiles_without_exe,
+        meta = self.meta.derive("runfiles_without_exe()"),
+    )
+
+# buildifier: disable=name-conventions
+PyExecutableInfoSubject = struct(
+    new = _py_executable_info_subject_new,
+)