Skip to content

Commit a6e7d34

Browse files
authored
feat(venv): make wheel scripts runnable in venv (bazel-contrib#3743)
Currently, `#!python` based scripts in a wheel are put into the venv as-is, so aren't actually runnable. Additionally, wheel entry points aren't installed into the venv. To fix, rewrite the scripts to re-exec themselves with the `python3` binary in the same directory. Also adds support for wheel entry points in venvs. This is done by having the repo-phase parse entry_points.txt and defining a target for each entry point. These targets run during build phase, so are able to generate platform-specific outputs. Cross-building with Windows is also added, supporting both Windows as a target or exec platform to generate outputs for the target platform. Fixes bazel-contrib#3202
1 parent eda9fe3 commit a6e7d34

29 files changed

Lines changed: 806 additions & 40 deletions

File tree

examples/pip_parse/pip_parse_test.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,22 @@ def test_entry_point(self):
5050
def test_data(self):
5151
actual = os.environ.get("WHEEL_DATA_CONTENTS")
5252
self.assertIsNotNone(actual)
53-
actual = self._remove_leading_dirs(actual.split(" "))
53+
actual = set(self._remove_leading_dirs(actual.split(" ")))
54+
55+
s3cmd_bin = "bin/s3cmd"
56+
if os.name == "nt":
57+
s3cmd_bin += ".bat"
5458

55-
expected = [
56-
"bin/s3cmd",
59+
expected = {
60+
s3cmd_bin,
5761
"data/share/doc/packages/s3cmd/INSTALL.md",
5862
"data/share/doc/packages/s3cmd/LICENSE",
5963
"data/share/doc/packages/s3cmd/NEWS",
6064
"data/share/doc/packages/s3cmd/README.md",
6165
"data/share/man/man1/s3cmd.1",
62-
]
66+
}
6367

64-
self.assertListEqual(actual, expected)
68+
self.assertEqual(actual, expected)
6569

6670
def test_dist_info(self):
6771
actual = os.environ.get("WHEEL_DIST_INFO_CONTENTS")

python/private/py_executable.bzl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,14 @@ def _create_venv_windows(ctx, *, venv_ctx_rel_root, runtime, interpreter_actual_
749749
link_to_path = interpreter_actual_path,
750750
files = depset([runtime.interpreter]),
751751
))
752+
753+
# This isn't strictly correct, but should work ok.
754+
interpreter_symlinks.add(ExplicitSymlink(
755+
runfiles_path = paths.join(paths.dirname(rf_path), "pythonw.exe"),
756+
venv_path = paths.join(paths.dirname(venv_rel_path), "pythonw.exe"),
757+
link_to_path = paths.join(paths.dirname(interpreter_actual_path), "pythonw.exe"),
758+
files = depset(),
759+
))
752760
else:
753761
# It's OK to use declare_symlink here because an absolute path
754762
# will be written to it, so Bazel won't mangle it.

python/private/pypi/BUILD.bazel

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ exports_files(
2424
visibility = ["//visibility:public"],
2525
)
2626

