Skip to content

Commit 9345115

Browse files
authored
Merge branch 'main' into chore.rm.pip.repository.annotations
2 parents f6791a9 + 2c5616b commit 9345115

File tree

9 files changed

+375
-45
lines changed

9 files changed

+375
-45
lines changed

python/private/zipapp/py_zipapp_rule.bzl

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def _is_symlink(f):
1818
else:
1919
return "-1"
2020

21-
def _create_zipapp_main_py(ctx, py_runtime, py_executable, stage2_bootstrap):
21+
def _create_zipapp_main_py(ctx, py_runtime, py_executable, stage2_bootstrap, runfiles):
2222
venv_python_exe = py_executable.venv_python_exe
2323
if venv_python_exe:
2424
venv_python_exe_path = runfiles_root_path(ctx, venv_python_exe.short_path)
@@ -31,15 +31,40 @@ def _create_zipapp_main_py(ctx, py_runtime, py_executable, stage2_bootstrap):
3131
python_binary_actual_path = py_runtime.interpreter_path
3232

3333
zip_main_py = ctx.actions.declare_file(ctx.label.name + ".zip_main.py")
34-
ctx.actions.expand_template(
35-
template = py_runtime.zip_main_template,
36-
output = zip_main_py,
37-
substitutions = {
38-
"%python_binary%": venv_python_exe_path,
39-
"%python_binary_actual%": python_binary_actual_path,
40-
"%stage2_bootstrap%": runfiles_root_path(ctx, stage2_bootstrap.short_path),
41-
"%workspace_name%": ctx.workspace_name,
42-
},
34+
35+
args = ctx.actions.args()
36+
args.add(py_runtime.zip_main_template, format = "--template=%s")
37+
args.add(zip_main_py, format = "--output=%s")
38+
39+
args.add(
40+
"%EXTRACT_DIR%=" + paths.join(
41+
(ctx.label.repo_name or "_main"),
42+
ctx.label.package,
43+
ctx.label.name,
44+
),
45+
format = "--substitution=%s",
46+
)
47+
args.add("%python_binary%=" + venv_python_exe_path, format = "--substitution=%s")
48+
args.add("%python_binary_actual%=" + python_binary_actual_path, format = "--substitution=%s")
49+
args.add("%stage2_bootstrap%=" + runfiles_root_path(ctx, stage2_bootstrap.short_path), format = "--substitution=%s")
50+
args.add("%workspace_name%=" + ctx.workspace_name, format = "--substitution=%s")
51+
52+
hash_files_manifest = ctx.actions.args()
53+
hash_files_manifest.use_param_file("--hash_files_manifest=%s", use_always = True)
54+
hash_files_manifest.set_param_file_format("multiline")
55+
56+
inputs = builders.DepsetBuilder()
57+
inputs.add(py_runtime.zip_main_template)
58+
_build_manifest(ctx, hash_files_manifest, runfiles, inputs)
59+
60+
actions_run(
61+
ctx,
62+
executable = ctx.attr._zip_main_maker,
63+
arguments = [args, hash_files_manifest],
64+
inputs = inputs.build(),
65+
outputs = [zip_main_py],
66+
mnemonic = "PyZipAppCreateMainPy",
67+
progress_message = "Generating zipapp __main__.py: %{label}",
4368
)
4469
return zip_main_py
4570

@@ -55,9 +80,7 @@ def _map_zip_symlinks(entry):
5580
def _map_zip_root_symlinks(entry):
5681
return "rf-root-symlink|" + _is_symlink(entry.target_file) + "|" + entry.path + "|" + entry.target_file.path
5782

58-
def _build_manifest(ctx, manifest, runfiles, zip_main):
59-
manifest.add("regular|0|__main__.py|{}".format(zip_main.path))
60-
83+
def _build_manifest(ctx, manifest, runfiles, inputs):
6184
manifest.add_all(
6285
# NOTE: Accessing runfiles.empty_filenames materializes them. A lambda
6386
# is used to defer that.
@@ -70,7 +93,10 @@ def _build_manifest(ctx, manifest, runfiles, zip_main):
7093
manifest.add_all(runfiles.symlinks, map_each = _map_zip_symlinks)
7194
manifest.add_all(runfiles.root_symlinks, map_each = _map_zip_root_symlinks)
7295

73-
inputs = [zip_main]
96+
inputs.add(runfiles.files)
97+
inputs.add([entry.target_file for entry in runfiles.symlinks.to_list()])
98+
inputs.add([entry.target_file for entry in runfiles.root_symlinks.to_list()])
99+
74100
zip_repo_mapping_manifest = maybe_create_repo_mapping(
75101
ctx = ctx,
76102
runfiles = runfiles,
@@ -82,8 +108,7 @@ def _build_manifest(ctx, manifest, runfiles, zip_main):
82108
zip_repo_mapping_manifest.path,
83109
format = "rf-root-symlink|0|_repo_mapping|%s",
84110
)
85-
inputs.append(zip_repo_mapping_manifest)
86-
return inputs
111+
inputs.add(zip_repo_mapping_manifest)
87112

88113
def _create_zip(ctx, py_runtime, py_executable, stage2_bootstrap):
89114
output = ctx.actions.declare_file(ctx.label.name + ".zip")
@@ -101,8 +126,17 @@ def _create_zip(ctx, py_runtime, py_executable, stage2_bootstrap):
101126

102127
runfiles = runfiles.build(ctx)
103128

104-
zip_main = _create_zipapp_main_py(ctx, py_runtime, py_executable, stage2_bootstrap)
105-
inputs = _build_manifest(ctx, manifest, runfiles, zip_main)
129+
zip_main = _create_zipapp_main_py(
130+
ctx,
131+
py_runtime,
132+
py_executable,
133+
stage2_bootstrap,
134+
runfiles,
135+
)
136+
inputs = builders.DepsetBuilder()
137+
manifest.add("regular|0|__main__.py|{}".format(zip_main.path))
138+
inputs.add(zip_main)
139+
_build_manifest(ctx, manifest, runfiles, inputs)
106140

107141
zipper_args = ctx.actions.args()
108142
zipper_args.add(output)
@@ -119,7 +153,7 @@ def _create_zip(ctx, py_runtime, py_executable, stage2_bootstrap):
119153
ctx,
120154
executable = ctx.attr._zipper,
121155
arguments = [manifest, zipper_args],
122-
inputs = depset(inputs, transitive = [runfiles.files]),
156+
inputs = inputs.build(),
123157
outputs = [output],
124158
mnemonic = "PyZipAppCreateZip",
125159
progress_message = "Reticulating zipapp archive: %{label} into %{output}",
@@ -310,6 +344,10 @@ Whether the output should be an executable zip file.
310344
"@platforms//os:windows",
311345
],
312346
),
347+
"_zip_main_maker": attr.label(
348+
cfg = "exec",
349+
default = "//tools/private/zipapp:zip_main_maker",
350+
),
313351
"_zip_shell_template": attr.label(
314352
default = ":zip_shell_template",
315353
allow_single_file = True,

python/private/zipapp/zip_main_template.py

Lines changed: 54 additions & 26 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, basename
2931

3032
# runfiles-root-relative path
3133
_STAGE2_BOOTSTRAP = "%stage2_bootstrap%"
@@ -35,6 +37,12 @@
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+
APP_HASH = "%APP_HASH%"
43+
44+
EXTRACT_ROOT = os.environ.get("RULES_PYTHON_EXTRACT_ROOT")
45+
IS_WINDOWS = os.name == "nt"
3846

3947

4048
def print_verbose(*args, mapping=None, values=None):
@@ -61,10 +69,6 @@ def print_verbose(*args, mapping=None, values=None):
6169
print("bootstrap: stage 1:", *args, file=sys.stderr, flush=True)
6270

6371

64-
# Return True if running on Windows
65-
def is_windows():
66-
return os.name == "nt"
67-
6872

6973
def get_windows_path_with_unc_prefix(path):
7074
"""Adds UNC prefix after getting a normalized absolute Windows path.
@@ -75,7 +79,7 @@ def get_windows_path_with_unc_prefix(path):
7579

7680
# No need to add prefix for non-Windows platforms.
7781
# And \\?\ doesn't work in python 2 or on mingw
78-
if not is_windows() or sys.version_info[0] < 3:
82+
if not IS_WINDOWS or sys.version_info[0] < 3:
7983
return path
8084

8185
# Starting in Windows 10, version 1607(OS build 14393), MAX_PATH limitations have been
@@ -107,7 +111,7 @@ def has_windows_executable_extension(path):
107111

108112
if (
109113
_PYTHON_BINARY_VENV
110-
and is_windows()
114+
and IS_WINDOWS
111115
and not has_windows_executable_extension(_PYTHON_BINARY_VENV)
112116
):
113117
_PYTHON_BINARY_VENV = _PYTHON_BINARY_VENV + ".exe"
@@ -118,7 +122,7 @@ def search_path(name):
118122
search_path = os.getenv("PATH", os.defpath).split(os.pathsep)
119123
for directory in search_path:
120124
if directory:
121-
path = os.path.join(directory, name)
125+
path = join(directory, name)
122126
if os.path.isfile(path) and os.access(path, os.X_OK):
123127
return path
124128
return None
@@ -139,7 +143,7 @@ def find_binary(runfiles_root, bin_name):
139143
# Use normpath() to convert slashes to os.sep on Windows.
140144
elif os.sep in os.path.normpath(bin_name):
141145
# Case 3: Path is relative to the repo root.
142-
return os.path.join(runfiles_root, bin_name)
146+
return join(runfiles_root, bin_name)
143147
else:
144148
# Case 4: Path has to be looked up in the search path.
145149
return search_path(bin_name)
@@ -161,10 +165,18 @@ def extract_zip(zip_path, dest_dir):
161165
dest_dir = get_windows_path_with_unc_prefix(dest_dir)
162166
with zipfile.ZipFile(zip_path) as zf:
163167
for info in zf.infolist():
168+
file_path = os.path.abspath(join(dest_dir, info.filename))
169+
# If the file exists, it might be a symlink or read-only file from a previous extraction.
170+
# Unlink it first so zipfile.extract doesn't corrupt the symlink target or fail on read-only files.
171+
if os.path.lexists(file_path) and not os.path.isdir(file_path):
172+
try:
173+
os.unlink(file_path)
174+
except OSError:
175+
# On Windows, unlinking a read-only file fails.
176+
os.chmod(file_path, stat.S_IWRITE)
177+
os.unlink(file_path)
178+
164179
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))
168180
# The Unix st_mode bits (see "man 7 inode") are stored in the upper 16
169181
# bits of external_attr.
170182
attrs = info.external_attr >> 16
@@ -182,11 +194,21 @@ def extract_zip(zip_path, dest_dir):
182194

183195
# Create the runfiles tree by extracting the zip file
184196
def create_runfiles_root():
185-
temp_dir = tempfile.mkdtemp("", "Bazel.runfiles_")
186-
extract_zip(os.path.dirname(__file__), temp_dir)
197+
if EXTRACT_ROOT:
198+
# Shorten the path for Windows in case long path support is disabled
199+
if IS_WINDOWS:
200+
hash_dir = APP_HASH[0:32]
201+
extract_dir = basename(EXTRACT_DIR)
202+
extract_root = join(EXTRACT_ROOT, extract_dir, hash_dir)
203+
else:
204+
extract_root = join(EXTRACT_ROOT, EXTRACT_DIR, APP_HASH)
205+
extract_root = get_windows_path_with_unc_prefix(extract_root)
206+
else:
207+
extract_root = tempfile.mkdtemp("", "Bazel.runfiles_")
208+
extract_zip(dirname(__file__), extract_root)
187209
# IMPORTANT: Later code does `rm -fr` on dirname(runfiles_root) -- it's
188210
# important that deletion code be in sync with this directory structure
189-
return os.path.join(temp_dir, "runfiles")
211+
return join(extract_root, "runfiles")
190212

191213

192214
def execute_file(
@@ -223,18 +245,24 @@ def execute_file(
223245
# - When running in a zip file, we need to clean up the
224246
# workspace after the process finishes so control must return here.
225247
try:
226-
subprocess_argv = [python_program, main_filename] + args
227-
print_verbose("subprocess argv:", values=subprocess_argv)
248+
subprocess_argv = [python_program]
249+
if not EXTRACT_ROOT:
250+
subprocess_argv.append(f"-XRULES_PYTHON_ZIP_DIR={dirname(runfiles_root)}")
251+
subprocess_argv.append(main_filename)
252+
subprocess_argv += args
228253
print_verbose("subprocess env:", mapping=env)
229254
print_verbose("subprocess cwd:", workspace)
255+
print_verbose("subprocess argv:", values=subprocess_argv)
230256
ret_code = subprocess.call(subprocess_argv, env=env, cwd=workspace)
231257
sys.exit(ret_code)
232258
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
259+
if not EXTRACT_ROOT:
260+
# NOTE: dirname() is called because create_runfiles_root() creates a
261+
# sub-directory within a temporary directory, and we want to remove the
262+
# whole temporary directory.
263+
extract_root = dirname(runfiles_root)
264+
print_verbose("cleanup: rmtree: ", extract_root)
265+
shutil.rmtree(extract_root, True)
238266

239267

240268
def main():
@@ -254,7 +282,7 @@ def main():
254282

255283
# The main Python source file.
256284
main_rel_path = _STAGE2_BOOTSTRAP
257-
if is_windows():
285+
if IS_WINDOWS:
258286
main_rel_path = main_rel_path.replace("/", os.sep)
259287

260288
runfiles_root = create_runfiles_root()
@@ -266,7 +294,7 @@ def main():
266294
# See: https://docs.python.org/3.11/using/cmdline.html#envvar-PYTHONSAFEPATH
267295
new_env["PYTHONSAFEPATH"] = "1"
268296

269-
main_filename = os.path.join(runfiles_root, main_rel_path)
297+
main_filename = join(runfiles_root, main_rel_path)
270298
main_filename = get_windows_path_with_unc_prefix(main_filename)
271299
assert os.path.exists(main_filename), (
272300
"Cannot exec() %r: file not found." % main_filename
@@ -276,7 +304,7 @@ def main():
276304
)
277305

278306
if _PYTHON_BINARY_VENV:
279-
python_program = os.path.join(runfiles_root, _PYTHON_BINARY_VENV)
307+
python_program = join(runfiles_root, _PYTHON_BINARY_VENV)
280308
# When a venv is used, the `bin/python3` symlink may need to be created.
281309
# This case occurs when "create venv at runtime" or "resolve python at
282310
# runtime" modes are enabled.
@@ -288,7 +316,7 @@ def main():
288316
"Program's venv binary not under runfiles: {python_program}"
289317
)
290318
symlink_to = find_binary(runfiles_root, _PYTHON_BINARY_ACTUAL)
291-
os.makedirs(os.path.dirname(python_program), exist_ok=True)
319+
os.makedirs(dirname(python_program), exist_ok=True)
292320
try:
293321
os.symlink(symlink_to, python_program)
294322
except OSError as e:
@@ -317,7 +345,7 @@ def main():
317345
# change directory to the right runfiles directory.
318346
# (So that the data files are accessible)
319347
if os.environ.get("RUN_UNDER_RUNFILES") == "1":
320-
workspace = os.path.join(runfiles_root, _WORKSPACE_NAME)
348+
workspace = join(runfiles_root, _WORKSPACE_NAME)
321349

322350
sys.stdout.flush()
323351
execute_file(

tests/py_zipapp/system_python_zipapp_external_bootstrap_test.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,42 @@ 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 "====================================================================="
20+
echo "Running zipapp using an automatic temp directory..."
21+
echo "====================================================================="
22+
"$PYTHON" "$ZIPAPP"
23+
24+
echo
25+
echo
26+
27+
echo "====================================================================="
28+
echo "Running zipapp with extract root set..."
29+
echo "====================================================================="
30+
export RULES_PYTHON_EXTRACT_ROOT="${TEST_TMPDIR:-/tmp}/extract_root_test"
31+
"$PYTHON" "$ZIPAPP"
32+
33+
# Verify that the directory was created
34+
if [[ ! -d "$RULES_PYTHON_EXTRACT_ROOT" ]]; then
35+
echo "Error: Extract root directory $RULES_PYTHON_EXTRACT_ROOT was not created!"
36+
exit 1
37+
fi
38+
39+
# On windows, the path is shortened to just the basename to avoid long path errors.
40+
# Other platforms use the full path.
41+
# Note: [ -d ... ] expands globs, while [[ -d ... ]] does not.
42+
if [ -d "$RULES_PYTHON_EXTRACT_ROOT/_main/tests/py_zipapp/system_python_zipapp"/*/runfiles ]; then
43+
echo "Found runfiles at $RULES_PYTHON_EXTRACT_ROOT/_main/tests/py_zipapp/system_python_zipapp/*/runfiles"
44+
elif [ -d "$RULES_PYTHON_EXTRACT_ROOT/system_python_zipapp"/*/runfiles ]; then
45+
echo "Found runfiles at $RULES_PYTHON_EXTRACT_ROOT/system_python_zipapp/*/runfiles"
46+
else
47+
echo "Error: Could not find 'runfiles' directory"
48+
exit 1
49+
fi
50+
51+
echo "====================================================================="
52+
echo "Running zipapp with extract root set a second time..."
53+
echo "====================================================================="
1854
"$PYTHON" "$ZIPAPP"

tests/tools/zipapp/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ py_test(
1111
srcs = ["exe_zip_maker_test.py"],
1212
deps = ["//tools/private/zipapp:exe_zip_maker_lib"],
1313
)
14+
15+
py_test(
16+
name = "zip_main_maker_test",
17+
srcs = ["zip_main_maker_test.py"],
18+
deps = ["//tools/private/zipapp:zip_main_maker_lib"],
19+
)

0 commit comments

Comments
 (0)