Skip to content

Commit 3b39c60

Browse files
committed
wip: make sys.executable work with script bootstrap
1 parent 273cbd1 commit 3b39c60

14 files changed

+639
-92
lines changed

CHANGELOG.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,20 @@ Unreleased changes template.
5252

5353
{#v0-0-0-changed}
5454
### Changed
55-
* Nothing yet.
55+
* (binaries/tests) For {obj}`--bootstrap_impl=script`, an empty,
56+
binary-specific, virtual env is used to customize sys.path initialization.
5657

5758
{#v0-0-0-fixed}
5859
### Fixed
59-
* Nothing yet.
60+
* (binaries/tests) ({obj}`--bootstrap_impl=scipt`) Using `sys.executable` will
61+
use the same `sys.path` setup as the calling binary.
62+
([2169](https://github.com/bazelbuild/rules_python/issues/2169)).
6063

6164
{#v0-0-0-added}
6265
### Added
63-
* Nothing yet.
66+
* (providers) Added {obj}`py_runtime_info.site_init_template` and
67+
{obj}`PyRuntimeInfo.site_init_template` for specifying the template to use to
68+
initialize the interpreter via venv startup hooks.
6469

6570
{#v0-0-0-removed}
6671
### Removed
@@ -99,6 +104,9 @@ Unreleased changes template.
99104
* (precompiling) Skip precompiling (instead of erroring) if the legacy
100105
`@bazel_tools//tools/python:autodetecting_toolchain` is being used
101106
([#2364](https://github.com/bazelbuild/rules_python/issues/2364)).
107+
* (bzlmod) Generate `config_setting` values for all available toolchains instead
108+
of only the registered toolchains, which restores the previous behaviour that
109+
`bzlmod` users would have observed.
102110

103111
{#v0-39-0-added}
104112
### Added

python/private/BUILD.bazel

Lines changed: 8 additions & 0 deletions
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

Lines changed: 126 additions & 6 deletions
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 files that zipping processes.
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,75 @@ 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+
interpreter = ctx.actions.declare_file("{}/bin/{}".format(venv, py_exe_basename))
366+
ctx.actions.symlink(output = interpreter, target_file = runtime.interpreter)
367+
interpreter_actual_path = runtime.interpreter.short_path
368+
else:
369+
py_exe_basename = paths.basename(runtime.interpreter_path)
370+
interpreter = ctx.actions.declare_symlink("{}/bin/{}".format(venv, py_exe_basename))
371+
ctx.actions.symlink(output = interpreter, target_path = runtime.interpreter_path)
372+
interpreter_actual_path = runtime.interpreter_path
373+
374+
if runtime.interpreter_version_info:
375+
version = "{}.{}".format(
376+
runtime.interpreter_version_info.major,
377+
runtime.interpreter_version_info.minor,
378+
)
379+
else:
380+
version_flag = ctx.attr._python_version_flag[config_common.FeatureFlagInfo].value
381+
version_flag_parts = version_flag.split(".")[0:2]
382+
version = "{}.{}".format(*version_flag_parts)
383+
384+
# See site.py logic: free-threaded builds append "t" to the venv lib dir name
385+
if "t" in runtime.abi_flags:
386+
version += "t"
387+
388+
site_packages = "{}/lib/python{}/site-packages".format(venv, version)
389+
pth = ctx.actions.declare_file("{}/bazel.pth".format(site_packages))
390+
ctx.actions.write(pth, "import _bazel_site_init\n")
391+
392+
site_init = ctx.actions.declare_file("{}/_bazel_site_init.py".format(site_packages))
393+
computed_subs = ctx.actions.template_dict()
394+
computed_subs.add_joined("%imports%", imports, join_with = ":", map_each = _map_each_identity)
395+
ctx.actions.expand_template(
396+
template = runtime.site_init_template,
397+
output = site_init,
398+
substitutions = {
399+
"%import_all%": "True" if ctx.fragments.bazel_py.python_import_all_repositories else "False",
400+
"%site_init_runfiles_path%": "{}/{}".format(ctx.workspace_name, site_init.short_path),
401+
"%workspace_name%": ctx.workspace_name,
402+
},
403+
computed_substitutions = computed_subs,
404+
)
405+
406+
return struct(
407+
interpreter = interpreter,
408+
# Runfiles-relative path or absolute path
409+
interpreter_actual_path = interpreter_actual_path,
410+
files_without_interpreter = [pyvenv_cfg, pth, site_init],
411+
)
412+
413+
def _map_each_identity(v):
414+
return v
415+
324416
def _create_stage2_bootstrap(
325417
ctx,
326418
*,
@@ -363,6 +455,13 @@ def _create_stage2_bootstrap(
363455
)
364456
return output
365457

458+
def _runfiles_root_path(ctx, path):
459+
# The ../ comes from short_path for files in other repos.
460+
if path.startswith("../"):
461+
return path[3:]
462+
else:
463+
return "{}/{}".format(ctx.workspace_name, path)
464+
366465
def _create_stage1_bootstrap(
367466
ctx,
368467
*,
@@ -371,12 +470,24 @@ def _create_stage1_bootstrap(
371470
stage2_bootstrap = None,
372471
imports = None,
373472
is_for_zip,
374-
runtime_details):
473+
runtime_details,
474+
venv = None):
375475
runtime = runtime_details.effective_runtime
376476

477+
if venv:
478+
python_binary_path = _runfiles_root_path(ctx, venv.interpreter.short_path)
479+
else:
480+
python_binary_path = runtime_details.executable_interpreter_path
481+
482+
if is_for_zip and venv:
483+
python_binary_actual = _runfiles_root_path(ctx, venv.interpreter_actual_path)
484+
else:
485+
python_binary_actual = ""
486+
377487
subs = {
378488
"%is_zipfile%": "1" if is_for_zip else "0",
379-
"%python_binary%": runtime_details.executable_interpreter_path,
489+
"%python_binary%": python_binary_path,
490+
"%python_binary_actual%": python_binary_actual,
380491
"%target%": str(ctx.label),
381492
"%workspace_name%": ctx.workspace_name,
382493
}
@@ -447,6 +558,7 @@ def _create_windows_exe_launcher(
447558
)
448559

449560
def _create_zip_file(ctx, *, output, original_nonzip_executable, zip_main, runfiles):
561+
"""Create a Python zipapp (zip with __main__.py entry point)."""
450562
workspace_name = ctx.workspace_name
451563
legacy_external_runfiles = _py_builtins.get_legacy_external_runfiles(ctx)
452564

@@ -524,7 +636,14 @@ def _get_zip_runfiles_path(path, workspace_name, legacy_external_runfiles):
524636
zip_runfiles_path = paths.normalize("{}/{}".format(workspace_name, path))
525637
return "{}/{}".format(_ZIP_RUNFILES_DIRECTORY_NAME, zip_runfiles_path)
526638

527-
def _create_executable_zip_file(ctx, *, output, zip_file, stage2_bootstrap, runtime_details):
639+
def _create_executable_zip_file(
640+
ctx,
641+
*,
642+
output,
643+
zip_file,
644+
stage2_bootstrap,
645+
runtime_details,
646+
venv):
528647
prelude = ctx.actions.declare_file(
529648
"{}_zip_prelude.sh".format(output.basename),
530649
sibling = output,
@@ -536,6 +655,7 @@ def _create_executable_zip_file(ctx, *, output, zip_file, stage2_bootstrap, runt
536655
stage2_bootstrap = stage2_bootstrap,
537656
runtime_details = runtime_details,
538657
is_for_zip = True,
658+
venv = venv,
539659
)
540660
else:
541661
ctx.actions.write(prelude, "#!/usr/bin/env python3\n")

python/private/py_runtime_info.bzl

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ def _PyRuntimeInfo_init(
6868
interpreter_version_info = None,
6969
stage2_bootstrap_template = None,
7070
zip_main_template = None,
71-
abi_flags = ""):
71+
abi_flags = "",
72+
site_init_template = None):
7273
if (interpreter_path and interpreter) or (not interpreter_path and not interpreter):
7374
fail("exactly one of interpreter or interpreter_path must be specified")
7475

@@ -117,6 +118,7 @@ def _PyRuntimeInfo_init(
117118
"interpreter_version_info": interpreter_version_info_struct_from_dict(interpreter_version_info),
118119
"pyc_tag": pyc_tag,
119120
"python_version": python_version,
121+
"site_init_template": site_init_template,
120122
"stage2_bootstrap_template": stage2_bootstrap_template,
121123
"stub_shebang": stub_shebang,
122124
"zip_main_template": zip_main_template,
@@ -126,6 +128,11 @@ PyRuntimeInfo, _unused_raw_py_runtime_info_ctor = define_bazel_6_provider(
126128
doc = """Contains information about a Python runtime, as returned by the `py_runtime`
127129
rule.
128130
131+
:::{warning}
132+
This is an **unstable public** API. It may change more frequently and has weaker
133+
compatibility guarantees.
134+
:::
135+
129136
A Python runtime describes either a *platform runtime* or an *in-build runtime*.
130137
A platform runtime accesses a system-installed interpreter at a known path,
131138
whereas an in-build runtime points to a `File` that acts as the interpreter. In
@@ -139,6 +146,9 @@ the same conventions as the standard CPython interpreter.
139146
:type: str
140147
141148
The runtime's ABI flags, i.e. `sys.abiflags`.
149+
150+
:::{versionadded} 0.39.0
151+
:::
142152
""",
143153
"bootstrap_template": """
144154
:type: File
@@ -160,7 +170,8 @@ is expected to behave and the substutitions performed.
160170
`%target%`, `%workspace_name`, `%coverage_tool%`, `%import_all%`, `%imports%`,
161171
`%main%`, `%shebang%`
162172
* `--bootstrap_impl=script` substititions: `%is_zipfile%`, `%python_binary%`,
163-
`%target%`, `%workspace_name`, `%shebang%, `%stage2_bootstrap%`
173+
`%python_binary_actual%`, `%target%`, `%workspace_name`,
174+
`%shebang%`, `%stage2_bootstrap%`
164175
165176
Substitution definitions:
166177
@@ -172,6 +183,19 @@ Substitution definitions:
172183
* An absolute path to a system interpreter (e.g. begins with `/`).
173184
* A runfiles-relative path to an interpreter (e.g. `somerepo/bin/python3`)
174185
* A program to search for on PATH, i.e. a word without spaces, e.g. `python3`.
186+
187+
When `--bootstrap_impl=script` is used, this is always a runfiles-relative
188+
path to a venv-based interpreter executable.
189+
190+
* `%python_binary_actual%`: The path to the interpreter that
191+
`%python_binary%` invokes. There are three types of paths:
192+
* An absolute path to a system interpreter (e.g. begins with `/`).
193+
* A runfiles-relative path to an interpreter (e.g. `somerepo/bin/python3`)
194+
* A program to search for on PATH, i.e. a word without spaces, e.g. `python3`.
195+
196+
Only set for zip builds with `--bootstrap_impl=script`; other builds will use
197+
an empty string.
198+
175199
* `%workspace_name%`: The name of the workspace the target belongs to.
176200
* `%is_zipfile%`: The string `1` if this template is prepended to a zipfile to
177201
create a self-executable zip file. The string `0` otherwise.
@@ -250,6 +274,15 @@ correctly.
250274
251275
Indicates whether this runtime uses Python major version 2 or 3. Valid values
252276
are (only) `"PY2"` and `"PY3"`.
277+
""",
278+
"site_init_template": """
279+
:type: File
280+
281+
The template to use for the binary-specific site-init hook run by the
282+
interpreter at startup.
283+
284+
:::{versionadded} VERSION_NEXT_FEATURE
285+
:::
253286
""",
254287
"stage2_bootstrap_template": """
255288
:type: File

0 commit comments

Comments
 (0)