27+
alias(
28+
name = "venv_entry_point_template",
29+
actual = select({
30+
"@platforms//os:windows": "venv_entry_point_template.bat",
31+
"//conditions:default": "venv_entry_point_template.sh",
32+
}),
33+
visibility = ["//visibility:public"],
34+
)
35+
36+
alias(
37+
name = "venv_shebang_rewriter",
38+
actual = select({
39+
"@platforms//os:windows": "venv_shebang_rewriter.ps1",
40+
"//conditions:default": "venv_shebang_rewriter.sh",
41+
}),
42+
visibility = ["//visibility:public"],
43+
)
44+
2745
exports_files(
2846
srcs = ["deps.bzl"],
2947
visibility = ["//tools/private/update_deps:__pkg__"],
@@ -520,3 +538,15 @@ bzl_library(
520538
name = "whl_target_platforms_bzl",
521539
srcs = ["whl_target_platforms.bzl"],
522540
)
541+
542+
bzl_library(
543+
name = "venv_entry_point_bzl",
544+
srcs = ["venv_entry_point.bzl"],
545+
visibility = ["//visibility:public"],
546+
)
547+
548+
bzl_library(
549+
name = "venv_rewrite_shebang_bzl",
550+
srcs = ["venv_rewrite_shebang.bzl"],
551+
visibility = ["//visibility:public"],
552+
)

python/private/pypi/generate_whl_library_build_bazel.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ _RENDER = {
2323
"data_exclude": render.list,
2424
"dependencies": render.list,
2525
"dependencies_by_platform": lambda x: render.dict(x, value_repr = render.list),
26+
"entry_points": render.dict_dict,
2627
"extras": render.list,
2728
"group_deps": render.list,
2829
"include": str,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Rule for generating venv entry point scripts."""
2+
3+
load("//python/private:attributes.bzl", "WINDOWS_CONSTRAINTS_ATTRS")
4+
load("//python/private:common.bzl", "is_windows_platform")
5+
load("//python/private:rule_builders.bzl", "ruleb")
6+
7+
def _venv_entry_point_impl(ctx):
8+
is_windows = is_windows_platform(ctx)
9+
10+
out_name = ctx.label.name
11+
python_exe = ""
12+
if is_windows:
13+
out_name += ".bat"
14+
python_exe = "pythonw.exe" if ctx.attr.group == "gui_scripts" else "python.exe"
15+
16+
out = ctx.actions.declare_file(out_name)
17+
18+
ctx.actions.expand_template(
19+
template = ctx.file._template,
20+
output = out,
21+
substitutions = {
22+
"{ATTRIBUTE}": ctx.attr.attribute,
23+
"{MODULE}": ctx.attr.module,
24+
"{PYTHON_EXE}": python_exe,
25+
},
26+
is_executable = True,
27+
)
28+
29+
return [DefaultInfo(
30+
files = depset([out]),
31+
executable = out,
32+
)]
33+
34+
_builder = ruleb.Rule(
35+
implementation = _venv_entry_point_impl,
36+
executable = True,
37+
)
38+
_builder.attrs.update({
39+
"attribute": attr.string(mandatory = False, doc = "The attribute to call"),
40+
"extras": attr.string(mandatory = False, doc = "The extras for the entry point"),
41+
"group": attr.string(mandatory = False, doc = "The entry point group (e.g. console_scripts)"),
42+
"module": attr.string(mandatory = True, doc = "The module to import"),
43+
"_template": attr.label(
44+
default = Label("//python/private/pypi:venv_entry_point_template"),
45+
allow_single_file = True,
46+
),
47+
})
48+
_builder.attrs.update(WINDOWS_CONSTRAINTS_ATTRS)
49+
50+
venv_entry_point = _builder.build()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@setlocal enabledelayedexpansion & "%~dp0{PYTHON_EXE}" -x "%~f0" %* & exit /b !ERRORLEVEL!
2+
# -*- coding: utf-8 -*-
3+
import re
4+
import sys
5+
from {MODULE} import {ATTRIBUTE}
6+
if __name__ == "__main__":
7+
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
8+
sys.exit({ATTRIBUTE}())
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/sh
2+
'''exec' "$(dirname "$0")/python3" "$0" "$@"
3+
' '''
4+
# -*- coding: utf-8 -*-
5+
import re
6+
import sys
7+
from {MODULE} import {ATTRIBUTE}
8+
if __name__ == '__main__':
9+
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
10+
sys.exit({ATTRIBUTE}())
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Rule for rewriting portable shebangs."""
2+
3+
load("//python/private:attributes.bzl", "WINDOWS_CONSTRAINTS_ATTRS")
4+
load("//python/private:common.bzl", "is_windows_platform", "runfiles_root_path")
5+
load("//python/private:py_info.bzl", "PyInfoBuilder", "VenvSymlinkEntry", "VenvSymlinkKind")
6+
load("//python/private:rule_builders.bzl", "ruleb")
7+
8+
def _venv_rewrite_shebang_impl(ctx):
9+
is_windows = is_windows_platform(ctx)
10+
11+
out_name = ctx.label.name
12+
if is_windows:
13+
out_name += ".bat"
14+
15+
out_file = ctx.actions.declare_file(out_name)
16+
in_file = ctx.file.src
17+
18+
action_args = ctx.actions.args()
19+
rewriter_file = ctx.files._venv_shebang_rewriter[0]
20+
inputs = depset([in_file, rewriter_file])
21+
22+
if rewriter_file.path.endswith(".ps1"):
23+
action_exe = "powershell.exe"
24+
action_args.add_all([
25+
"-ExecutionPolicy",
26+
"Bypass",
27+
"-NoProfile",
28+
"-File",
29+
rewriter_file,
30+
])
31+
else:
32+
action_exe = ctx.attr._venv_shebang_rewriter[DefaultInfo].files_to_run
33+
34+
action_args.add(in_file)
35+
action_args.add(out_file)
36+
action_args.add("windows" if is_windows else "unix")
37+
38+
ctx.actions.run(
39+
inputs = inputs,
40+
outputs = [out_file],
41+
executable = action_exe,
42+
arguments = [action_args],
43+
mnemonic = "PyVenvRewriteBin",
44+
progress_message = "Rewriting venv bin script %{input}",
45+
toolchain = None,
46+
)
47+
48+
symlink = VenvSymlinkEntry(
49+
kind = VenvSymlinkKind.BIN,
50+
link_to_path = runfiles_root_path(ctx, out_file.short_path),
51+
link_to_file = out_file,
52+
venv_path = out_name,
53+
package = ctx.attr.package,
54+
version = ctx.attr.version,
55+
files = depset([out_file]),
56+
)
57+
builder = PyInfoBuilder.new()
58+
builder.venv_symlinks.add([symlink])
59+
py_info = builder.build()
60+
61+
return [
62+
DefaultInfo(files = depset([out_file]), executable = out_file),
63+
py_info,
64+
]
65+
66+
_builder = ruleb.Rule(
67+
implementation = _venv_rewrite_shebang_impl,
68+
executable = True,
69+
)
70+
_builder.attrs.update({
71+
"package": attr.string(),
72+
"src": attr.label(mandatory = True, allow_single_file = True),
73+
"version": attr.string(),
74+
"_venv_shebang_rewriter": attr.label(
75+
default = "//python/private/pypi:venv_shebang_rewriter",
76+
allow_files = True,
77+
cfg = "exec",
78+
),
79+
})
80+
_builder.attrs.update(WINDOWS_CONSTRAINTS_ATTRS)
81+
82+
venv_rewrite_shebang = _builder.build()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[CmdletBinding()]
2+
param(
3+
[Parameter(Position=0, Mandatory=$true)]
4+
[string]$InFile,
5+
6+
[Parameter(Position=1, Mandatory=$true)]
7+
[string]$OutFile,
8+
9+
[Parameter(Position=2, Mandatory=$true)]
10+
[string]$TargetOs
11+
)
12+
13+
$ErrorActionPreference = "Stop"
14+
15+
$firstLine = Get-Content -Path $InFile -TotalCount 1 -ErrorAction SilentlyContinue
16+
$content = Get-Content -Path $InFile | Select-Object -Skip 1
17+
18+
$Utf8NoBom = New-Object System.Text.UTF8Encoding $False
19+
20+
if ($TargetOs -eq "windows") {
21+
if ($firstLine -match "^#!pythonw") {
22+
$pythonExe = "pythonw.exe"
23+
} else {
24+
$pythonExe = "python.exe"
25+
}
26+
# A Batch-Python polyglot. Batch executes the first line and exits,
27+
# while Python (via -x) ignores the first line and executes the rest.
28+
$wrapper = "@setlocal enabledelayedexpansion & `"%~dp0$pythonExe`" -x `"%~f0`" %* & exit /b !ERRORLEVEL!"
29+
[System.IO.File]::WriteAllText($OutFile, $wrapper + "`r`n", $Utf8NoBom)
30+
} else {
31+
# A Shell-Python polyglot. The shell executes the triple-quoted 'exec'
32+
# command, re-running the script with python3 from the scripts directory.
33+
# Python ignores the triple-quoted string and continues.
34+
$wrapper = @"
35+
#!/bin/sh
36+
'''exec' "`$(dirname "`$0")/python3" "`$0" "`$@"
37+
' '''
38+
"@
39+
[System.IO.File]::WriteAllText($OutFile, $wrapper + "`n", $Utf8NoBom)
40+
}
41+
42+
if ($null -ne $content) {
43+
[System.IO.File]::AppendAllLines($OutFile, [string[]]$content, $Utf8NoBom)
44+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
IN="$1"
5+
OUT="$2"
6+
TARGET_OS="$3"
7+
8+
FIRST_LINE=$(head -n 1 "$IN")
9+
10+
if [ "$TARGET_OS" = "windows" ]; then
11+
case "$FIRST_LINE" in
12+
"#!pythonw"*) PYTHON_EXE="pythonw.exe" ;;
13+
*) PYTHON_EXE="python.exe" ;;
14+
esac
15+
# A Batch-Python polyglot. Batch executes the first line and exits,
16+
# while Python (via -x) ignores the first line and executes the rest.
17+
printf "@setlocal enabledelayedexpansion & \"%%~dp0$PYTHON_EXE\" -x \"%%~f0\" %%* & exit /b !ERRORLEVEL!\r\n" > "$OUT"
18+
else
19+
printf "#!/bin/sh\n" > "$OUT"
20+
# A Shell-Python polyglot. The shell executes the triple-quoted 'exec'
21+
# command, re-running the script with python3 from the scripts directory.
22+
# Python ignores the triple-quoted string and continues.
23+
printf "'''exec' \"\$(dirname \"\$0\")/python3\" \"\$0\" \"\$@\"\n' '''\n" >> "$OUT"
24+
fi
25+
26+
tail -n +2 "$IN" >> "$OUT"
27+
chmod +x "$OUT"

0 commit comments

Comments
 (0)