tests: add integration test for build_python_zip (#2045)

This is a more comprehensive regression test for verifying
`--build_python_zip` is
actually working
(https://github.com/bazelbuild/rules_python/issues/1840)

This also creates a small framework to make it easier to write
integration tests that
need to customize the environment bazel runs in and check the output of
bazel itself.
I figure this will be helpful for writing simple verification tests for
repository/bzlmod
phase logic (i.e. set the debug env vars and grep the output). While we
should avoid heavy
usage of these bazel-in-bazel tests, a bit of grepping logs would go a
long way for covering
edge cases that examples don't cover.
diff --git a/.bazelrc b/.bazelrc
index 07188a9..bf13baf 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -4,8 +4,8 @@
 # (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it)
 # To update these lines, execute
 # `bazel run @rules_bazel_integration_test//tools:update_deleted_packages`
-build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/python/private,gazelle/pythonconfig,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
-query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/python/private,gazelle/pythonconfig,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
+build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/custom_commands,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
+query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/custom_commands,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
 
 test --test_output=errors
 
diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel
index f1c427f..ac475da 100644
--- a/tests/integration/BUILD.bazel
+++ b/tests/integration/BUILD.bazel
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 load("@rules_bazel_integration_test//bazel_integration_test:defs.bzl", "default_test_runner")
+load("//python:py_library.bzl", "py_library")
 load(":integration_test.bzl", "rules_python_integration_test")
 
 licenses(["notice"])
@@ -102,3 +103,14 @@
     bzlmod = False,
     workspace_path = "py_cc_toolchain_registered",
 )
+
+rules_python_integration_test(
+    name = "custom_commands_test",
+    py_main = "custom_commands_test.py",
+)
+
+py_library(
+    name = "runner_lib",
+    srcs = ["runner.py"],
+    imports = ["../../"],
+)
diff --git a/tests/integration/custom_commands/BUILD.bazel b/tests/integration/custom_commands/BUILD.bazel
new file mode 100644
index 0000000..b0fafff
--- /dev/null
+++ b/tests/integration/custom_commands/BUILD.bazel
@@ -0,0 +1,20 @@
+# 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("@rules_python//python:py_binary.bzl", "py_binary")
+
+py_binary(
+    name = "bin",
+    srcs = ["bin.py"],
+)
diff --git a/tests/integration/custom_commands/MODULE.bazel b/tests/integration/custom_commands/MODULE.bazel
new file mode 100644
index 0000000..5bea812
--- /dev/null
+++ b/tests/integration/custom_commands/MODULE.bazel
@@ -0,0 +1,21 @@
+# 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.
+
+module(name = "module_under_test")
+
+bazel_dep(name = "rules_python", version = "0.0.0")
+local_path_override(
+    module_name = "rules_python",
+    path = "../../..",
+)
diff --git a/tests/integration/custom_commands/WORKSPACE b/tests/integration/custom_commands/WORKSPACE
new file mode 100644
index 0000000..de90854
--- /dev/null
+++ b/tests/integration/custom_commands/WORKSPACE
@@ -0,0 +1,13 @@
+local_repository(
+    name = "rules_python",
+    path = "../../..",
+)
+
+load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains")
+
+py_repositories()
+
+python_register_toolchains(
+    name = "python_3_11",
+    python_version = "3.11",
+)
diff --git a/tests/integration/custom_commands/WORKSPACE.bzlmod b/tests/integration/custom_commands/WORKSPACE.bzlmod
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/integration/custom_commands/WORKSPACE.bzlmod
diff --git a/tests/integration/custom_commands/bin.py b/tests/integration/custom_commands/bin.py
new file mode 100644
index 0000000..62487b5
--- /dev/null
+++ b/tests/integration/custom_commands/bin.py
@@ -0,0 +1,16 @@
+# 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.
+
+print("Hello, world")
+print(__file__)
diff --git a/tests/integration/custom_commands_test.py b/tests/integration/custom_commands_test.py
new file mode 100644
index 0000000..f78ee46
--- /dev/null
+++ b/tests/integration/custom_commands_test.py
@@ -0,0 +1,31 @@
+# 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.
+
+import logging
+import unittest
+
+from tests.integration import runner
+
+
+class CustomCommandsTest(runner.TestCase):
+    # Regression test for https://github.com/bazelbuild/rules_python/issues/1840
+    def test_run_build_python_zip_false(self):
+        result = self.run_bazel("run", "--build_python_zip=false", "//:bin")
+        self.assert_result_matches(result, "bazel-out")
+
+
+if __name__ == "__main__":
+    # Enabling this makes the runner log subprocesses as the test goes along.
+    # logging.basicConfig(level = "INFO")
+    unittest.main()
diff --git a/tests/integration/integration_test.bzl b/tests/integration/integration_test.bzl
index 16d6a5a..7a8070a 100644
--- a/tests/integration/integration_test.bzl
+++ b/tests/integration/integration_test.bzl
@@ -19,6 +19,7 @@
     "bazel_integration_tests",
     "integration_test_utils",
 )
