Introduce --incompatible_use_python_toolchains

This renames --experimental_use_python_toolchains to --incompatible. It also adds the behavior to the flag that

  1) py_runtime's python_version attribute becomes manadatory, and

  2) the --python_top flag is disallowed (it is superseded by toolchains), as well as --python2_path and --python3_path (they were already no-ops). --python_path is strongly discouraged but not yet banned due to an existing use on Windows (#7901).

Feature issue is #7375, incompatible change migration issue is #7899.

RELNOTES[INC]: Introduced --incompatible_use_python_toolchains, which supersedes --python_top/--python_path. See #7899 and #7375 for more information.

PiperOrigin-RevId: 241134532
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfiguration.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfiguration.java
index d19e56b..fb5caf6 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfiguration.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfiguration.java
@@ -24,9 +24,10 @@
 import com.google.devtools.build.lib.analysis.config.InvalidConfigurationException;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
-import com.google.devtools.build.lib.util.OS;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.rules.python.PythonOptions;
 import com.google.devtools.build.lib.vfs.PathFragment;
-import com.google.devtools.common.options.Converter;
 import com.google.devtools.common.options.Option;
 import com.google.devtools.common.options.OptionDocumentationCategory;
 import com.google.devtools.common.options.OptionEffectTag;
@@ -35,77 +36,61 @@
 /** Bazel-specific Python configuration. */
 @Immutable
 public class BazelPythonConfiguration extends BuildConfiguration.Fragment {
-  /**
-  * A path converter for python3 path
-  */
-  public static class Python3PathConverter implements Converter<String> {
-    @Override
-    public String convert(String input) {
-      if (input.equals("auto")) {
-        return OS.getCurrent() == OS.WINDOWS ? "python" : "python3";
-      }
-      return input;
-    }
-
-    @Override
-    public String getTypeDescription() {
-      return "An option for python3 path";
-    }
-  }
 
   /** Bazel-specific Python configuration options. */
   public static final class Options extends FragmentOptions {
     @Option(
-      name = "python2_path",
-      defaultValue = "python",
-      documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
-      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS, OptionEffectTag.AFFECTS_OUTPUTS},
-      metadataTags = {OptionMetadataTag.DEPRECATED},
-      help =
-          "Local path to the Python2 executable. "
-              + "Deprecated, please use python_path or python_top instead."
-    )
+        name = "python2_path",
+        defaultValue = "null",
+        documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
+        effectTags = {OptionEffectTag.NO_OP},
+        metadataTags = {OptionMetadataTag.DEPRECATED},
+        help = "Deprecated, no-op. Disabled by `--incompatible_use_python_toolchains`.")
     public String python2Path;
 
     @Option(
-      name = "python3_path",
-      converter = Python3PathConverter.class,
-      defaultValue = "auto",
-      documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
-      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS, OptionEffectTag.AFFECTS_OUTPUTS},
-      metadataTags = {OptionMetadataTag.DEPRECATED},
-      help =
-          "Local path to the Python3 executable. "
-              + "Deprecated, please use python_path or python_top instead."
-    )
+        name = "python3_path",
+        defaultValue = "null",
+        documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
+        effectTags = {OptionEffectTag.NO_OP},
+        metadataTags = {OptionMetadataTag.DEPRECATED},
+        help = "Deprecated, no-op. Disabled by `--incompatible_use_python_toolchains`.")
     public String python3Path;
 
     @Option(
-      name = "python_top",
-      converter = LabelConverter.class,
-      defaultValue = "null",
-      documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
-      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS, OptionEffectTag.AFFECTS_OUTPUTS},
-      help = "The label of py_runtime rule used for the Python interpreter invoked by Bazel."
-    )
+        name = "python_top",
+        converter = LabelConverter.class,
+        defaultValue = "null",
+        documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
+        effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS, OptionEffectTag.AFFECTS_OUTPUTS},
+        help =
+            "The label of a py_runtime representing the Python interpreter invoked to run Python "
+                + "targets on the target platform. Deprecated; disabled by "
+                + "--incompatible_use_python_toolchains.")
     public Label pythonTop;
 
     @Option(
-      name = "python_path",
-      defaultValue = "python",
-      documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
-      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS, OptionEffectTag.AFFECTS_OUTPUTS},
-      help = "The absolute path of the Python interpreter invoked by Bazel."
-    )
+        name = "python_path",
+        defaultValue = "null",
+        documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
+        effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS, OptionEffectTag.AFFECTS_OUTPUTS},
+        help =
+            "The absolute path of the Python interpreter invoked to run Python targets on the "
+                + "target platform. Deprecated; disabled by --incompatible_use_python_toolchains.")
     public String pythonPath;
 
     @Option(
-      name = "experimental_python_import_all_repositories",
-      defaultValue = "true",
-      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
-      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS},
-      help = "Do not use."
-    )
+        name = "experimental_python_import_all_repositories",
+        defaultValue = "true",
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS},
+        help =
+            "If true, the roots of repositories in the runfiles tree are added to PYTHONPATH, so "
+                + "that imports like `import mytoplevelpackage.package.module` are valid."
+                + " Regardless of whether this flag is true, the runfiles root itself is also"
+                + " added to the PYTHONPATH, so "
+                + "`import myreponame.mytoplevelpackage.package.module` is valid. The latter form "
+                + "is less likely to experience import name collisions.")
     public boolean experimentalPythonImportAllRepositories;
 
      /**
@@ -143,7 +128,7 @@
 
     @Override
     public ImmutableSet<Class<? extends FragmentOptions>> requiredOptions() {
-      return ImmutableSet.<Class<? extends FragmentOptions>>of(Options.class);
+      return ImmutableSet.of(Options.class, PythonOptions.class);
     }
   }
 
@@ -153,12 +138,35 @@
     this.options = options;
   }
 
-  public String getPython2Path() {
-    return options.python2Path;
-  }
-
-  public String getPython3Path() {
-    return options.python3Path;
+  @Override
+  public void reportInvalidOptions(EventHandler reporter, BuildOptions buildOptions) {
+    PythonOptions pythonOpts = buildOptions.get(PythonOptions.class);
+    Options opts = buildOptions.get(Options.class);
+    if (pythonOpts.incompatibleUsePythonToolchains) {
+      // Forbid deprecated flags.
+      if (opts.python2Path != null) {
+        reporter.handle(
+            Event.error(
+                "`--python2_path` is disabled by `--incompatible_use_python_toolchains`. Since "
+                    + "`--python2_path` is a deprecated no-op, there is no need to pass it."));
+      }
+      if (opts.python3Path != null) {
+        reporter.handle(
+            Event.error(
+                "`--python3_path` is disabled by `--incompatible_use_python_toolchains`. Since "
+                    + "`--python3_path` is a deprecated no-op, there is no need to pass it."));
+      }
+      if (opts.pythonTop != null) {
+        reporter.handle(
+            Event.error(
+                "`--python_top` is disabled by `--incompatible_use_python_toolchains`. Instead of "
+                    + "configuring the Python runtime directly, register a Python toolchain. See "
+                    + "https://github.com/bazelbuild/bazel/issues/7899. You can temporarily revert "
+                    + "to the legacy flag-based way of specifying toolchains by setting "
+                    + "`--incompatible_use_python_toolchains=false`."));
+      }
+      // TODO(#7901): Also prohibit --python_path here.
+    }
   }
 
   public Label getPythonTop() {
@@ -166,7 +174,7 @@
   }
 
   public String getPythonPath() {
-    return options.pythonPath;
+    return options.pythonPath == null ? "python" : options.pythonPath;
   }
 
   public boolean getImportAllRepositories() {
diff --git a/src/main/java/com/google/devtools/build/lib/rules/python/PyRuntime.java b/src/main/java/com/google/devtools/build/lib/rules/python/PyRuntime.java
index 4459f39..3b145b6 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/python/PyRuntime.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/python/PyRuntime.java
@@ -33,6 +33,8 @@
 
   @Override
   public ConfiguredTarget create(RuleContext ruleContext) throws ActionConflictException {
+    PythonConfiguration pyConfig = ruleContext.getFragment(PythonConfiguration.class);
+
     NestedSet<Artifact> files =
         PrerequisiteArtifacts.nestedSet(ruleContext, "files", Mode.TARGET);
     Artifact interpreter = ruleContext.getPrerequisiteArtifact("interpreter", Mode.TARGET);
@@ -58,15 +60,24 @@
     }
 
     if (pythonVersion == PythonVersion._INTERNAL_SENTINEL) {
-      // Use the same default as py_binary/py_test would use for their python_version attribute.
-      // (Of course, in our case there's no configuration transition involved.)
-      pythonVersion = ruleContext.getFragment(PythonConfiguration.class).getDefaultPythonVersion();
+      if (pyConfig.useToolchains()) {
+        ruleContext.attributeError(
+            "python_version",
+            "When using Python toolchains, this attribute must be set explicitly to either 'PY2' "
+                + "or 'PY3'. See https://github.com/bazelbuild/bazel/issues/7899 for more "
+                + "information. You can temporarily avoid this error by reverting to the legacy "
+                + "Python runtime mechanism (`--incompatible_use_python_toolchains=false`).");
+      } else {
+        // Use the same default as py_binary/py_test would use for their python_version attribute.
+        // (Of course, in our case there's no configuration transition involved.)
+        pythonVersion = pyConfig.getDefaultPythonVersion();
+      }
     }
-    Preconditions.checkState(pythonVersion.isTargetValue());
 
     if (ruleContext.hasErrors()) {
       return null;
     }
+    Preconditions.checkState(pythonVersion.isTargetValue());
 
     PyRuntimeInfo provider =
         hermetic
diff --git a/src/main/java/com/google/devtools/build/lib/rules/python/PythonOptions.java b/src/main/java/com/google/devtools/build/lib/rules/python/PythonOptions.java
index 0468469..fa99438 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/python/PythonOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/python/PythonOptions.java
@@ -230,11 +230,14 @@
   public boolean incompatibleDisallowLegacyPyProvider;
 
   @Option(
-      // TODO(brandjon): Rename to --incompatible_use_python_toolchains when ready to make available
-      name = "experimental_use_python_toolchains",
+      name = "incompatible_use_python_toolchains",
       defaultValue = "false",
       documentationCategory = OptionDocumentationCategory.GENERIC_INPUTS,
       effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS},
+      metadataTags = {
+        OptionMetadataTag.INCOMPATIBLE_CHANGE,
+        OptionMetadataTag.TRIGGERED_BY_ALL_INCOMPATIBLE_CHANGES
+      },
       help =
           "If set to true, executable native Python rules will use the Python runtime specified by "
               + "the Python toolchain, rather than the runtime given by legacy flags like "
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPyBinaryConfiguredTargetTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPyBinaryConfiguredTargetTest.java
index 4e96207..a878000 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPyBinaryConfiguredTargetTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPyBinaryConfiguredTargetTest.java
@@ -94,7 +94,7 @@
         ")");
     String pythonTop =
         analysisMock.pySupport().createPythonTopEntryPoint(mockToolsConfig, "//pkg:my_py_runtime");
-    useConfiguration("--experimental_use_python_toolchains=false", "--python_top=" + pythonTop);
+    useConfiguration("--incompatible_use_python_toolchains=false", "--python_top=" + pythonTop);
     String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
     assertThat(path).isEqualTo("/system/python2");
   }
@@ -107,7 +107,7 @@
         "    name = 'pybin',",
         "    srcs = ['pybin.py'],",
         ")");
-    useConfiguration("--experimental_use_python_toolchains=false", "--python_path=/system/python2");
+    useConfiguration("--incompatible_use_python_toolchains=false", "--python_path=/system/python2");
     String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
     assertThat(path).isEqualTo("/system/python2");
   }
@@ -120,7 +120,7 @@
         "    name = 'pybin',",
         "    srcs = ['pybin.py'],",
         ")");
-    useConfiguration("--experimental_use_python_toolchains=false");
+    useConfiguration("--incompatible_use_python_toolchains=false");
     String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
     assertThat(path).isEqualTo("python");
   }
@@ -141,7 +141,7 @@
     String pythonTop =
         analysisMock.pySupport().createPythonTopEntryPoint(mockToolsConfig, "//pkg:my_py_runtime");
     useConfiguration(
-        "--experimental_use_python_toolchains=false",
+        "--incompatible_use_python_toolchains=false",
         "--python_top=" + pythonTop,
         "--python_path=/better/not/be/this/one");
     String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
@@ -205,7 +205,7 @@
         "    python_version = 'PY3',",
         ")");
     useConfiguration(
-        "--experimental_use_python_toolchains=true",
+        "--incompatible_use_python_toolchains=true",
         "--extra_toolchains=//toolchains:py_toolchain");
 
     String py2Path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py2_bin"));
@@ -225,7 +225,7 @@
         "    python_version = 'PY2',",
         ")");
     useConfiguration(
-        "--experimental_use_python_toolchains=true",
+        "--incompatible_use_python_toolchains=true",
         "--extra_toolchains=//toolchains:py_toolchain_for_py2_only");
 
     String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py2_bin"));
@@ -237,24 +237,14 @@
     defineToolchains();
     scratch.file(
         "pkg/BUILD",
-        "py_runtime(",
-        "    name = 'dont_use_this_runtime',",
-        "    interpreter_path = '/dont/use/me',",
-        "    python_version = 'PY2',",
-        ")",
         "py_binary(",
         "    name = 'py2_bin',",
         "    srcs = ['py2_bin.py'],",
         "    python_version = 'PY2',",
         ")");
-    String pythonTop =
-        analysisMock
-            .pySupport()
-            .createPythonTopEntryPoint(mockToolsConfig, "//pkg:dont_use_this_runtime");
     useConfiguration(
-        "--experimental_use_python_toolchains=true",
+        "--incompatible_use_python_toolchains=true",
         "--extra_toolchains=//toolchains:py_toolchain",
-        "--python_top=" + pythonTop,
         "--python_path=/better/not/be/this/one");
 
     String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py2_bin"));
@@ -273,7 +263,7 @@
         "    python_version = 'PY3',",
         ")");
     useConfiguration(
-        "--experimental_use_python_toolchains=true",
+        "--incompatible_use_python_toolchains=true",
         "--extra_toolchains=//toolchains:py_toolchain_for_py2_only");
 
     getConfiguredTarget("//pkg:py3_bin");
@@ -324,7 +314,7 @@
         "    python_version = 'PY2',",
         ")");
     useConfiguration(
-        "--experimental_use_python_toolchains=true",
+        "--incompatible_use_python_toolchains=true",
         "--extra_toolchains=//toolchains:custom_toolchain");
     getConfiguredTarget("//pkg:pybin");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfigurationTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfigurationTest.java
index cdb3223..5b5eb2d 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfigurationTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonConfigurationTest.java
@@ -61,4 +61,23 @@
         () -> create("--python_path=pajama"));
     assertThat(expected).hasMessageThat().contains("python_path must be an absolute path");
   }
+
+  @Test
+  public void legacyFlagsDeprecatedByPythonToolchains() throws Exception {
+    checkError(
+        "`--python2_path` is disabled by `--incompatible_use_python_toolchains`",
+        "--incompatible_use_python_toolchains=true",
+        "--python2_path=/system/python2");
+    checkError(
+        "`--python3_path` is disabled by `--incompatible_use_python_toolchains`",
+        "--incompatible_use_python_toolchains=true",
+        "--python3_path=/system/python3");
+    checkError(
+        "`--python_top` is disabled by `--incompatible_use_python_toolchains`",
+        "--incompatible_use_python_toolchains=true",
+        "--python_top=//mypkg:my_py_runtime");
+    // TODO(#7901): Also test that --python_path is disallowed (once implemented). Currently we
+    // still need it to communicate the location of python.exe on Windows from the client to the
+    // server.
+  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeConfiguredTargetTest.java b/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeConfiguredTargetTest.java
index 8569131..75e498f 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeConfiguredTargetTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeConfiguredTargetTest.java
@@ -73,6 +73,8 @@
   @Test
   public void pythonVersionDefault() throws Exception {
     ensureDefaultIsPY2();
+    // When using toolchains, the python_version attribute is mandatory.
+    useConfiguration("--incompatible_use_python_toolchains=false");
     scratch.file(
         "pkg/BUILD",
         "py_runtime(",
@@ -170,4 +172,19 @@
 
     assertContainsEvent("invalid value in 'python_version' attribute");
   }
+
+  @Test
+  public void versionAttributeMandatoryWhenUsingToolchains() throws Exception {
+    reporter.removeHandler(failFastHandler);
+    useConfiguration("--incompatible_use_python_toolchains=true");
+    scratch.file(
+        "pkg/BUILD",
+        "py_runtime(",
+        "    name = 'myruntime',",
+        "    interpreter_path = '/system/interpreter',",
+        ")");
+    getConfiguredTarget("//pkg:myruntime");
+
+    assertContainsEvent("must be set explicitly to either 'PY2' or 'PY3'");
+  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/rules/python/PythonConfigurationTest.java b/src/test/java/com/google/devtools/build/lib/rules/python/PythonConfigurationTest.java
index ab93932..b2b5f12 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/python/PythonConfigurationTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/python/PythonConfigurationTest.java
@@ -221,7 +221,7 @@
             "--incompatible_py2_outputs_are_suffixed=true",
             "--build_python_zip=true",
             "--incompatible_disallow_legacy_py_provider=true",
-            "--experimental_use_python_toolchains=true");
+            "--incompatible_use_python_toolchains=true");
     PythonOptions hostOpts = (PythonOptions) opts.getHost();
     assertThat(hostOpts.incompatibleAllowPythonVersionTransitions).isTrue();
     assertThat(hostOpts.incompatibleRemoveOldPythonVersionApi).isTrue();
diff --git a/src/test/shell/bazel/bazel_example_test.sh b/src/test/shell/bazel/bazel_example_test.sh
index 168d90c..663dfef 100755
--- a/src/test/shell/bazel/bazel_example_test.sh
+++ b/src/test/shell/bazel/bazel_example_test.sh
@@ -97,13 +97,13 @@
 }
 
 function test_native_python() {
-  assert_build //examples/py_native:bin --python2_path=python
-  assert_test_ok //examples/py_native:test --python2_path=python
-  assert_test_fails //examples/py_native:fail --python2_path=python
+  assert_build //examples/py_native:bin
+  assert_test_ok //examples/py_native:test
+  assert_test_fails //examples/py_native:fail
 }
 
 function test_native_python_with_zip() {
-  assert_build //examples/py_native:bin --python2_path=python --build_python_zip
+  assert_build //examples/py_native:bin --build_python_zip
   # run the python package directly
   ./bazel-bin/examples/py_native/bin >& $TEST_log \
     || fail "//examples/py_native:bin execution failed"
@@ -112,8 +112,8 @@
   python ./bazel-bin/examples/py_native/bin >& $TEST_log \
     || fail "//examples/py_native:bin execution failed"
   expect_log "Fib(5) == 8"
-  assert_test_ok //examples/py_native:test --python2_path=python --build_python_zip
-  assert_test_fails //examples/py_native:fail --python2_path=python --build_python_zip
+  assert_test_ok //examples/py_native:test --build_python_zip
+  assert_test_fails //examples/py_native:fail --build_python_zip
 }
 
 function test_shell() {
diff --git a/tools/python/pywrapper_template.txt b/tools/python/pywrapper_template.txt
index c0a67d1..c2f5b18 100644
--- a/tools/python/pywrapper_template.txt
+++ b/tools/python/pywrapper_template.txt
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 
 # TODO(#7843): integration tests for failure to find python / wrong version
 # found / error while trying to print version