fix: allow disabling exec_tools toolchain from looking up an interpreter (#2194)

This allow the exec_interpreter attribute to propagate None even though
it's a label
with a default value. Such attributes can't _directly_ be set to None
because None
means to use the default. To work around that, a sentinel target is
used, which allows
breaking the dependency on the default and triggering the rule to use
None instead.

A null exec interpreter is necessary in environments that don't provide
a separate Python
interpreter.
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 252606c..7b913df 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -18,6 +18,7 @@
 load("//python:py_library.bzl", "py_library")
 load("//python:versions.bzl", "print_toolchains_checksums")
 load(":py_exec_tools_toolchain.bzl", "current_interpreter_executable")
+load(":sentinel.bzl", "sentinel")
 load(":stamp.bzl", "stamp_build_setting")
 
 package(
@@ -213,6 +214,7 @@
     srcs = ["py_exec_tools_toolchain.bzl"],
     deps = [
         ":py_exec_tools_info_bzl",
+        ":sentinel_bzl",
         ":toolchain_types_bzl",
         "//python/private/common:providers_bzl",
         "@bazel_skylib//lib:paths",
@@ -295,6 +297,11 @@
 )
 
 bzl_library(
+    name = "sentinel_bzl",
+    srcs = ["sentinel.bzl"],
+)
+
+bzl_library(
     name = "stamp_bzl",
     srcs = ["stamp.bzl"],
     visibility = ["//:__subpackages__"],
@@ -469,3 +476,7 @@
     # py_exec_tools_toolchain.
     visibility = ["//visibility:public"],
 )
+
+sentinel(
+    name = "sentinel",
+)
diff --git a/python/private/py_exec_tools_info.bzl b/python/private/py_exec_tools_info.bzl
index 6fa4011..b74f480 100644
--- a/python/private/py_exec_tools_info.bzl
+++ b/python/private/py_exec_tools_info.bzl
@@ -17,7 +17,9 @@
     doc = "Build tools used as part of building Python programs.",
     fields = {
         "exec_interpreter": """
-Optional Target; an interpreter valid for running in the exec configuration.
+:type: Target | None
+
+If available, an interpreter valid for running in the exec configuration.
 When running it in an action, use `DefaultInfo.files_to_run` to ensure all its
 files are appropriately available. An exec interpreter may not be available,
 e.g. if all the exec tools are prebuilt binaries.
@@ -33,7 +35,9 @@
 the toolchain.
 """,
         "precompiler": """
-Optional Target. The tool to use for generating pyc files. If not available,
+:type: Target | None
+
+If available, the tool to use for generating pyc files. If not available,
 precompiling will not be available.
 
 Must provide one of the following:
diff --git a/python/private/py_exec_tools_toolchain.bzl b/python/private/py_exec_tools_toolchain.bzl
index a4516d8..26c09ca 100644
--- a/python/private/py_exec_tools_toolchain.bzl
+++ b/python/private/py_exec_tools_toolchain.bzl
@@ -16,6 +16,7 @@
 
 load("@bazel_skylib//lib:paths.bzl", "paths")
 load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
+load("//python/private:sentinel.bzl", "SentinelInfo")
 load("//python/private:toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE")
 load(":py_exec_tools_info.bzl", "PyExecToolsInfo")
 
@@ -24,9 +25,13 @@
     if ctx.attr._visible_for_testing[BuildSettingInfo].value:
         extra_kwargs["toolchain_label"] = ctx.label
 
+    exec_interpreter = ctx.attr.exec_interpreter
+    if SentinelInfo in ctx.attr.exec_interpreter:
+        exec_interpreter = None
+
     return [platform_common.ToolchainInfo(
         exec_tools = PyExecToolsInfo(
-            exec_interpreter = ctx.attr.exec_interpreter,
+            exec_interpreter = exec_interpreter,
             precompiler = ctx.attr.precompiler,
         ),
         **extra_kwargs
@@ -38,7 +43,11 @@
         "exec_interpreter": attr.label(
             default = "//python/private:current_interpreter_executable",
             cfg = "exec",
-            doc = "See PyexecToolsInfo.exec_interpreter.",
+            doc = """
+The interpreter to use in the exec config. To disable, specify the
+special target `//python/private:sentinel`. See PyExecToolsInfo.exec_interpreter
+for further docs.
+""",
         ),
         "precompiler": attr.label(
             allow_files = True,
diff --git a/python/private/sentinel.bzl b/python/private/sentinel.bzl
new file mode 100644
index 0000000..6d753e1
--- /dev/null
+++ b/python/private/sentinel.bzl
@@ -0,0 +1,30 @@
+# 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.
+
+"""A rule to define a target to act as a singleton for label attributes.
+
+Label attributes with defaults cannot accept None, otherwise they fall
+back to using the default. A sentinel allows detecting an intended None value.
+"""
+
+SentinelInfo = provider(
+    doc = "Indicates this was the sentinel target.",
+    fields = [],
+)
+
+def _sentinel_impl(ctx):
+    _ = ctx  # @unused
+    return [SentinelInfo()]
+
+sentinel = rule(implementation = _sentinel_impl)
diff --git a/tests/py_exec_tools_toolchain/BUILD.bazel b/tests/py_exec_tools_toolchain/BUILD.bazel
new file mode 100644
index 0000000..092e790
--- /dev/null
+++ b/tests/py_exec_tools_toolchain/BUILD.bazel
@@ -0,0 +1,19 @@
+# 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(":py_exec_tools_toolchain_tests.bzl", "py_exec_tools_toolchain_test_suite")
+
+py_exec_tools_toolchain_test_suite(
+    name = "py_exec_tools_toolchain_tests",
+)
diff --git a/tests/py_exec_tools_toolchain/py_exec_tools_toolchain_tests.bzl b/tests/py_exec_tools_toolchain/py_exec_tools_toolchain_tests.bzl
new file mode 100644
index 0000000..3be2bc3
--- /dev/null
+++ b/tests/py_exec_tools_toolchain/py_exec_tools_toolchain_tests.bzl
@@ -0,0 +1,40 @@
+# 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.
+"""Starlark tests for py_exec_tools_toolchain rule."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("//python/private:py_exec_tools_toolchain.bzl", "py_exec_tools_toolchain")  # buildifier: disable=bzl-visibility
+
+_tests = []
+
+def _test_disable_exec_interpreter(name):
+    py_exec_tools_toolchain(
+        name = name + "_subject",
+        exec_interpreter = "//python/private:sentinel",
+    )
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = _test_disable_exec_interpreter_impl,
+    )
+
+def _test_disable_exec_interpreter_impl(env, target):
+    exec_tools = target[platform_common.ToolchainInfo].exec_tools
+    env.expect.that_bool(exec_tools.exec_interpreter == None).equals(True)
+
+_tests.append(_test_disable_exec_interpreter)
+
+def py_exec_tools_toolchain_test_suite(name):
+    test_suite(name = name, tests = _tests)