+load("//python:py_test.bzl", "py_test")
 
 def rules_python_integration_test(
         name,
@@ -26,6 +27,7 @@
         bzlmod = True,
         gazelle_plugin = False,
         tags = None,
+        py_main = None,
         **kwargs):
     """Runs a bazel-in-bazel integration test.
 
@@ -37,10 +39,24 @@
             disable bzlmod.
         gazelle_plugin: Whether the test uses the gazelle plugin.
         tags: Test tags.
+        py_main: Optional `.py` file to run tests using. When specified, a
+            python based test runner is used, and this source file is the main
+            entry point and responsible for executing tests.
         **kwargs: Passed to the upstream `bazel_integration_tests` rule.
     """
     workspace_path = workspace_path or name.removesuffix("_test")
-    if bzlmod:
+    if py_main:
+        test_runner = name + "_py_runner"
+        py_test(
+            name = test_runner,
+            srcs = [py_main],
+            main = py_main,
+            deps = [":runner_lib"],
+            # Hide from ... patterns; should only be run as part
+            # of the bazel integration test
+            tags = ["manual"],
+        )
+    elif bzlmod:
         if gazelle_plugin:
             test_runner = "//tests/integration:test_runner_gazelle_plugin"
         else:
diff --git a/tests/integration/runner.py b/tests/integration/runner.py
new file mode 100644
index 0000000..9414a86
--- /dev/null
+++ b/tests/integration/runner.py
@@ -0,0 +1,131 @@
+# 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.
+
+import logging
+import os
+import os.path
+import pathlib
+import re
+import shlex
+import subprocess
+import unittest
+
+_logger = logging.getLogger(__name__)
+
+class ExecuteError(Exception):
+    def __init__(self, result):
+        self.result = result
+    def __str__(self):
+        return self.result.describe()
+
+class ExecuteResult:
+    def __init__(
+        self,
+        args: list[str],
+        env: dict[str, str],
+        cwd: pathlib.Path,
+        proc_result: subprocess.CompletedProcess,
+    ):
+        self.args = args
+        self.env = env
+        self.cwd = cwd
+        self.exit_code = proc_result.returncode
+        self.stdout = proc_result.stdout
+        self.stderr = proc_result.stderr
+
+    def describe(self) -> str:
+        env_lines = [
+            "  " + shlex.quote(f"{key}={value}")
+            for key, value in sorted(self.env.items())
+        ]
+        env = " \\\n".join(env_lines)
+        args = shlex.join(self.args)
+        maybe_stdout_nl = "" if self.stdout.endswith("\n") else "\n"
+        maybe_stderr_nl = "" if self.stderr.endswith("\n") else "\n"
+        return f"""\
+COMMAND:
+cd {self.cwd} && \\
+env \\
+{env} \\
+  {args}
+RESULT: exit_code: {self.exit_code}
+===== STDOUT START =====
+{self.stdout}{maybe_stdout_nl}===== STDOUT END   =====
+===== STDERR START =====
+{self.stderr}{maybe_stderr_nl}===== STDERR END   =====
+"""
+
+
+class TestCase(unittest.TestCase):
+    def setUp(self):
+        super().setUp()
+        self.repo_root = pathlib.Path(os.environ["BIT_WORKSPACE_DIR"])
+        self.bazel = pathlib.Path(os.environ["BIT_BAZEL_BINARY"])
+        outer_test_tmpdir = pathlib.Path(os.environ["TEST_TMPDIR"])
+        self.test_tmp_dir = outer_test_tmpdir / "bit_test_tmp"
+        # Put the global tmp not under the test tmp to better match how a real
+        # execution has entirely different directories for these.
+        self.tmp_dir = outer_test_tmpdir / "bit_tmp"
+        self.bazel_env = {
+            "PATH": os.environ["PATH"],
+            "TEST_TMPDIR": str(self.test_tmp_dir),
+            "TMP": str(self.tmp_dir),
+            # For some reason, this is necessary for Bazel 6.4 to work.
+            # If not present, it can't find some bash helpers in @bazel_tools
+            "RUNFILES_DIR": os.environ["TEST_SRCDIR"]
+        }
+
+    def run_bazel(self, *args: str, check: bool = True) -> ExecuteResult:
+        """Run a bazel invocation.
+
+        Args:
+            *args: The args to pass to bazel; the leading `bazel` command is
+                added automatically
+            check: True if the execution must succeed, False if failure
+                should raise an error.
+        Returns:
+            An `ExecuteResult` from running Bazel
+        """
+        args = [str(self.bazel), *args]
+        env = self.bazel_env
+        _logger.info("executing: %s", shlex.join(args))
+        cwd = self.repo_root
+        proc_result = subprocess.run(
+            args=args,
+            text=True,
+            capture_output=True,
+            cwd=cwd,
+            env=env,
+            check=False,
+        )
+        exec_result = ExecuteResult(args, env, cwd, proc_result)
+        if check and exec_result.exit_code:
+            raise ExecuteError(exec_result)
+        else:
+            return exec_result
+
+    def assert_result_matches(self, result: ExecuteResult, regex: str) -> None:
+        """Assert stdout/stderr of an invocation matches a regex.
+
+        Args:
+            result: ExecuteResult from `run_bazel` whose stdout/stderr will
+                be checked.
+            regex: Pattern to match, using `re.search` semantics.
+        """
+        if not re.search(regex, result.stdout + result.stderr):
+            self.fail(
+                "Bazel output did not match expected pattern\n"
+                + f"expected pattern: {regex}\n"
+                + f"invocation details:\n{result.describe()}"
+            )