fix(windows): symlink bootstrap script when not building zip (#2015)

This fixes #1840 by supporting again `--build_python_zip=false` on
Windows.

When the zip file isn't build, the transition executable looks for the
eponymous bootstrap script but the latter doesn't exist. I've just added
a symlink, and refactored a bit because logic would have been
duplicated.

It seems you don't run CICD on Windows. FWIW I've manually tested it,
both with `build_python_zip` as `true` and `false`.

---------

Co-authored-by: Richard Levasseur <rlevasseur@google.com>
diff --git a/python/config_settings/transition.bzl b/python/config_settings/transition.bzl
index 48b0447..da48a1f 100644
--- a/python/config_settings/transition.bzl
+++ b/python/config_settings/transition.bzl
@@ -43,24 +43,40 @@
         output = executable,
         target_file = target[DefaultInfo].files_to_run.executable,
     )
-    zipfile_symlink = None
+    default_outputs = []
     if target_is_windows:
-        # Under Windows, the expected "<name>.zip" does not exist, so we have to
-        # create the symlink ourselves to achieve the same behaviour as in macOS
-        # and Linux.
-        zipfile = None
-        expected_target_path = target[DefaultInfo].files_to_run.executable.short_path[:-4] + ".zip"
-        for file in target[DefaultInfo].default_runfiles.files.to_list():
-            if file.short_path == expected_target_path:
-                zipfile = file
+        # NOTE: Bazel 6 + host=linux + target=windows results in the .exe extension missing
+        inner_bootstrap_path = _strip_suffix(target[DefaultInfo].files_to_run.executable.short_path, ".exe")
+        inner_bootstrap = None
+        inner_zip_file_path = inner_bootstrap_path + ".zip"
+        inner_zip_file = None
+        for file in target[DefaultInfo].files.to_list():
+            if file.short_path == inner_bootstrap_path:
+                inner_bootstrap = file
+            elif file.short_path == inner_zip_file_path:
+                inner_zip_file = file
 
-        if zipfile:
-            zipfile_symlink = ctx.actions.declare_file(ctx.attr.name + ".zip")
+        # TODO: Use `fragments.py.build_python_zip` once Bazel 6 support is dropped.
+        # Which file the Windows .exe looks for depends on the --build_python_zip file.
+        # Bazel 7+ has APIs to know the effective value of that flag, but not Bazel 6.
+        # To work around this, we treat the existence of a .zip in the default outputs
+        # to mean --build_python_zip=true.
+        if inner_zip_file:
+            suffix = ".zip"
+            underlying_launched_file = inner_zip_file
+        else:
+            suffix = ""
+            underlying_launched_file = inner_bootstrap
+
+        if underlying_launched_file:
+            launched_file_symlink = ctx.actions.declare_file(ctx.attr.name + suffix)
             ctx.actions.symlink(
                 is_executable = True,
-                output = zipfile_symlink,
-                target_file = zipfile,
+                output = launched_file_symlink,
+                target_file = underlying_launched_file,
             )
+            default_outputs.append(launched_file_symlink)
+
     env = {}
     for k, v in ctx.attr.env.items():
         env[k] = ctx.expand_location(v)
@@ -85,8 +101,8 @@
     providers = [
         DefaultInfo(
             executable = executable,
-            files = depset([zipfile_symlink] if zipfile_symlink else [], transitive = [target[DefaultInfo].files]),
-            runfiles = ctx.runfiles([zipfile_symlink] if zipfile_symlink else []).merge(target[DefaultInfo].default_runfiles),
+            files = depset(default_outputs, transitive = [target[DefaultInfo].files]),
+            runfiles = ctx.runfiles(default_outputs).merge(target[DefaultInfo].default_runfiles),
         ),
         py_info,
         py_runtime_info,
@@ -169,6 +185,7 @@
     attrs = _COMMON_ATTRS | _PY_TEST_ATTRS,
     cfg = _transition_python_version,
     executable = True,
+    fragments = ["py"],
 )
 
 _transition_py_test = rule(
@@ -176,6 +193,7 @@
     attrs = _COMMON_ATTRS | _PY_TEST_ATTRS,
     cfg = _transition_python_version,
     test = True,
+    fragments = ["py"],
 )
 
 def _py_rule(rule_impl, transition_rule, name, python_version, **kwargs):
