Skip to content

Commit 7dd67aa

Browse files
committed
gh-126187 Add emscripten.py script to automate emscripten build
This is modeled heavily on `Tools/wasm/wasi.py`. I tested it manually, hopefully we can soon use it as the basis for an Emscripten build bot. There are a few hacks to work around problems. I prioritized adding a script that works as soon as possible without changing other files over making it non-hacky. I will clean it up in followup.
1 parent dcad8fe commit 7dd67aa

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

Tools/wasm/emscripten.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import contextlib
5+
import functools
6+
import os
7+
8+
try:
9+
from os import process_cpu_count as cpu_count
10+
except ImportError:
11+
from os import cpu_count
12+
from pathlib import Path
13+
import shutil
14+
import subprocess
15+
import sys
16+
import sysconfig
17+
import tempfile
18+
19+
WASM_DIR = Path(__file__).parent
20+
CHECKOUT = WASM_DIR.parent.parent
21+
22+
CROSS_BUILD_DIR = CHECKOUT / "cross-build"
23+
BUILD_DIR = CROSS_BUILD_DIR / "build"
24+
HOST_TRIPLE = "wasm32-emscripten"
25+
HOST_DIR = CROSS_BUILD_DIR / HOST_TRIPLE
26+
27+
LOCAL_SETUP = CHECKOUT / "Modules" / "Setup.local"
28+
LOCAL_SETUP_MARKER = "# Generated by Tools/wasm/emscripten.py\n".encode("utf-8")
29+
30+
31+
def updated_env(updates={}):
32+
"""Create a new dict representing the environment to use.
33+
34+
The changes made to the execution environment are printed out.
35+
"""
36+
env_defaults = {}
37+
# https://reproducible-builds.org/docs/source-date-epoch/
38+
git_epoch_cmd = ["git", "log", "-1", "--pretty=%ct"]
39+
try:
40+
epoch = subprocess.check_output(git_epoch_cmd, encoding="utf-8").strip()
41+
env_defaults["SOURCE_DATE_EPOCH"] = epoch
42+
except subprocess.CalledProcessError:
43+
pass # Might be building from a tarball.
44+
# This layering lets SOURCE_DATE_EPOCH from os.environ takes precedence.
45+
environment = env_defaults | os.environ | updates
46+
47+
env_diff = {}
48+
for key, value in environment.items():
49+
if os.environ.get(key) != value:
50+
env_diff[key] = value
51+
52+
print("🌎 Environment changes:")
53+
for key in sorted(env_diff.keys()):
54+
print(f" {key}={env_diff[key]}")
55+
56+
return environment
57+
58+
59+
def subdir(working_dir, *, clean_ok=False):
60+
"""Decorator to change to a working directory."""
61+
62+
def decorator(func):
63+
@functools.wraps(func)
64+
def wrapper(context):
65+
try:
66+
tput_output = subprocess.check_output(
67+
["tput", "cols"], encoding="utf-8"
68+
)
69+
terminal_width = int(tput_output.strip())
70+
except subprocess.CalledProcessError:
71+
terminal_width = 80
72+
print("⎯" * terminal_width)
73+
print("📁", working_dir)
74+
if clean_ok and getattr(context, "clean", False) and working_dir.exists():
75+
print(f"🚮 Deleting directory (--clean)...")
76+
shutil.rmtree(working_dir)
77+
78+
working_dir.mkdir(parents=True, exist_ok=True)
79+
80+
with contextlib.chdir(working_dir):
81+
return func(context, working_dir)
82+
83+
return wrapper
84+
85+
return decorator
86+
87+
88+
def call(command, *, quiet, **kwargs):
89+
"""Execute a command.
90+
91+
If 'quiet' is true, then redirect stdout and stderr to a temporary file.
92+
"""
93+
print("❯", " ".join(map(str, command)))
94+
if not quiet:
95+
stdout = None
96+
stderr = None
97+
else:
98+
stdout = tempfile.NamedTemporaryFile(
99+
"w",
100+
encoding="utf-8",
101+
delete=False,
102+
prefix="cpython-emscripten-",
103+
suffix=".log",
104+
)
105+
stderr = subprocess.STDOUT
106+
print(f"📝 Logging output to {stdout.name} (--quiet)...")
107+
108+
subprocess.check_call(command, **kwargs, stdout=stdout, stderr=stderr)
109+
110+
111+
def build_platform():
112+
"""The name of the build/host platform."""
113+
# Can also be found via `config.guess`.`
114+
return sysconfig.get_config_var("BUILD_GNU_TYPE")
115+
116+
117+
def build_python_path():
118+
"""The path to the build Python binary."""
119+
binary = BUILD_DIR / "python"
120+
if not binary.is_file():
121+
binary = binary.with_suffix(".exe")
122+
if not binary.is_file():
123+
raise FileNotFoundError("Unable to find `python(.exe)` in " f"{BUILD_DIR}")
124+
125+
return binary
126+
127+
128+
@subdir(BUILD_DIR, clean_ok=True)
129+
def configure_build_python(context, working_dir):
130+
"""Configure the build/host Python."""
131+
if LOCAL_SETUP.exists():
132+
print(f"👍 {LOCAL_SETUP} exists ...")
133+
else:
134+
print(f"📝 Touching {LOCAL_SETUP} ...")
135+
LOCAL_SETUP.write_bytes(LOCAL_SETUP_MARKER)
136+
137+
configure = [os.path.relpath(CHECKOUT / "configure", working_dir)]
138+
if context.args:
139+
configure.extend(context.args)
140+
141+
call(configure, quiet=context.quiet)
142+
143+
144+
@subdir(BUILD_DIR)
145+
def make_build_python(context, working_dir):
146+
"""Make/build the build Python."""
147+
call(["make", "--jobs", str(cpu_count()), "all"], quiet=context.quiet)
148+
149+
binary = build_python_path()
150+
cmd = [
151+
binary,
152+
"-c",
153+
"import sys; " "print(f'{sys.version_info.major}.{sys.version_info.minor}')",
154+
]
155+
version = subprocess.check_output(cmd, encoding="utf-8").strip()
156+
157+
print(f"🎉 {binary} {version}")
158+
159+
160+
@subdir(HOST_DIR, clean_ok=True)
161+
def configure_emscripten_python(context, working_dir):
162+
"""Configure the emscripten/host build."""
163+
config_site = os.fsdecode(
164+
CHECKOUT / "Tools" / "wasm" / "config.site-wasm32-emscripten"
165+
)
166+
167+
emscripten_build_dir = working_dir.relative_to(CHECKOUT)
168+
169+
python_build_dir = BUILD_DIR / "build"
170+
lib_dirs = list(python_build_dir.glob("lib.*"))
171+
assert (
172+
len(lib_dirs) == 1
173+
), f"Expected a single lib.* directory in {python_build_dir}"
174+
lib_dir = os.fsdecode(lib_dirs[0])
175+
pydebug = lib_dir.endswith("-pydebug")
176+
python_version = lib_dir.removesuffix("-pydebug").rpartition("-")[-1]
177+
sysconfig_data = (
178+
f"{emscripten_build_dir}/build/lib.emscripten-wasm32-{python_version}"
179+
)
180+
if pydebug:
181+
sysconfig_data += "-pydebug"
182+
183+
host_runner = context.host_runner
184+
env_additions = {"CONFIG_SITE": config_site, "HOSTRUNNER": host_runner}
185+
build_python = os.fsdecode(build_python_path())
186+
configure = [
187+
"emconfigure",
188+
os.path.relpath(CHECKOUT / "configure", working_dir),
189+
"ax_cv_c_float_words_bigendian=no",
190+
"CFLAGS=-DPY_CALL_TRAMPOLINE -sUSE_BZIP2",
191+
f"--host={HOST_TRIPLE}",
192+
f"--build={build_platform()}",
193+
f"--with-build-python={build_python}",
194+
"--without-pymalloc",
195+
"--disable-shared",
196+
"--disable-ipv6",
197+
"--enable-big-digits=30",
198+
"--enable-wasm-dynamic-linking",
199+
f"--prefix={HOST_DIR}",
200+
]
201+
if pydebug:
202+
configure.append("--with-pydebug")
203+
if context.args:
204+
configure.extend(context.args)
205+
call(
206+
configure,
207+
env=updated_env(env_additions),
208+
quiet=context.quiet,
209+
)
210+
211+
python_js = working_dir / "python.js"
212+
exec_script = working_dir / "python.sh"
213+
exec_script.write_text(f'#!/bin/sh\nexec {host_runner} {python_js} "$@"\n')
214+
exec_script.chmod(0o755)
215+
print(f"🏃‍♀️ Created {exec_script} ... ")
216+
sys.stdout.flush()
217+
218+
219+
@subdir(HOST_DIR)
220+
def make_emscripten_python(context, working_dir):
221+
"""Run `make` for the emscripten/host build."""
222+
call(
223+
["make", "--jobs", str(cpu_count()), "commoninstall"],
224+
env=updated_env(),
225+
quiet=context.quiet,
226+
)
227+
228+
exec_script = working_dir / "python.sh"
229+
subprocess.check_call([exec_script, "--version"])
230+
231+
232+
def build_all(context):
233+
"""Build everything."""
234+
steps = [
235+
configure_build_python,
236+
make_build_python,
237+
configure_emscripten_python,
238+
make_emscripten_python,
239+
]
240+
for step in steps:
241+
step(context)
242+
243+
244+
def clean_contents(context):
245+
"""Delete all files created by this script."""
246+
if CROSS_BUILD_DIR.exists():
247+
print(f"🧹 Deleting {CROSS_BUILD_DIR} ...")
248+
shutil.rmtree(CROSS_BUILD_DIR)
249+
250+
if LOCAL_SETUP.exists():
251+
with LOCAL_SETUP.open("rb") as file:
252+
if file.read(len(LOCAL_SETUP_MARKER)) == LOCAL_SETUP_MARKER:
253+
print(f"🧹 Deleting generated {LOCAL_SETUP} ...")
254+
255+
256+
def main():
257+
default_host_runner = "node"
258+
259+
parser = argparse.ArgumentParser()
260+
subcommands = parser.add_subparsers(dest="subcommand")
261+
build = subcommands.add_parser("build", help="Build everything")
262+
configure_build = subcommands.add_parser(
263+
"configure-build-python", help="Run `configure` for the " "build Python"
264+
)
265+
make_build = subcommands.add_parser(
266+
"make-build-python", help="Run `make` for the build Python"
267+
)
268+
configure_host = subcommands.add_parser(
269+
"configure-host",
270+
help="Run `configure` for the host/emscripten (pydebug builds are inferred from the build Python)",
271+
)
272+
make_host = subcommands.add_parser("make-host", help="Run `make` for the host/emscripten")
273+
clean = subcommands.add_parser(
274+
"clean", help="Delete files and directories created by this script"
275+
)
276+
for subcommand in build, configure_build, make_build, configure_host, make_host:
277+
subcommand.add_argument(
278+
"--quiet",
279+
action="store_true",
280+
default=False,
281+
dest="quiet",
282+
help="Redirect output from subprocesses to a log file",
283+
)
284+
for subcommand in configure_build, configure_host:
285+
subcommand.add_argument(
286+
"--clean",
287+
action="store_true",
288+
default=False,
289+
dest="clean",
290+
help="Delete any relevant directories before building",
291+
)
292+
for subcommand in build, configure_build, configure_host:
293+
subcommand.add_argument(
294+
"args", nargs="*", help="Extra arguments to pass to `configure`"
295+
)
296+
for subcommand in build, configure_host:
297+
subcommand.add_argument(
298+
"--host-runner",
299+
action="store",
300+
default=default_host_runner,
301+
dest="host_runner",
302+
help="Command template for running the emscripten host"
303+
f"`{default_host_runner}`)",
304+
)
305+
306+
context = parser.parse_args()
307+
308+
dispatch = {
309+
"configure-build-python": configure_build_python,
310+
"make-build-python": make_build_python,
311+
"configure-host": configure_emscripten_python,
312+
"make-host": make_emscripten_python,
313+
"build": build_all,
314+
"clean": clean_contents,
315+
}
316+
317+
if not context.subcommand:
318+
# No command provided, display help and exit
319+
print("Expected one of", ", ".join(sorted(dispatch.keys())), file=sys.stderr)
320+
parser.print_help(sys.stderr)
321+
sys.exit(1)
322+
dispatch[context.subcommand](context)
323+
324+
325+
if __name__ == "__main__":
326+
main()

0 commit comments

Comments
 (0)