Preserve file permissions when extracting runfiles zip
In Python rules, the Python stub script is responsible for extracting the runfiles from the zip file when --build_python_zip is used. However, there's a bug in the standard Python library (https://bugs.python.org/issue15795) that causes file permissions to be lost when extracting zips. This causes runfiles to not be marked executable, which is a problem when the py_runtime is an in-build runtime (issue #5104).
This change updates the stub script to workaround the bug by directly chmodding extracted files with their intended mode. It also adds a regression test combining a custom py_runtime with --build_python_zip.
Fixes #5104, unblocks #7375.
RELNOTES: Fixed an issue where some `py_runtime`s were incompatible with using `--build_python_zip` (#5104).
PiperOrigin-RevId: 246195931
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt b/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt
index dac21c9..a6c5110 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt
@@ -99,13 +99,38 @@
raise AssertionError('Cannot find .runfiles directory for %s' % sys.argv[0])
+def ExtractZip(zip_path, dest_dir):
+ """Extracts the contents of a zip file, preserving the unix file mode bits.
+
+ These include the permission bits, and in particular, the executable bit.
+
+ Ideally the zipfile module should set these bits, but it doesn't. See:
+ https://bugs.python.org/issue15795.
+
+ Args:
+ zip_path: The path to the zip file to extract
+ dest_dir: The path to the destination directory
+ """
+ zip_path = GetWindowsPathWithUNCPrefix(zip_path)
+ dest_dir = GetWindowsPathWithUNCPrefix(dest_dir)
+ with zipfile.ZipFile(zip_path) as zf:
+ for info in zf.infolist():
+ zf.extract(info, dest_dir)
+ # UNC-prefixed paths must be absolute/normalized. See
+ # https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation
+ file_path = os.path.abspath(os.path.join(dest_dir, info.filename))
+ # The Unix st_mode bits (see "man 7 inode") are stored in the upper 16
+ # bits of external_attr. Of those, we set the lower 12 bits, which are the
+ # file mode bits (since the file type bits can't be set by chmod anyway).
+ attrs = info.external_attr >> 16
+ if attrs != 0: # Rumor has it these can be 0 for zips created on Windows.
+ os.chmod(file_path, attrs & 0o7777)
+
# Create the runfiles tree by extracting the zip file
def CreateModuleSpace():
- ZIP_RUNFILES_DIRECTORY_NAME = "runfiles"
temp_dir = tempfile.mkdtemp("", "Bazel.runfiles_")
- zf = zipfile.ZipFile(GetWindowsPathWithUNCPrefix(os.path.dirname(__file__)))
- zf.extractall(GetWindowsPathWithUNCPrefix(temp_dir))
- return os.path.join(temp_dir, ZIP_RUNFILES_DIRECTORY_NAME)
+ ExtractZip(os.path.dirname(__file__), temp_dir)
+ return os.path.join(temp_dir, "runfiles")
# Returns repository roots to add to the import path.
def GetRepositoriesImports(module_space, import_all):
diff --git a/src/test/shell/bazel/python_version_test.sh b/src/test/shell/bazel/python_version_test.sh
index 22c2e22..e463f4d 100755
--- a/src/test/shell/bazel/python_version_test.sh
+++ b/src/test/shell/bazel/python_version_test.sh
@@ -188,6 +188,47 @@
expect_log "File contents: abcdefg"
}
+# Regression test for #5104. This test ensures that it's possible to use
+# --build_python_zip in combination with a py_runtime (as opposed to not using
+# a py_runtime, i.e., the legacy --python_path mechanism).
+#
+# Note that with --incompatible_use_python_toolchains flipped, we're always
+# using a py_runtime, so in that case this amounts to a test that
+# --build_python_zip works at all.
+#
+# The specific issue #5104 was caused by file permissions being lost when
+# unzipping runfiles, which led to an unexecutable runtime.
+function test_build_python_zip_works_with_py_runtime() {
+ mkdir -p test
+
+ cat > test/BUILD << EOF
+py_binary(
+ name = "pybin",
+ srcs = ["pybin.py"],
+)
+
+py_runtime(
+ name = "mock_runtime",
+ interpreter = ":mockpy.sh",
+ python_version = "PY3",
+)
+EOF
+ cat > test/pybin.py << EOF
+# This doesn't actually run because we use a mock Python runtime that never
+# executes the Python code.
+print("I am pybin!")
+EOF
+ cat > test/mockpy.sh <<EOF
+#!/bin/bash
+echo "I am mockpy!"
+EOF
+ chmod u+x test/mockpy.sh
+
+ bazel run //test:pybin --python_top=//test:mock_runtime --build_python_zip \
+ &> $TEST_log || fail "bazel run failed"
+ expect_log "I am mockpy!"
+}
+
function test_pybin_can_have_different_version_pybin_as_data_dep() {
mkdir -p test