Skip to content

Commit 8ff4386

Browse files
rickeylevaignas
andauthored
fix: make sys.executable work with script bootstrap (#2409)
When `--bootstrap_impl=script` is used, `PYTHONPATH` is no longer used to set the import paths, which means subprocesses no longer inherit the Bazel paths. This is generally a good thing, but breaks when `sys.executable` is used to directly invoke the interpreter. Such an invocation assumes the interpreter will have the same packages available and works with the system_python bootstrap. To fix, have the script bootstrap use a basic virtual env. This allows it to intercept interpreter startup even when the Bazel executable isn't invoked. Under the hood, there's two pieces to make this work. The first is a binary uses `declare_symlink()` to write a relative-path based symlink that points to the underlying Python interpreter. The second piece is a site init hook (triggered by a `.pth` file using an `import` line) performs sys.path setup as part of site (`import site`) initialization. Fixes #2169 --------- Co-authored-by: Ignas Anikevicius <[email protected]>
1 parent 4a55ef4 commit 8ff4386

15 files changed

+617
-159
lines changed

.bazelrc

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
# (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it)
55
# To update these lines, execute
66
# `bazel run @rules_bazel_integration_test//tools:update_deleted_packages`
7-
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/custom_commands,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/local_toolchains,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
8-
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/custom_commands,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/local_toolchains,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
7+
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,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/pythonconfig,gazelle/python/private,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/local_toolchains,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
8+
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,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/pythonconfig,gazelle/python/private,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/local_toolchains,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/py_cc_toolchain_registered
99

1010
test --test_output=errors
1111

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ Unreleased changes template.
7474
Other changes:
7575
* (python_repository) Start honoring the `strip_prefix` field for `zstd` archives.
7676
* (pypi) {bzl:obj}`pip_parse.extra_hub_aliases` now works in WORKSPACE files.
77+
* (binaries/tests) For {obj}`--bootstrap_impl=script`, a binary-specific (but
78+
otherwise empty) virtual env is used to customize `sys.path` initialization.
7779

7880
{#v0-0-0-fixed}
7981
### Fixed
@@ -83,6 +85,9 @@ Other changes:
8385
Fixes ([2337](https://github.com/bazelbuild/rules_python/issues/2337)).
8486
* (uv): Correct the sha256sum for the `uv` binary for aarch64-apple-darwin.
8587
Fixes ([2411](https://github.com/bazelbuild/rules_python/issues/2411)).
88+
* (binaries/tests) ({obj}`--bootstrap_impl=scipt`) Using `sys.executable` will
89+
use the same `sys.path` setup as the calling binary.
90+
([2169](https://github.com/bazelbuild/rules_python/issues/2169)).
8691

8792
{#v0-0-0-added}
8893
### Added
@@ -97,6 +102,9 @@ Other changes:
97102
for the latest toolchain versions for each minor Python version. You can control
98103
the toolchain selection by using the
99104
{bzl:obj}`//python/config_settings:py_linux_libc` build flag.
105+
* (providers) Added {obj}`py_runtime_info.site_init_template` and
106+
{obj}`PyRuntimeInfo.site_init_template` for specifying the template to use to
107+
initialize the interpreter via venv startup hooks.
100108

101109
{#v0-0-0-removed}
102110
### Removed

python/private/BUILD.bazel

+8
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,14 @@ filegroup(
702702
visibility = ["//visibility:public"],
703703
)
704704

705+
filegroup(
706+
name = "site_init_template",
707+
srcs = ["site_init_template.py"],
708+
# Not actually public. Only public because it's an implicit dependency of
709+
# py_runtime.
710+
visibility = ["//visibility:public"],
711+
)
712+
705713
# NOTE: Windows builds don't use this bootstrap. Instead, a native Windows
706714
# program locates some Python exe and runs `python.exe foo.zip` which
707715
# runs the __main__.py in the zip file.

python/private/py_executable_bazel.bzl

+133-6
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ the `srcs` of Python targets as required.
8181
"_py_toolchain_type": attr.label(
8282
default = TARGET_TOOLCHAIN_TYPE,
8383
),
84+
"_python_version_flag": attr.label(
85+
default = "//python/config_settings:python_version",
86+
),
8487
"_windows_launcher_maker": attr.label(
8588
default = "@bazel_tools//tools/launcher:launcher_maker",
8689
cfg = "exec",
@@ -177,13 +180,22 @@ def _create_executable(
177180
else:
178181
base_executable_name = executable.basename
179182

183+
venv = None
184+
180185
# The check for stage2_bootstrap_template is to support legacy
181186
# BuiltinPyRuntimeInfo providers, which is likely to come from
182187
# @bazel_tools//tools/python:autodetecting_toolchain, the toolchain used
183188
# for workspace builds when no rules_python toolchain is configured.
184189
if (BootstrapImplFlag.get_value(ctx) == BootstrapImplFlag.SCRIPT and
185190
runtime_details.effective_runtime and
186191
hasattr(runtime_details.effective_runtime, "stage2_bootstrap_template")):
192+
venv = _create_venv(
193+
ctx,
194+
output_prefix = base_executable_name,
195+
imports = imports,
196+
runtime_details = runtime_details,
197+
)
198+
187199
stage2_bootstrap = _create_stage2_bootstrap(
188200
ctx,
189201
output_prefix = base_executable_name,
@@ -192,11 +204,12 @@ def _create_executable(
192204
imports = imports,
193205
runtime_details = runtime_details,
194206
)
195-
extra_runfiles = ctx.runfiles([stage2_bootstrap])
207+
extra_runfiles = ctx.runfiles([stage2_bootstrap] + venv.files_without_interpreter)
196208
zip_main = _create_zip_main(
197209
ctx,
198210
stage2_bootstrap = stage2_bootstrap,
199211
runtime_details = runtime_details,
212+
venv = venv,
200213
)
201214
else:
202215
stage2_bootstrap = None
@@ -272,6 +285,7 @@ def _create_executable(
272285
zip_file = zip_file,
273286
stage2_bootstrap = stage2_bootstrap,
274287
runtime_details = runtime_details,
288+
venv = venv,
275289
)
276290
elif bootstrap_output:
277291
_create_stage1_bootstrap(
@@ -282,6 +296,7 @@ def _create_executable(
282296
is_for_zip = False,
283297
imports = imports,
284298
main_py = main_py,
299+
venv = venv,
285300
)
286301
else:
287302
# Otherwise, this should be the Windows case of launcher + zip.
@@ -296,13 +311,20 @@ def _create_executable(
296311
build_zip_enabled = build_zip_enabled,
297312
))
298313

314+
# The interpreter is added this late in the process so that it isn't
315+
# added to the zipped files.
316+
if venv:
317+
extra_runfiles = extra_runfiles.merge(ctx.runfiles([venv.interpreter]))
299318
return create_executable_result_struct(
300319
extra_files_to_build = depset(extra_files_to_build),
301320
output_groups = {"python_zip_file": depset([zip_file])},
302321
extra_runfiles = extra_runfiles,
303322
)
304323

305-
def _create_zip_main(ctx, *, stage2_bootstrap, runtime_details):
324+
def _create_zip_main(ctx, *, stage2_bootstrap, runtime_details, venv):
325+
python_binary = _runfiles_root_path(ctx, venv.interpreter.short_path)
326+
python_binary_actual = _runfiles_root_path(ctx, venv.interpreter_actual_path)
327+
306328
# The location of this file doesn't really matter. It's added to
307329
# the zip file as the top-level __main__.py file and not included
308330
# elsewhere.
@@ -311,7 +333,8 @@ def _create_zip_main(ctx, *, stage2_bootstrap, runtime_details):
311333
template = runtime_details.effective_runtime.zip_main_template,
312334
output = output,
313335
substitutions = {
314-
"%python_binary%": runtime_details.executable_interpreter_path,
336+
"%python_binary%": python_binary,
337+
"%python_binary_actual%": python_binary_actual,
315338
"%stage2_bootstrap%": "{}/{}".format(
316339
ctx.workspace_name,
317340
stage2_bootstrap.short_path,
@@ -321,6 +344,82 @@ def _create_zip_main(ctx, *, stage2_bootstrap, runtime_details):
321344
)
322345
return output
323346

347+
# Create a venv the executable can use.
348+
# For venv details and the venv startup process, see:
349+
# * https://docs.python.org/3/library/venv.html
350+
# * https://snarky.ca/how-virtual-environments-work/
351+
# * https://github.com/python/cpython/blob/main/Modules/getpath.py
352+
# * https://github.com/python/cpython/blob/main/Lib/site.py
353+
def _create_venv(ctx, output_prefix, imports, runtime_details):
354+
venv = "_{}.venv".format(output_prefix.lstrip("_"))
355+
356+
# The pyvenv.cfg file must be present to trigger the venv site hooks.
357+
# Because it's paths are expected to be absolute paths, we can't reliably
358+
# put much in it. See https://github.com/python/cpython/issues/83650
359+
pyvenv_cfg = ctx.actions.declare_file("{}/pyvenv.cfg".format(venv))
360+
ctx.actions.write(pyvenv_cfg, "")
361+
362+
runtime = runtime_details.effective_runtime
363+
if runtime.interpreter:
364+
py_exe_basename = paths.basename(runtime.interpreter.short_path)
365+
366+
# Even though ctx.actions.symlink() is used, using
367+
# declare_symlink() is required to ensure that the resulting file
368+
# in runfiles is always a symlink. An RBE implementation, for example,
369+
# may choose to write what symlink() points to instead.
370+
interpreter = ctx.actions.declare_symlink("{}/bin/{}".format(venv, py_exe_basename))
371+
interpreter_actual_path = runtime.interpreter.short_path
372+
parent = "/".join([".."] * (interpreter_actual_path.count("/") + 1))
373+
rel_path = parent + "/" + interpreter_actual_path
374+
ctx.actions.symlink(output = interpreter, target_path = rel_path)
375+
else:
376+
py_exe_basename = paths.basename(runtime.interpreter_path)
377+
interpreter = ctx.actions.declare_symlink("{}/bin/{}".format(venv, py_exe_basename))
378+
ctx.actions.symlink(output = interpreter, target_path = runtime.interpreter_path)
379+
interpreter_actual_path = runtime.interpreter_path
380+
381+
if runtime.interpreter_version_info:
382+
version = "{}.{}".format(
383+
runtime.interpreter_version_info.major,
384+
runtime.interpreter_version_info.minor,
385+
)
386+
else:
387+
version_flag = ctx.attr._python_version_flag[config_common.FeatureFlagInfo].value
388+
version_flag_parts = version_flag.split(".")[0:2]
389+
version = "{}.{}".format(*version_flag_parts)
390+
391+
# See site.py logic: free-threaded builds append "t" to the venv lib dir name
392+
if "t" in runtime.abi_flags:
393+
version += "t"
394+
395+
site_packages = "{}/lib/python{}/site-packages".format(venv, version)
396+
pth = ctx.actions.declare_file("{}/bazel.pth".format(site_packages))
397+
ctx.actions.write(pth, "import _bazel_site_init\n")
398+
399+
site_init = ctx.actions.declare_file("{}/_bazel_site_init.py".format(site_packages))
400+
computed_subs = ctx.actions.template_dict()
401+
computed_subs.add_joined("%imports%", imports, join_with = ":", map_each = _map_each_identity)
402+
ctx.actions.expand_template(
403+
template = runtime.site_init_template,
404+
output = site_init,
405+
substitutions = {
406+
"%import_all%": "True" if ctx.fragments.bazel_py.python_import_all_repositories else "False",
407+
"%site_init_runfiles_path%": "{}/{}".format(ctx.workspace_name, site_init.short_path),
408+
"%workspace_name%": ctx.workspace_name,
409+
},
410+
computed_substitutions = computed_subs,
411+
)
412+
413+
return struct(
414+
interpreter = interpreter,
415+
# Runfiles-relative path or absolute path
416+
interpreter_actual_path = interpreter_actual_path,
417+
files_without_interpreter = [pyvenv_cfg, pth, site_init],
418+
)
419+
420+
def _map_each_identity(v):
421+
return v
422+
324423
def _create_stage2_bootstrap(
325424
ctx,
326425
*,
@@ -363,6 +462,13 @@ def _create_stage2_bootstrap(
363462
)
364463
return output
365464

465+
def _runfiles_root_path(ctx, path):
466+
# The ../ comes from short_path for files in other repos.
467+
if path.startswith("../"):
468+
return path[3:]
469+
else:
470+
return "{}/{}".format(ctx.workspace_name, path)
471+
366472
def _create_stage1_bootstrap(
367473
ctx,
368474
*,
@@ -371,12 +477,24 @@ def _create_stage1_bootstrap(
371477
stage2_bootstrap = None,
372478
imports = None,
373479
is_for_zip,
374-
runtime_details):
480+
runtime_details,
481+
venv = None):
375482
runtime = runtime_details.effective_runtime
376483

484+
if venv:
485+
python_binary_path = _runfiles_root_path(ctx, venv.interpreter.short_path)
486+
else:
487+
python_binary_path = runtime_details.executable_interpreter_path
488+
489+
if is_for_zip and venv:
490+
python_binary_actual = _runfiles_root_path(ctx, venv.interpreter_actual_path)
491+
else:
492+
python_binary_actual = ""
493+
377494
subs = {
378495
"%is_zipfile%": "1" if is_for_zip else "0",
379-
"%python_binary%": runtime_details.executable_interpreter_path,
496+
"%python_binary%": python_binary_path,
497+
"%python_binary_actual%": python_binary_actual,
380498
"%target%": str(ctx.label),
381499
"%workspace_name%": ctx.workspace_name,
382500
}
@@ -447,6 +565,7 @@ def _create_windows_exe_launcher(
447565
)
448566

449567
def _create_zip_file(ctx, *, output, original_nonzip_executable, zip_main, runfiles):
568+
"""Create a Python zipapp (zip with __main__.py entry point)."""
450569
workspace_name = ctx.workspace_name
451570
legacy_external_runfiles = _py_builtins.get_legacy_external_runfiles(ctx)
452571

@@ -524,7 +643,14 @@ def _get_zip_runfiles_path(path, workspace_name, legacy_external_runfiles):
524643
zip_runfiles_path = paths.normalize("{}/{}".format(workspace_name, path))
525644
return "{}/{}".format(_ZIP_RUNFILES_DIRECTORY_NAME, zip_runfiles_path)
526645

527-
def _create_executable_zip_file(ctx, *, output, zip_file, stage2_bootstrap, runtime_details):
646+
def _create_executable_zip_file(
647+
ctx,
648+
*,
649+
output,
650+
zip_file,
651+
stage2_bootstrap,
652+
runtime_details,
653+
venv):
528654
prelude = ctx.actions.declare_file(
529655
"{}_zip_prelude.sh".format(output.basename),
530656
sibling = output,
@@ -536,6 +662,7 @@ def _create_executable_zip_file(ctx, *, output, zip_file, stage2_bootstrap, runt
536662
stage2_bootstrap = stage2_bootstrap,
537663
runtime_details = runtime_details,
538664
is_for_zip = True,
665+
venv = venv,
539666
)
540667
else:
541668
ctx.actions.write(prelude, "#!/usr/bin/env python3\n")

0 commit comments

Comments
 (0)