|
| 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