@@ -263,3 +281,9 @@
 
 def py_test(name, python_version, **kwargs):
     return _py_rule(_py_test, _transition_py_test, name, python_version, **kwargs)
+
+def _strip_suffix(s, suffix):
+    if s.endswith(suffix):
+        return s[:-len(suffix)]
+    else:
+        return s
diff --git a/tests/config_settings/transition/multi_version_tests.bzl b/tests/config_settings/transition/multi_version_tests.bzl
index f3707db..e35590b 100644
--- a/tests/config_settings/transition/multi_version_tests.bzl
+++ b/tests/config_settings/transition/multi_version_tests.bzl
@@ -15,8 +15,9 @@
 
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
 load("@rules_testing//lib:test_suite.bzl", "test_suite")
-load("@rules_testing//lib:util.bzl", rt_util = "util")
+load("@rules_testing//lib:util.bzl", "TestingAspectInfo", rt_util = "util")
 load("//python/config_settings:transition.bzl", py_binary_transitioned = "py_binary", py_test_transitioned = "py_test")
+load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER")  # buildifier: disable=bzl-visibility
 
 # NOTE @aignas 2024-06-04: we are using here something that is registered in the MODULE.Bazel
 # and if you find tests failing, it could be because of the toolchain resolution issues here.
@@ -68,6 +69,80 @@
 
 _tests.append(_test_py_binary_with_transition)
 
+def _setup_py_binary_windows(name, *, impl, build_python_zip):
+    rt_util.helper_target(
+        py_binary_transitioned,
+        name = name + "_subject",
+        srcs = [name + "_subject.py"],
+        python_version = _PYTHON_VERSION,
+    )
+
+    analysis_test(
+        name = name,
+        target = name + "_subject",
+        impl = impl,
+        config_settings = {
+            "//command_line_option:build_python_zip": build_python_zip,
+            "//command_line_option:extra_toolchains": "//tests/cc:all",
+            "//command_line_option:platforms": str(Label("//tests/support:windows_x86_64")),
+        },
+    )
+
+def _test_py_binary_windows_build_python_zip_false(name):
+    _setup_py_binary_windows(
+        name,
+        build_python_zip = "false",
+        impl = _test_py_binary_windows_build_python_zip_false_impl,
+    )
+
+def _test_py_binary_windows_build_python_zip_false_impl(env, target):
+    default_outputs = env.expect.that_target(target).default_outputs()
+    if IS_BAZEL_7_OR_HIGHER:
+        # TODO: These outputs aren't correct. The outputs shouldn't
+        # have the "_" prefix on them (those are coming from the underlying
+        # wrapped binary).
+        env.expect.that_target(target).default_outputs().contains_exactly([
+            "{package}/_{test_name}_subject",
+            "{package}/_{test_name}_subject.exe",
+            "{package}/{test_name}_subject",
+            "{package}/{test_name}_subject.py",
+        ])
+    else:
+        inner_exe = target[TestingAspectInfo].attrs.target[DefaultInfo].files_to_run.executable
+        default_outputs.contains_at_least([
+            inner_exe.short_path,
+        ])
+
+_tests.append(_test_py_binary_windows_build_python_zip_false)
+
+def _test_py_binary_windows_build_python_zip_true(name):
+    _setup_py_binary_windows(
+        name,
+        build_python_zip = "true",
+        impl = _test_py_binary_windows_build_python_zip_true_impl,
+    )
+
+def _test_py_binary_windows_build_python_zip_true_impl(env, target):
+    default_outputs = env.expect.that_target(target).default_outputs()
+    if IS_BAZEL_7_OR_HIGHER:
+        # TODO: These outputs aren't correct. The outputs shouldn't
+        # have the "_" prefix on them (those are coming from the underlying
+        # wrapped binary).
+        default_outputs.contains_exactly([
+            "{package}/_{test_name}_subject.exe",
+            "{package}/_{test_name}_subject.zip",
+            "{package}/{test_name}_subject.py",
+            "{package}/{test_name}_subject.zip",
+        ])
+    else:
+        inner_exe = target[TestingAspectInfo].attrs.target[DefaultInfo].files_to_run.executable
+        default_outputs.contains_at_least([
+            "{package}/{test_name}_subject.zip",
+            inner_exe.short_path,
+        ])
+
+_tests.append(_test_py_binary_windows_build_python_zip_true)
+
 def multi_version_test_suite(name):
     test_suite(
         name = name,