Skip to content

Commit 6d38770

Browse files
authored
feat(zipapp): support EXTRACT_ROOT env var for __main__.py invocations (#3682)
This makes zipapps that are invoked using the `__main__.py` entry point (i.e. `python foo.zip`) respect the RULES_PYTHON_EXTRACT_ROOT env var, which allows some control over where they extract themselves.
1 parent df7a168 commit 6d38770

File tree

3 files changed

+61
-18
lines changed

3 files changed

+61
-18
lines changed

python/private/zipapp/py_zipapp_rule.bzl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ def _create_zipapp_main_py(ctx, py_runtime, py_executable, stage2_bootstrap):
3535
template = py_runtime.zip_main_template,
3636
output = zip_main_py,
3737
substitutions = {
38+
"%EXTRACT_DIR%": paths.join(
39+
(ctx.label.repo_name or "_main"),
40+
ctx.label.package,
41+
ctx.label.name,
42+
),
3843
"%python_binary%": venv_python_exe_path,
3944
"%python_binary_actual%": python_binary_actual_path,
4045
"%stage2_bootstrap%": runfiles_root_path(ctx, stage2_bootstrap.short_path),

python/private/zipapp/zip_main_template.py

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323

2424
import os
2525
import shutil
26+
import stat
2627
import subprocess
2728
import tempfile
2829
import zipfile
30+
from os.path import dirname, join
2931

3032
# runfiles-root-relative path
3133
_STAGE2_BOOTSTRAP = "%stage2_bootstrap%"
@@ -35,6 +37,10 @@
3537
# executable to use.
3638
_PYTHON_BINARY_ACTUAL = "%python_binary_actual%"
3739
_WORKSPACE_NAME = "%workspace_name%"
40+
# relative path under EXTRACT_ROOT to extract to.
41+
EXTRACT_DIR = "%EXTRACT_DIR%"
42+
43+
EXTRACT_ROOT = os.environ.get("RULES_PYTHON_EXTRACT_ROOT")
3844

3945

4046
def print_verbose(*args, mapping=None, values=None):
@@ -118,7 +124,7 @@ def search_path(name):
118124
search_path = os.getenv("PATH", os.defpath).split(os.pathsep)
119125
for directory in search_path:
120126
if directory:
121-
path = os.path.join(directory, name)
127+
path = join(directory, name)
122128
if os.path.isfile(path) and os.access(path, os.X_OK):
123129
return path
124130
return None
@@ -139,7 +145,7 @@ def find_binary(runfiles_root, bin_name):
139145
# Use normpath() to convert slashes to os.sep on Windows.
140146
elif os.sep in os.path.normpath(bin_name):
141147
# Case 3: Path is relative to the repo root.
142-
return os.path.join(runfiles_root, bin_name)
148+
return join(runfiles_root, bin_name)
143149
else:
144150
# Case 4: Path has to be looked up in the search path.
145151
return search_path(bin_name)
@@ -161,10 +167,18 @@ def extract_zip(zip_path, dest_dir):
161167
dest_dir = get_windows_path_with_unc_prefix(dest_dir)
162168
with zipfile.ZipFile(zip_path) as zf:
163169
for info in zf.infolist():
170+
file_path = os.path.abspath(join(dest_dir, info.filename))
171+
# If the file exists, it might be a symlink or read-only file from a previous extraction.
172+
# Unlink it first so zipfile.extract doesn't corrupt the symlink target or fail on read-only files.
173+
if os.path.lexists(file_path) and not os.path.isdir(file_path):
174+
try:
175+
os.unlink(file_path)
176+
except OSError:
177+
# On Windows, unlinking a read-only file fails.
178+
os.chmod(file_path, stat.S_IWRITE)
179+
os.unlink(file_path)
180+
164181
zf.extract(info, dest_dir)
165-
# UNC-prefixed paths must be absolute/normalized. See
166-
# https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation
167-
file_path = os.path.abspath(os.path.join(dest_dir, info.filename))
168182
# The Unix st_mode bits (see "man 7 inode") are stored in the upper 16
169183
# bits of external_attr.
170184
attrs = info.external_attr >> 16
@@ -182,11 +196,14 @@ def extract_zip(zip_path, dest_dir):
182196

183197
# Create the runfiles tree by extracting the zip file
184198
def create_runfiles_root():
185-
temp_dir = tempfile.mkdtemp("", "Bazel.runfiles_")
186-
extract_zip(os.path.dirname(__file__), temp_dir)
199+
if EXTRACT_ROOT:
200+
extract_root = join(EXTRACT_ROOT, EXTRACT_DIR)
201+
else:
202+
extract_root = tempfile.mkdtemp("", "Bazel.runfiles_")
203+
extract_zip(dirname(__file__), extract_root)
187204
# IMPORTANT: Later code does `rm -fr` on dirname(runfiles_root) -- it's
188205
# important that deletion code be in sync with this directory structure
189-
return os.path.join(temp_dir, "runfiles")
206+
return join(extract_root, "runfiles")
190207

191208

192209
def execute_file(
@@ -223,18 +240,24 @@ def execute_file(
223240
# - When running in a zip file, we need to clean up the
224241
# workspace after the process finishes so control must return here.
225242
try:
226-
subprocess_argv = [python_program, main_filename] + args
243+
subprocess_argv = [python_program]
244+
if not EXTRACT_ROOT:
245+
subprocess_argv.append(f"-XRULES_PYTHON_ZIP_DIR={dirname(runfiles_root)}")
246+
subprocess_argv.append(main_filename)
247+
subprocess_argv += args
227248
print_verbose("subprocess argv:", values=subprocess_argv)
228249
print_verbose("subprocess env:", mapping=env)
229250
print_verbose("subprocess cwd:", workspace)
230251
ret_code = subprocess.call(subprocess_argv, env=env, cwd=workspace)
231252
sys.exit(ret_code)
232253
finally:
233-
# NOTE: dirname() is called because create_runfiles_root() creates a
234-
# sub-directory within a temporary directory, and we want to remove the
235-
# whole temporary directory.
236-
##shutil.rmtree(os.path.dirname(runfiles_root), True)
237-
pass
254+
if not EXTRACT_ROOT:
255+
# NOTE: dirname() is called because create_runfiles_root() creates a
256+
# sub-directory within a temporary directory, and we want to remove the
257+
# whole temporary directory.
258+
extract_root = dirname(runfiles_root)
259+
print_verbose("cleanup: rmtree: ", extract_root)
260+
shutil.rmtree(extract_root, True)
238261

239262

240263
def main():
@@ -266,7 +289,7 @@ def main():
266289
# See: https://docs.python.org/3.11/using/cmdline.html#envvar-PYTHONSAFEPATH
267290
new_env["PYTHONSAFEPATH"] = "1"
268291

269-
main_filename = os.path.join(runfiles_root, main_rel_path)
292+
main_filename = join(runfiles_root, main_rel_path)
270293
main_filename = get_windows_path_with_unc_prefix(main_filename)
271294
assert os.path.exists(main_filename), (
272295
"Cannot exec() %r: file not found." % main_filename
@@ -276,7 +299,7 @@ def main():
276299
)
277300

278301
if _PYTHON_BINARY_VENV:
279-
python_program = os.path.join(runfiles_root, _PYTHON_BINARY_VENV)
302+
python_program = join(runfiles_root, _PYTHON_BINARY_VENV)
280303
# When a venv is used, the `bin/python3` symlink may need to be created.
281304
# This case occurs when "create venv at runtime" or "resolve python at
282305
# runtime" modes are enabled.
@@ -288,7 +311,7 @@ def main():
288311
"Program's venv binary not under runfiles: {python_program}"
289312
)
290313
symlink_to = find_binary(runfiles_root, _PYTHON_BINARY_ACTUAL)
291-
os.makedirs(os.path.dirname(python_program), exist_ok=True)
314+
os.makedirs(dirname(python_program), exist_ok=True)
292315
try:
293316
os.symlink(symlink_to, python_program)
294317
except OSError as e:
@@ -317,7 +340,7 @@ def main():
317340
# change directory to the right runfiles directory.
318341
# (So that the data files are accessible)
319342
if os.environ.get("RUN_UNDER_RUNFILES") == "1":
320-
workspace = os.path.join(runfiles_root, _WORKSPACE_NAME)
343+
workspace = join(runfiles_root, _WORKSPACE_NAME)
321344

322345
sys.stdout.flush()
323346
execute_file(

tests/py_zipapp/system_python_zipapp_external_bootstrap_test.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ fi
1313
ZIPAPP="${ZIPAPP/.exe/.zip}"
1414

1515
export RULES_PYTHON_BOOTSTRAP_VERBOSE=1
16+
1617
# We're testing the invocation of `__main__.py`, so we have to
1718
# manually pass the zipapp to python.
19+
echo "Running zipapp using an automatic temp directory..."
20+
"$PYTHON" "$ZIPAPP"
21+
22+
echo "Running zipapp with extract root set..."
23+
export RULES_PYTHON_EXTRACT_ROOT="${TEST_TMPDIR:-/tmp}/extract_root_test"
24+
"$PYTHON" "$ZIPAPP"
25+
26+
# Verify that the directory was created
27+
if [[ ! -d "$RULES_PYTHON_EXTRACT_ROOT" ]]; then
28+
echo "Error: Extract root directory $RULES_PYTHON_EXTRACT_ROOT was not created!"
29+
exit 1
30+
fi
31+
32+
echo "Running zipapp with extract root set a second time..."
1833
"$PYTHON" "$ZIPAPP"

0 commit comments

Comments
 (0)