• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2"""Build script for Python on WebAssembly platforms.
3
4  $ ./Tools/wasm/wasm_builder.py emscripten-browser build repl
5  $ ./Tools/wasm/wasm_builder.py emscripten-node-dl build test
6  $ ./Tools/wasm/wasm_builder.py wasi build test
7
8Primary build targets are "emscripten-node-dl" (NodeJS, dynamic linking),
9"emscripten-browser", and "wasi".
10
11Emscripten builds require a recent Emscripten SDK. The tools looks for an
12activated EMSDK environment (". /path/to/emsdk_env.sh"). System packages
13(Debian, Homebrew) are not supported.
14
15WASI builds require WASI SDK and wasmtime. The tool looks for 'WASI_SDK_PATH'
16and falls back to /opt/wasi-sdk.
17
18The 'build' Python interpreter must be rebuilt every time Python's byte code
19changes.
20
21  ./Tools/wasm/wasm_builder.py --clean build build
22
23"""
24import argparse
25import enum
26import dataclasses
27import logging
28import os
29import pathlib
30import re
31import shlex
32import shutil
33import socket
34import subprocess
35import sys
36import sysconfig
37import tempfile
38import time
39import warnings
40import webbrowser
41
42# for Python 3.8
43from typing import (
44    cast,
45    Any,
46    Callable,
47    Dict,
48    Iterable,
49    List,
50    Optional,
51    Tuple,
52    Union,
53)
54
55logger = logging.getLogger("wasm_build")
56
57SRCDIR = pathlib.Path(__file__).parent.parent.parent.absolute()
58WASMTOOLS = SRCDIR / "Tools" / "wasm"
59BUILDDIR = SRCDIR / "builddir"
60CONFIGURE = SRCDIR / "configure"
61SETUP_LOCAL = SRCDIR / "Modules" / "Setup.local"
62
63HAS_CCACHE = shutil.which("ccache") is not None
64
65# path to WASI-SDK root
66WASI_SDK_PATH = pathlib.Path(os.environ.get("WASI_SDK_PATH", "/opt/wasi-sdk"))
67
68# path to Emscripten SDK config file.
69# auto-detect's EMSDK in /opt/emsdk without ". emsdk_env.sh".
70EM_CONFIG = pathlib.Path(os.environ.setdefault("EM_CONFIG", "/opt/emsdk/.emscripten"))
71EMSDK_MIN_VERSION = (3, 1, 19)
72EMSDK_BROKEN_VERSION = {
73    (3, 1, 14): "https://github.com/emscripten-core/emscripten/issues/17338",
74    (3, 1, 16): "https://github.com/emscripten-core/emscripten/issues/17393",
75    (3, 1, 20): "https://github.com/emscripten-core/emscripten/issues/17720",
76}
77_MISSING = pathlib.Path("MISSING")
78
79WASM_WEBSERVER = WASMTOOLS / "wasm_webserver.py"
80
81CLEAN_SRCDIR = f"""
82Builds require a clean source directory. Please use a clean checkout or
83run "make clean -C '{SRCDIR}'".
84"""
85
86INSTALL_NATIVE = """
87Builds require a C compiler (gcc, clang), make, pkg-config, and development
88headers for dependencies like zlib.
89
90Debian/Ubuntu: sudo apt install build-essential git curl pkg-config zlib1g-dev
91Fedora/CentOS: sudo dnf install gcc make git-core curl pkgconfig zlib-devel
92"""
93
94INSTALL_EMSDK = """
95wasm32-emscripten builds need Emscripten SDK. Please follow instructions at
96https://emscripten.org/docs/getting_started/downloads.html how to install
97Emscripten and how to activate the SDK with "emsdk_env.sh".
98
99    git clone https://github.com/emscripten-core/emsdk.git /path/to/emsdk
100    cd /path/to/emsdk
101    ./emsdk install latest
102    ./emsdk activate latest
103    source /path/to/emsdk_env.sh
104"""
105
106INSTALL_WASI_SDK = """
107wasm32-wasi builds need WASI SDK. Please fetch the latest SDK from
108https://github.com/WebAssembly/wasi-sdk/releases and install it to
109"/opt/wasi-sdk". Alternatively you can install the SDK in a different location
110and point the environment variable WASI_SDK_PATH to the root directory
111of the SDK. The SDK is available for Linux x86_64, macOS x86_64, and MinGW.
112"""
113
114INSTALL_WASMTIME = """
115wasm32-wasi tests require wasmtime on PATH. Please follow instructions at
116https://wasmtime.dev/ to install wasmtime.
117"""
118
119
120def parse_emconfig(
121    emconfig: pathlib.Path = EM_CONFIG,
122) -> Tuple[pathlib.Path, pathlib.Path]:
123    """Parse EM_CONFIG file and lookup EMSCRIPTEN_ROOT and NODE_JS.
124
125    The ".emscripten" config file is a Python snippet that uses "EM_CONFIG"
126    environment variable. EMSCRIPTEN_ROOT is the "upstream/emscripten"
127    subdirectory with tools like "emconfigure".
128    """
129    if not emconfig.exists():
130        return _MISSING, _MISSING
131    with open(emconfig, encoding="utf-8") as f:
132        code = f.read()
133    # EM_CONFIG file is a Python snippet
134    local: Dict[str, Any] = {}
135    exec(code, globals(), local)
136    emscripten_root = pathlib.Path(local["EMSCRIPTEN_ROOT"])
137    node_js = pathlib.Path(local["NODE_JS"])
138    return emscripten_root, node_js
139
140
141EMSCRIPTEN_ROOT, NODE_JS = parse_emconfig()
142
143
144def read_python_version(configure: pathlib.Path = CONFIGURE) -> str:
145    """Read PACKAGE_VERSION from configure script
146
147    configure and configure.ac are the canonical source for major and
148    minor version number.
149    """
150    version_re = re.compile(r"^PACKAGE_VERSION='(\d\.\d+)'")
151    with configure.open(encoding="utf-8") as f:
152        for line in f:
153            mo = version_re.match(line)
154            if mo:
155                return mo.group(1)
156    raise ValueError(f"PACKAGE_VERSION not found in {configure}")
157
158
159PYTHON_VERSION = read_python_version()
160
161
162class ConditionError(ValueError):
163    def __init__(self, info: str, text: str) -> None:
164        self.info = info
165        self.text = text
166
167    def __str__(self) -> str:
168        return f"{type(self).__name__}: '{self.info}'\n{self.text}"
169
170
171class MissingDependency(ConditionError):
172    pass
173
174
175class DirtySourceDirectory(ConditionError):
176    pass
177
178
179@dataclasses.dataclass
180class Platform:
181    """Platform-specific settings
182
183    - CONFIG_SITE override
184    - configure wrapper (e.g. emconfigure)
185    - make wrapper (e.g. emmake)
186    - additional environment variables
187    - check function to verify SDK
188    """
189
190    name: str
191    pythonexe: str
192    config_site: Optional[pathlib.PurePath]
193    configure_wrapper: Optional[pathlib.Path]
194    make_wrapper: Optional[pathlib.PurePath]
195    environ: Dict[str, Any]
196    check: Callable[[], None]
197    # Used for build_emports().
198    ports: Optional[pathlib.PurePath]
199    cc: Optional[pathlib.PurePath]
200
201    def getenv(self, profile: "BuildProfile") -> Dict[str, Any]:
202        return self.environ.copy()
203
204
205def _check_clean_src() -> None:
206    candidates = [
207        SRCDIR / "Programs" / "python.o",
208        SRCDIR / "Python" / "frozen_modules" / "importlib._bootstrap.h",
209    ]
210    for candidate in candidates:
211        if candidate.exists():
212            raise DirtySourceDirectory(os.fspath(candidate), CLEAN_SRCDIR)
213
214
215def _check_native() -> None:
216    if not any(shutil.which(cc) for cc in ["cc", "gcc", "clang"]):
217        raise MissingDependency("cc", INSTALL_NATIVE)
218    if not shutil.which("make"):
219        raise MissingDependency("make", INSTALL_NATIVE)
220    if sys.platform == "linux":
221        # skip pkg-config check on macOS
222        if not shutil.which("pkg-config"):
223            raise MissingDependency("pkg-config", INSTALL_NATIVE)
224        # zlib is needed to create zip files
225        for devel in ["zlib"]:
226            try:
227                subprocess.check_call(["pkg-config", "--exists", devel])
228            except subprocess.CalledProcessError:
229                raise MissingDependency(devel, INSTALL_NATIVE) from None
230    _check_clean_src()
231
232
233NATIVE = Platform(
234    "native",
235    # macOS has python.exe
236    pythonexe=sysconfig.get_config_var("BUILDPYTHON") or "python",
237    config_site=None,
238    configure_wrapper=None,
239    ports=None,
240    cc=None,
241    make_wrapper=None,
242    environ={},
243    check=_check_native,
244)
245
246
247def _check_emscripten() -> None:
248    if EMSCRIPTEN_ROOT is _MISSING:
249        raise MissingDependency("Emscripten SDK EM_CONFIG", INSTALL_EMSDK)
250    # sanity check
251    emconfigure = EMSCRIPTEN.configure_wrapper
252    if emconfigure is not None and not emconfigure.exists():
253        raise MissingDependency(os.fspath(emconfigure), INSTALL_EMSDK)
254    # version check
255    version_txt = EMSCRIPTEN_ROOT / "emscripten-version.txt"
256    if not version_txt.exists():
257        raise MissingDependency(os.fspath(version_txt), INSTALL_EMSDK)
258    with open(version_txt) as f:
259        version = f.read().strip().strip('"')
260    if version.endswith("-git"):
261        # git / upstream / tot-upstream installation
262        version = version[:-4]
263    version_tuple = cast(
264        Tuple[int, int, int],
265        tuple(int(v) for v in version.split("."))
266    )
267    if version_tuple < EMSDK_MIN_VERSION:
268        raise ConditionError(
269            os.fspath(version_txt),
270            f"Emscripten SDK {version} in '{EMSCRIPTEN_ROOT}' is older than "
271            "minimum required version "
272            f"{'.'.join(str(v) for v in EMSDK_MIN_VERSION)}.",
273        )
274    broken = EMSDK_BROKEN_VERSION.get(version_tuple)
275    if broken is not None:
276        raise ConditionError(
277            os.fspath(version_txt),
278            (
279                f"Emscripten SDK {version} in '{EMSCRIPTEN_ROOT}' has known "
280                f"bugs, see {broken}."
281            ),
282        )
283    if os.environ.get("PKG_CONFIG_PATH"):
284        warnings.warn(
285            "PKG_CONFIG_PATH is set and not empty. emconfigure overrides "
286            "this environment variable. Use EM_PKG_CONFIG_PATH instead."
287        )
288    _check_clean_src()
289
290
291EMSCRIPTEN = Platform(
292    "emscripten",
293    pythonexe="python.js",
294    config_site=WASMTOOLS / "config.site-wasm32-emscripten",
295    configure_wrapper=EMSCRIPTEN_ROOT / "emconfigure",
296    ports=EMSCRIPTEN_ROOT / "embuilder",
297    cc=EMSCRIPTEN_ROOT / "emcc",
298    make_wrapper=EMSCRIPTEN_ROOT / "emmake",
299    environ={
300        # workaround for https://github.com/emscripten-core/emscripten/issues/17635
301        "TZ": "UTC",
302        "EM_COMPILER_WRAPPER": "ccache" if HAS_CCACHE else None,
303        "PATH": [EMSCRIPTEN_ROOT, os.environ["PATH"]],
304    },
305    check=_check_emscripten,
306)
307
308
309def _check_wasi() -> None:
310    wasm_ld = WASI_SDK_PATH / "bin" / "wasm-ld"
311    if not wasm_ld.exists():
312        raise MissingDependency(os.fspath(wasm_ld), INSTALL_WASI_SDK)
313    wasmtime = shutil.which("wasmtime")
314    if wasmtime is None:
315        raise MissingDependency("wasmtime", INSTALL_WASMTIME)
316    _check_clean_src()
317
318
319WASI = Platform(
320    "wasi",
321    pythonexe="python.wasm",
322    config_site=WASMTOOLS / "config.site-wasm32-wasi",
323    configure_wrapper=WASMTOOLS / "wasi-env",
324    ports=None,
325    cc=WASI_SDK_PATH / "bin" / "clang",
326    make_wrapper=None,
327    environ={
328        "WASI_SDK_PATH": WASI_SDK_PATH,
329        # workaround for https://github.com/python/cpython/issues/95952
330        "HOSTRUNNER": (
331            "wasmtime run "
332            "--wasm max-wasm-stack=8388608 "
333            "--wasi preview2 "
334            "--dir {srcdir}::/ "
335            "--env PYTHONPATH=/{relbuilddir}/build/lib.wasi-wasm32-{version}:/Lib"
336        ),
337        "PATH": [WASI_SDK_PATH / "bin", os.environ["PATH"]],
338    },
339    check=_check_wasi,
340)
341
342
343class Host(enum.Enum):
344    """Target host triplet"""
345
346    wasm32_emscripten = "wasm32-unknown-emscripten"
347    wasm64_emscripten = "wasm64-unknown-emscripten"
348    wasm32_wasi = "wasm32-unknown-wasi"
349    wasm64_wasi = "wasm64-unknown-wasi"
350    # current platform
351    build = sysconfig.get_config_var("BUILD_GNU_TYPE")
352
353    @property
354    def platform(self) -> Platform:
355        if self.is_emscripten:
356            return EMSCRIPTEN
357        elif self.is_wasi:
358            return WASI
359        else:
360            return NATIVE
361
362    @property
363    def is_emscripten(self) -> bool:
364        cls = type(self)
365        return self in {cls.wasm32_emscripten, cls.wasm64_emscripten}
366
367    @property
368    def is_wasi(self) -> bool:
369        cls = type(self)
370        return self in {cls.wasm32_wasi, cls.wasm64_wasi}
371
372    def get_extra_paths(self) -> Iterable[pathlib.PurePath]:
373        """Host-specific os.environ["PATH"] entries.
374
375        Emscripten's Node version 14.x works well for wasm32-emscripten.
376        wasm64-emscripten requires more recent v8 version, e.g. node 16.x.
377        Attempt to use system's node command.
378        """
379        cls = type(self)
380        if self == cls.wasm32_emscripten:
381            return [NODE_JS.parent]
382        elif self == cls.wasm64_emscripten:
383            # TODO: look for recent node
384            return []
385        else:
386            return []
387
388    @property
389    def emport_args(self) -> List[str]:
390        """Host-specific port args (Emscripten)."""
391        cls = type(self)
392        if self is cls.wasm64_emscripten:
393            return ["-sMEMORY64=1"]
394        elif self is cls.wasm32_emscripten:
395            return ["-sMEMORY64=0"]
396        else:
397            return []
398
399    @property
400    def embuilder_args(self) -> List[str]:
401        """Host-specific embuilder args (Emscripten)."""
402        cls = type(self)
403        if self is cls.wasm64_emscripten:
404            return ["--wasm64"]
405        else:
406            return []
407
408
409class EmscriptenTarget(enum.Enum):
410    """Emscripten-specific targets (--with-emscripten-target)"""
411
412    browser = "browser"
413    browser_debug = "browser-debug"
414    node = "node"
415    node_debug = "node-debug"
416
417    @property
418    def is_browser(self) -> bool:
419        cls = type(self)
420        return self in {cls.browser, cls.browser_debug}
421
422    @property
423    def emport_args(self) -> List[str]:
424        """Target-specific port args."""
425        cls = type(self)
426        if self in {cls.browser_debug, cls.node_debug}:
427            # some libs come in debug and non-debug builds
428            return ["-O0"]
429        else:
430            return ["-O2"]
431
432
433class SupportLevel(enum.Enum):
434    supported = "tier 3, supported"
435    working = "working, unsupported"
436    experimental = "experimental, may be broken"
437    broken = "broken / unavailable"
438
439    def __bool__(self) -> bool:
440        cls = type(self)
441        return self in {cls.supported, cls.working}
442
443
444@dataclasses.dataclass
445class BuildProfile:
446    name: str
447    support_level: SupportLevel
448    host: Host
449    target: Union[EmscriptenTarget, None] = None
450    dynamic_linking: Union[bool, None] = None
451    pthreads: Union[bool, None] = None
452    default_testopts: str = "-j2"
453
454    @property
455    def is_browser(self) -> bool:
456        """Is this a browser build?"""
457        return self.target is not None and self.target.is_browser
458
459    @property
460    def builddir(self) -> pathlib.Path:
461        """Path to build directory"""
462        return BUILDDIR / self.name
463
464    @property
465    def python_cmd(self) -> pathlib.Path:
466        """Path to python executable"""
467        return self.builddir / self.host.platform.pythonexe
468
469    @property
470    def makefile(self) -> pathlib.Path:
471        """Path to Makefile"""
472        return self.builddir / "Makefile"
473
474    @property
475    def configure_cmd(self) -> List[str]:
476        """Generate configure command"""
477        # use relative path, so WASI tests can find lib prefix.
478        # pathlib.Path.relative_to() does not work here.
479        configure = os.path.relpath(CONFIGURE, self.builddir)
480        cmd = [configure, "-C"]
481        platform = self.host.platform
482        if platform.configure_wrapper:
483            cmd.insert(0, os.fspath(platform.configure_wrapper))
484
485        cmd.append(f"--host={self.host.value}")
486        cmd.append(f"--build={Host.build.value}")
487
488        if self.target is not None:
489            assert self.host.is_emscripten
490            cmd.append(f"--with-emscripten-target={self.target.value}")
491
492        if self.dynamic_linking is not None:
493            assert self.host.is_emscripten
494            opt = "enable" if self.dynamic_linking else "disable"
495            cmd.append(f"--{opt}-wasm-dynamic-linking")
496
497        if self.pthreads is not None:
498            opt = "enable" if self.pthreads else "disable"
499            cmd.append(f"--{opt}-wasm-pthreads")
500
501        if self.host != Host.build:
502            cmd.append(f"--with-build-python={BUILD.python_cmd}")
503
504        if platform.config_site is not None:
505            cmd.append(f"CONFIG_SITE={platform.config_site}")
506
507        return cmd
508
509    @property
510    def make_cmd(self) -> List[str]:
511        """Generate make command"""
512        cmd = ["make"]
513        platform = self.host.platform
514        if platform.make_wrapper:
515            cmd.insert(0, os.fspath(platform.make_wrapper))
516        return cmd
517
518    def getenv(self) -> Dict[str, Any]:
519        """Generate environ dict for platform"""
520        env = os.environ.copy()
521        if hasattr(os, 'process_cpu_count'):
522            cpu_count = os.process_cpu_count()
523        else:
524            cpu_count = os.cpu_count()
525        env.setdefault("MAKEFLAGS", f"-j{cpu_count}")
526        platenv = self.host.platform.getenv(self)
527        for key, value in platenv.items():
528            if value is None:
529                env.pop(key, None)
530            elif key == "PATH":
531                # list of path items, prefix with extra paths
532                new_path: List[pathlib.PurePath] = []
533                new_path.extend(self.host.get_extra_paths())
534                new_path.extend(value)
535                env[key] = os.pathsep.join(os.fspath(p) for p in new_path)
536            elif isinstance(value, str):
537                env[key] = value.format(
538                    relbuilddir=self.builddir.relative_to(SRCDIR),
539                    srcdir=SRCDIR,
540                    version=PYTHON_VERSION,
541                )
542            else:
543                env[key] = value
544        return env
545
546    def _run_cmd(
547        self,
548        cmd: Iterable[str],
549        args: Iterable[str] = (),
550        cwd: Optional[pathlib.Path] = None,
551    ) -> int:
552        cmd = list(cmd)
553        cmd.extend(args)
554        if cwd is None:
555            cwd = self.builddir
556        logger.info('Running "%s" in "%s"', shlex.join(cmd), cwd)
557        return subprocess.check_call(
558            cmd,
559            cwd=os.fspath(cwd),
560            env=self.getenv(),
561        )
562
563    def _check_execute(self) -> None:
564        if self.is_browser:
565            raise ValueError(f"Cannot execute on {self.target}")
566
567    def run_build(self, *args: str) -> None:
568        """Run configure (if necessary) and make"""
569        if not self.makefile.exists():
570            logger.info("Makefile not found, running configure")
571            self.run_configure(*args)
572        self.run_make("all", *args)
573
574    def run_configure(self, *args: str) -> int:
575        """Run configure script to generate Makefile"""
576        os.makedirs(self.builddir, exist_ok=True)
577        return self._run_cmd(self.configure_cmd, args)
578
579    def run_make(self, *args: str) -> int:
580        """Run make (defaults to build all)"""
581        return self._run_cmd(self.make_cmd, args)
582
583    def run_pythoninfo(self, *args: str) -> int:
584        """Run 'make pythoninfo'"""
585        self._check_execute()
586        return self.run_make("pythoninfo", *args)
587
588    def run_test(self, target: str, testopts: Optional[str] = None) -> int:
589        """Run buildbottests"""
590        self._check_execute()
591        if testopts is None:
592            testopts = self.default_testopts
593        return self.run_make(target, f"TESTOPTS={testopts}")
594
595    def run_py(self, *args: str) -> int:
596        """Run Python with hostrunner"""
597        self._check_execute()
598        return self.run_make(
599            "--eval", f"run: all; $(HOSTRUNNER) ./$(PYTHON) {shlex.join(args)}", "run"
600        )
601
602    def run_browser(self, bind: str = "127.0.0.1", port: int = 8000) -> None:
603        """Run WASM webserver and open build in browser"""
604        relbuilddir = self.builddir.relative_to(SRCDIR)
605        url = f"http://{bind}:{port}/{relbuilddir}/python.html"
606        args = [
607            sys.executable,
608            os.fspath(WASM_WEBSERVER),
609            "--bind",
610            bind,
611            "--port",
612            str(port),
613        ]
614        srv = subprocess.Popen(args, cwd=SRCDIR)
615        # wait for server
616        end = time.monotonic() + 3.0
617        while time.monotonic() < end and srv.returncode is None:
618            try:
619                with socket.create_connection((bind, port), timeout=0.1) as _:
620                    pass
621            except OSError:
622                time.sleep(0.01)
623            else:
624                break
625
626        webbrowser.open(url)
627
628        try:
629            srv.wait()
630        except KeyboardInterrupt:
631            pass
632
633    def clean(self, all: bool = False) -> None:
634        """Clean build directory"""
635        if all:
636            if self.builddir.exists():
637                shutil.rmtree(self.builddir)
638        elif self.makefile.exists():
639            self.run_make("clean")
640
641    def build_emports(self, force: bool = False) -> None:
642        """Pre-build emscripten ports."""
643        platform = self.host.platform
644        if platform.ports is None or platform.cc is None:
645            raise ValueError("Need ports and CC command")
646
647        embuilder_cmd = [os.fspath(platform.ports)]
648        embuilder_cmd.extend(self.host.embuilder_args)
649        if force:
650            embuilder_cmd.append("--force")
651
652        ports_cmd = [os.fspath(platform.cc)]
653        ports_cmd.extend(self.host.emport_args)
654        if self.target:
655            ports_cmd.extend(self.target.emport_args)
656
657        if self.dynamic_linking:
658            # Trigger PIC build.
659            ports_cmd.append("-sMAIN_MODULE")
660            embuilder_cmd.append("--pic")
661
662        if self.pthreads:
663            # Trigger multi-threaded build.
664            ports_cmd.append("-sUSE_PTHREADS")
665
666        # Pre-build libbz2, libsqlite3, libz, and some system libs.
667        ports_cmd.extend(["-sUSE_ZLIB", "-sUSE_BZIP2", "-sUSE_SQLITE3"])
668        # Multi-threaded sqlite3 has different suffix
669        embuilder_cmd.extend(
670            ["build", "bzip2", "sqlite3-mt" if self.pthreads else "sqlite3", "zlib"]
671        )
672
673        self._run_cmd(embuilder_cmd, cwd=SRCDIR)
674
675        with tempfile.TemporaryDirectory(suffix="-py-emport") as tmpdir:
676            tmppath = pathlib.Path(tmpdir)
677            main_c = tmppath / "main.c"
678            main_js = tmppath / "main.js"
679            with main_c.open("w") as f:
680                f.write("int main(void) { return 0; }\n")
681            args = [
682                os.fspath(main_c),
683                "-o",
684                os.fspath(main_js),
685            ]
686            self._run_cmd(ports_cmd, args, cwd=tmppath)
687
688
689# native build (build Python)
690BUILD = BuildProfile(
691    "build",
692    support_level=SupportLevel.working,
693    host=Host.build,
694)
695
696_profiles = [
697    BUILD,
698    # wasm32-emscripten
699    BuildProfile(
700        "emscripten-browser",
701        support_level=SupportLevel.supported,
702        host=Host.wasm32_emscripten,
703        target=EmscriptenTarget.browser,
704        dynamic_linking=True,
705    ),
706    BuildProfile(
707        "emscripten-browser-debug",
708        support_level=SupportLevel.working,
709        host=Host.wasm32_emscripten,
710        target=EmscriptenTarget.browser_debug,
711        dynamic_linking=True,
712    ),
713    BuildProfile(
714        "emscripten-node-dl",
715        support_level=SupportLevel.supported,
716        host=Host.wasm32_emscripten,
717        target=EmscriptenTarget.node,
718        dynamic_linking=True,
719    ),
720    BuildProfile(
721        "emscripten-node-dl-debug",
722        support_level=SupportLevel.working,
723        host=Host.wasm32_emscripten,
724        target=EmscriptenTarget.node_debug,
725        dynamic_linking=True,
726    ),
727    BuildProfile(
728        "emscripten-node-pthreads",
729        support_level=SupportLevel.supported,
730        host=Host.wasm32_emscripten,
731        target=EmscriptenTarget.node,
732        pthreads=True,
733    ),
734    BuildProfile(
735        "emscripten-node-pthreads-debug",
736        support_level=SupportLevel.working,
737        host=Host.wasm32_emscripten,
738        target=EmscriptenTarget.node_debug,
739        pthreads=True,
740    ),
741    # Emscripten build with both pthreads and dynamic linking is crashing.
742    BuildProfile(
743        "emscripten-node-dl-pthreads-debug",
744        support_level=SupportLevel.broken,
745        host=Host.wasm32_emscripten,
746        target=EmscriptenTarget.node_debug,
747        dynamic_linking=True,
748        pthreads=True,
749    ),
750    # wasm64-emscripten (requires Emscripten >= 3.1.21)
751    BuildProfile(
752        "wasm64-emscripten-node-debug",
753        support_level=SupportLevel.experimental,
754        host=Host.wasm64_emscripten,
755        target=EmscriptenTarget.node_debug,
756        # MEMORY64 is not compatible with dynamic linking
757        dynamic_linking=False,
758        pthreads=False,
759    ),
760    # wasm32-wasi
761    BuildProfile(
762        "wasi",
763        support_level=SupportLevel.supported,
764        host=Host.wasm32_wasi,
765    ),
766    # wasm32-wasi-threads
767    BuildProfile(
768        "wasi-threads",
769        support_level=SupportLevel.experimental,
770        host=Host.wasm32_wasi,
771        pthreads=True,
772    ),
773    # no SDK available yet
774    # BuildProfile(
775    #    "wasm64-wasi",
776    #    support_level=SupportLevel.broken,
777    #    host=Host.wasm64_wasi,
778    # ),
779]
780
781PROFILES = {p.name: p for p in _profiles}
782
783parser = argparse.ArgumentParser(
784    "wasm_build.py",
785    description=__doc__,
786    formatter_class=argparse.RawTextHelpFormatter,
787)
788
789parser.add_argument(
790    "--clean",
791    "-c",
792    help="Clean build directories first",
793    action="store_true",
794)
795
796parser.add_argument(
797    "--verbose",
798    "-v",
799    help="Verbose logging",
800    action="store_true",
801)
802
803parser.add_argument(
804    "--silent",
805    help="Run configure and make in silent mode",
806    action="store_true",
807)
808
809parser.add_argument(
810    "--testopts",
811    help=(
812        "Additional test options for 'test' and 'hostrunnertest', e.g. "
813        "--testopts='-v test_os'."
814    ),
815    default=None,
816)
817
818# Don't list broken and experimental variants in help
819platforms_choices = list(p.name for p in _profiles) + ["cleanall"]
820platforms_help = list(p.name for p in _profiles if p.support_level) + ["cleanall"]
821parser.add_argument(
822    "platform",
823    metavar="PLATFORM",
824    help=f"Build platform: {', '.join(platforms_help)}",
825    choices=platforms_choices,
826)
827
828ops = dict(
829    build="auto build (build 'build' Python, emports, configure, compile)",
830    configure="run ./configure",
831    compile="run 'make all'",
832    pythoninfo="run 'make pythoninfo'",
833    test="run 'make buildbottest TESTOPTS=...' (supports parallel tests)",
834    hostrunnertest="run 'make hostrunnertest TESTOPTS=...'",
835    repl="start interactive REPL / webserver + browser session",
836    clean="run 'make clean'",
837    cleanall="remove all build directories",
838    emports="build Emscripten port with embuilder (only Emscripten)",
839)
840ops_help = "\n".join(f"{op:16s} {help}" for op, help in ops.items())
841parser.add_argument(
842    "ops",
843    metavar="OP",
844    help=f"operation (default: build)\n\n{ops_help}",
845    choices=tuple(ops),
846    default="build",
847    nargs="*",
848)
849
850
851def main() -> None:
852    args = parser.parse_args()
853    logging.basicConfig(
854        level=logging.INFO if args.verbose else logging.ERROR,
855        format="%(message)s",
856    )
857
858    if args.platform == "cleanall":
859        for builder in PROFILES.values():
860            builder.clean(all=True)
861        parser.exit(0)
862
863    # additional configure and make args
864    cm_args = ("--silent",) if args.silent else ()
865
866    # nargs=* with default quirk
867    if args.ops == "build":
868        args.ops = ["build"]
869
870    builder = PROFILES[args.platform]
871    try:
872        builder.host.platform.check()
873    except ConditionError as e:
874        parser.error(str(e))
875
876    if args.clean:
877        builder.clean(all=False)
878
879    # hack for WASI
880    if builder.host.is_wasi and not SETUP_LOCAL.exists():
881        SETUP_LOCAL.touch()
882
883    # auto-build
884    if "build" in args.ops:
885        # check and create build Python
886        if builder is not BUILD:
887            logger.info("Auto-building 'build' Python.")
888            try:
889                BUILD.host.platform.check()
890            except ConditionError as e:
891                parser.error(str(e))
892            if args.clean:
893                BUILD.clean(all=False)
894            BUILD.run_build(*cm_args)
895        # build Emscripten ports with embuilder
896        if builder.host.is_emscripten and "emports" not in args.ops:
897            builder.build_emports()
898
899    for op in args.ops:
900        logger.info("\n*** %s %s", args.platform, op)
901        if op == "build":
902            builder.run_build(*cm_args)
903        elif op == "configure":
904            builder.run_configure(*cm_args)
905        elif op == "compile":
906            builder.run_make("all", *cm_args)
907        elif op == "pythoninfo":
908            builder.run_pythoninfo(*cm_args)
909        elif op == "repl":
910            if builder.is_browser:
911                builder.run_browser()
912            else:
913                builder.run_py()
914        elif op == "test":
915            builder.run_test("buildbottest", testopts=args.testopts)
916        elif op == "hostrunnertest":
917            builder.run_test("hostrunnertest", testopts=args.testopts)
918        elif op == "clean":
919            builder.clean(all=False)
920        elif op == "cleanall":
921            builder.clean(all=True)
922        elif op == "emports":
923            builder.build_emports(force=args.clean)
924        else:
925            raise ValueError(op)
926
927    print(builder.builddir)
928    parser.exit(0)
929
930
931if __name__ == "__main__":
932    main()
933