From c791d02e4b997b9d93aab25ac3026badfd226e7a Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Fri, 16 Jan 2026 14:28:52 -0800 Subject: [PATCH 1/2] GH-143941: Move WASI-related files to Platforms/WASI Along the way, leave a deprecated Tools/wasm/wasi/__main__.py behind for backwards-compatibility. --- .github/CODEOWNERS | 1 + .github/workflows/reusable-wasi.yml | 8 +- ...-01-16-14-27-53.gh-issue-143941.TiaE-3.rst | 2 + Platforms/WASI/.ruff.toml | 25 + Platforms/WASI/README.md | 59 ++ Platforms/WASI/__main__.py | 557 +++++++++++++++++ .../WASI}/config.site-wasm32-wasi | 0 .../wasm/wasi => Platforms/WASI}/config.toml | 0 .../wasi => Platforms/WASI}/wasmtime.toml | 0 Tools/wasm/README.md | 45 +- Tools/wasm/wasi.py | 7 - Tools/wasm/wasi/__main__.py | 560 +----------------- 12 files changed, 667 insertions(+), 597 deletions(-) create mode 100644 Misc/NEWS.d/next/Build/2026-01-16-14-27-53.gh-issue-143941.TiaE-3.rst create mode 100644 Platforms/WASI/.ruff.toml create mode 100644 Platforms/WASI/README.md create mode 100644 Platforms/WASI/__main__.py rename {Tools/wasm/wasi => Platforms/WASI}/config.site-wasm32-wasi (100%) rename {Tools/wasm/wasi => Platforms/WASI}/config.toml (100%) rename {Tools/wasm/wasi => Platforms/WASI}/wasmtime.toml (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bf10e34fde1f47..cdb07f9f33fb9d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -176,6 +176,7 @@ Tools/wasm/config.site-wasm32-emscripten @freakboy3742 @emmatyping Tools/wasm/emscripten @freakboy3742 @emmatyping # WebAssembly (WASI) +platforms/ WASI @brettcannon @emmatyping @savannahostrowski Tools/wasm/wasi-env @brettcannon @emmatyping @savannahostrowski Tools/wasm/wasi.py @brettcannon @emmatyping @savannahostrowski Tools/wasm/wasi @brettcannon @emmatyping @savannahostrowski diff --git a/.github/workflows/reusable-wasi.yml b/.github/workflows/reusable-wasi.yml index 4b03712eb1ee08..3c81f6ef82dc8c 100644 --- a/.github/workflows/reusable-wasi.yml +++ b/.github/workflows/reusable-wasi.yml @@ -47,14 +47,14 @@ jobs: - name: "Runner image version" run: echo "IMAGE_OS_VERSION=${ImageOS}-${ImageVersion}" >> "$GITHUB_ENV" - name: "Configure build Python" - run: python3 Tools/wasm/wasi configure-build-python -- --config-cache --with-pydebug + run: python3 Platforms/WASI configure-build-python -- --config-cache --with-pydebug - name: "Make build Python" - run: python3 Tools/wasm/wasi make-build-python + run: python3 Platforms/WASI make-build-python - name: "Configure host" # `--with-pydebug` inferred from configure-build-python - run: python3 Tools/wasm/wasi configure-host -- --config-cache + run: python3 Platforms/WASI configure-host -- --config-cache - name: "Make host" - run: python3 Tools/wasm/wasi make-host + run: python3 Platforms/WASI make-host - name: "Display build info" run: make --directory "${CROSS_BUILD_WASI}" pythoninfo - name: "Test" diff --git a/Misc/NEWS.d/next/Build/2026-01-16-14-27-53.gh-issue-143941.TiaE-3.rst b/Misc/NEWS.d/next/Build/2026-01-16-14-27-53.gh-issue-143941.TiaE-3.rst new file mode 100644 index 00000000000000..db581f8aa96889 --- /dev/null +++ b/Misc/NEWS.d/next/Build/2026-01-16-14-27-53.gh-issue-143941.TiaE-3.rst @@ -0,0 +1,2 @@ +Move WASI-related files to Platforms/WASI. Along the way, leave a deprecated +Tools/wasm/wasi/__main__.py behind for backwards-compatibility. diff --git a/Platforms/WASI/.ruff.toml b/Platforms/WASI/.ruff.toml new file mode 100644 index 00000000000000..3d8e59fa3f22c4 --- /dev/null +++ b/Platforms/WASI/.ruff.toml @@ -0,0 +1,25 @@ +extend = "../../.ruff.toml" # Inherit the project-wide settings + +[format] +preview = true +docstring-code-format = true + +[lint] +select = [ + "C4", # flake8-comprehensions + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PGH", # pygrep-hooks + "PT", # flake8-pytest-style + "PYI", # flake8-pyi + "RUF100", # Ban unused `# noqa` comments + "UP", # pyupgrade + "W", # pycodestyle + "YTT", # flake8-2020 +] +ignore = [ + "E501", # Line too long +] diff --git a/Platforms/WASI/README.md b/Platforms/WASI/README.md new file mode 100644 index 00000000000000..62c82924d437b1 --- /dev/null +++ b/Platforms/WASI/README.md @@ -0,0 +1,59 @@ +# Python WASI (wasm32-wasi) build + +**WASI support is [tier 2](https://peps.python.org/pep-0011/#tier-2).** + +This directory contains configuration and helpers to facilitate cross +compilation of CPython to WebAssembly (WASM) using WASI. WASI builds +use WASM runtimes such as [wasmtime](https://wasmtime.dev/). + +**NOTE**: If you are looking for general information about WebAssembly that is +not directly related to CPython, please see https://github.com/psf/webassembly. + +## Build + +See [the devguide on how to build and run for WASI](https://devguide.python.org/getting-started/setup-building/#wasi). + +## Detecting WASI builds + +### Python code + +```python +import os, sys + +if sys.platform == "wasi": + # Python on WASI + ... + +if os.name == "posix": + # WASM platforms identify as POSIX-like. + # Windows does not provide os.uname(). + machine = os.uname().machine + if machine.startswith("wasm"): + # WebAssembly (wasm32, wasm64 potentially in the future) +``` + +```python +>>> import os, sys +>>> os.uname() +posix.uname_result( + sysname='wasi', + nodename='(none)', + release='0.0.0', + version='0.0.0', + machine='wasm32' +) +>>> os.name +'posix' +>>> sys.platform +'wasi' +``` + +### C code + +WASI SDK defines several built-in macros. You can dump a full list of built-ins +with ``/path/to/wasi-sdk/bin/clang -dM -E - < /dev/null``. + +* WebAssembly ``__wasm__`` (also ``__wasm``) +* wasm32 ``__wasm32__`` (also ``__wasm32``) +* wasm64 ``__wasm64__`` +* WASI ``__wasi__`` diff --git a/Platforms/WASI/__main__.py b/Platforms/WASI/__main__.py new file mode 100644 index 00000000000000..ab4c3030a1ce51 --- /dev/null +++ b/Platforms/WASI/__main__.py @@ -0,0 +1,557 @@ +#!/usr/bin/env python3 + +import argparse +import contextlib +import functools +import os + +import tomllib + +try: + from os import process_cpu_count as cpu_count +except ImportError: + from os import cpu_count +import pathlib +import shutil +import subprocess +import sys +import sysconfig +import tempfile + +CHECKOUT = HERE = pathlib.Path(__file__).parent + +while CHECKOUT != CHECKOUT.parent: + if (CHECKOUT / "configure").is_file(): + break + CHECKOUT = CHECKOUT.parent +else: + raise FileNotFoundError( + "Unable to find the root of the CPython checkout by looking for 'configure'" + ) + +CROSS_BUILD_DIR = CHECKOUT / "cross-build" +# Build platform can also be found via `config.guess`. +BUILD_DIR = CROSS_BUILD_DIR / sysconfig.get_config_var("BUILD_GNU_TYPE") + +LOCAL_SETUP = CHECKOUT / "Modules" / "Setup.local" +LOCAL_SETUP_MARKER = ( + b"# Generated by Tools/wasm/wasi .\n" + b"# Required to statically build extension modules." +) + +WASI_SDK_VERSION = 29 + +WASMTIME_VAR_NAME = "WASMTIME" +WASMTIME_HOST_RUNNER_VAR = f"{{{WASMTIME_VAR_NAME}}}" + + +def separator(): + """Print a separator line across the terminal width.""" + try: + tput_output = subprocess.check_output( + ["tput", "cols"], encoding="utf-8" + ) + except subprocess.CalledProcessError: + terminal_width = 80 + else: + terminal_width = int(tput_output.strip()) + print("โŽฏ" * terminal_width) + + +def log(emoji, message, *, spacing=None): + """Print a notification with an emoji. + + If 'spacing' is None, calculate the spacing based on the number of code points + in the emoji as terminals "eat" a space when the emoji has multiple code points. + """ + if spacing is None: + spacing = " " if len(emoji) == 1 else " " + print("".join([emoji, spacing, message])) + + +def updated_env(updates={}): + """Create a new dict representing the environment to use. + + The changes made to the execution environment are printed out. + """ + env_defaults = {} + # https://reproducible-builds.org/docs/source-date-epoch/ + git_epoch_cmd = ["git", "log", "-1", "--pretty=%ct"] + try: + epoch = subprocess.check_output( + git_epoch_cmd, encoding="utf-8" + ).strip() + env_defaults["SOURCE_DATE_EPOCH"] = epoch + except subprocess.CalledProcessError: + pass # Might be building from a tarball. + # This layering lets SOURCE_DATE_EPOCH from os.environ takes precedence. + environment = env_defaults | os.environ | updates + + env_diff = {} + for key, value in environment.items(): + if os.environ.get(key) != value: + env_diff[key] = value + + env_vars = ( + f"\n {key}={item}" for key, item in sorted(env_diff.items()) + ) + log("๐ŸŒŽ", f"Environment changes:{''.join(env_vars)}") + + return environment + + +def subdir(working_dir, *, clean_ok=False): + """Decorator to change to a working directory.""" + + def decorator(func): + @functools.wraps(func) + def wrapper(context): + nonlocal working_dir + + if callable(working_dir): + working_dir = working_dir(context) + separator() + log("๐Ÿ“", os.fsdecode(working_dir)) + if ( + clean_ok + and getattr(context, "clean", False) + and working_dir.exists() + ): + log("๐Ÿšฎ", "Deleting directory (--clean)...") + shutil.rmtree(working_dir) + + working_dir.mkdir(parents=True, exist_ok=True) + + with contextlib.chdir(working_dir): + return func(context, working_dir) + + return wrapper + + return decorator + + +def call(command, *, context=None, quiet=False, logdir=None, **kwargs): + """Execute a command. + + If 'quiet' is true, then redirect stdout and stderr to a temporary file. + """ + if context is not None: + quiet = context.quiet + logdir = context.logdir + elif quiet and logdir is None: + raise ValueError("When quiet is True, logdir must be specified") + + log("โฏ", " ".join(map(str, command)), spacing=" ") + if not quiet: + stdout = None + stderr = None + else: + stdout = tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + delete=False, + dir=logdir, + prefix="cpython-wasi-", + suffix=".log", + ) + stderr = subprocess.STDOUT + log("๐Ÿ“", f"Logging output to {stdout.name} (--quiet)...") + + subprocess.check_call(command, **kwargs, stdout=stdout, stderr=stderr) + + +def build_python_path(): + """The path to the build Python binary.""" + binary = BUILD_DIR / "python" + if not binary.is_file(): + binary = binary.with_suffix(".exe") + if not binary.is_file(): + raise FileNotFoundError( + f"Unable to find `python(.exe)` in {BUILD_DIR}" + ) + + return binary + + +def build_python_is_pydebug(): + """Find out if the build Python is a pydebug build.""" + test = "import sys, test.support; sys.exit(test.support.Py_DEBUG)" + result = subprocess.run( + [build_python_path(), "-c", test], + capture_output=True, + ) + return bool(result.returncode) + + +@subdir(BUILD_DIR, clean_ok=True) +def configure_build_python(context, working_dir): + """Configure the build/host Python.""" + if LOCAL_SETUP.exists(): + if LOCAL_SETUP.read_bytes() == LOCAL_SETUP_MARKER: + log("๐Ÿ‘", f"{LOCAL_SETUP} exists ...") + else: + log("โš ๏ธ", f"{LOCAL_SETUP} exists, but has unexpected contents") + else: + log("๐Ÿ“", f"Creating {LOCAL_SETUP} ...") + LOCAL_SETUP.write_bytes(LOCAL_SETUP_MARKER) + + configure = [os.path.relpath(CHECKOUT / "configure", working_dir)] + if context.args: + configure.extend(context.args) + + call(configure, context=context) + + +@subdir(BUILD_DIR) +def make_build_python(context, working_dir): + """Make/build the build Python.""" + call(["make", "--jobs", str(cpu_count()), "all"], context=context) + + binary = build_python_path() + cmd = [ + binary, + "-c", + "import sys; " + "print(f'{sys.version_info.major}.{sys.version_info.minor}')", + ] + version = subprocess.check_output(cmd, encoding="utf-8").strip() + + log("๐ŸŽ‰", f"{binary} {version}") + + +def find_wasi_sdk(config): + """Find the path to the WASI SDK.""" + wasi_sdk_path = None + wasi_sdk_version = config["targets"]["wasi-sdk"] + + if wasi_sdk_path_env_var := os.environ.get("WASI_SDK_PATH"): + wasi_sdk_path = pathlib.Path(wasi_sdk_path_env_var) + else: + opt_path = pathlib.Path("/opt") + # WASI SDK versions have a ``.0`` suffix, but it's a constant; the WASI SDK team + # has said they don't plan to ever do a point release and all of their Git tags + # lack the ``.0`` suffix. + # Starting with WASI SDK 23, the tarballs went from containing a directory named + # ``wasi-sdk-{WASI_SDK_VERSION}.0`` to e.g. + # ``wasi-sdk-{WASI_SDK_VERSION}.0-x86_64-linux``. + potential_sdks = [ + path + for path in opt_path.glob(f"wasi-sdk-{wasi_sdk_version}.0*") + if path.is_dir() + ] + if len(potential_sdks) == 1: + wasi_sdk_path = potential_sdks[0] + elif (default_path := opt_path / "wasi-sdk").is_dir(): + wasi_sdk_path = default_path + + # Starting with WASI SDK 25, a VERSION file is included in the root + # of the SDK directory that we can read to warn folks when they are using + # an unsupported version. + if wasi_sdk_path and (version_file := wasi_sdk_path / "VERSION").is_file(): + version_details = version_file.read_text(encoding="utf-8") + found_version = version_details.splitlines()[0] + # Make sure there's a trailing dot to avoid false positives if somehow the + # supported version is a prefix of the found version (e.g. `25` and `2567`). + if not found_version.startswith(f"{wasi_sdk_version}."): + major_version = found_version.partition(".")[0] + log( + "โš ๏ธ", + f" Found WASI SDK {major_version}, " + f"but WASI SDK {wasi_sdk_version} is the supported version", + ) + + return wasi_sdk_path + + +def wasi_sdk_env(context): + """Calculate environment variables for building with wasi-sdk.""" + wasi_sdk_path = context.wasi_sdk_path + sysroot = wasi_sdk_path / "share" / "wasi-sysroot" + env = { + "CC": "clang", + "CPP": "clang-cpp", + "CXX": "clang++", + "AR": "llvm-ar", + "RANLIB": "ranlib", + } + + for env_var, binary_name in list(env.items()): + env[env_var] = os.fsdecode(wasi_sdk_path / "bin" / binary_name) + + if not wasi_sdk_path.name.startswith("wasi-sdk"): + for compiler in ["CC", "CPP", "CXX"]: + env[compiler] += f" --sysroot={sysroot}" + + env["PKG_CONFIG_PATH"] = "" + env["PKG_CONFIG_LIBDIR"] = os.pathsep.join( + map( + os.fsdecode, + [sysroot / "lib" / "pkgconfig", sysroot / "share" / "pkgconfig"], + ) + ) + env["PKG_CONFIG_SYSROOT_DIR"] = os.fsdecode(sysroot) + + env["WASI_SDK_PATH"] = os.fsdecode(wasi_sdk_path) + env["WASI_SYSROOT"] = os.fsdecode(sysroot) + + env["PATH"] = os.pathsep.join([ + os.fsdecode(wasi_sdk_path / "bin"), + os.environ["PATH"], + ]) + + return env + + +@subdir(lambda context: CROSS_BUILD_DIR / context.host_triple, clean_ok=True) +def configure_wasi_python(context, working_dir): + """Configure the WASI/host build.""" + if not context.wasi_sdk_path or not context.wasi_sdk_path.exists(): + raise ValueError( + "WASI-SDK not found; " + "download from " + "https://github.com/WebAssembly/wasi-sdk and/or " + "specify via $WASI_SDK_PATH or --wasi-sdk" + ) + + config_site = os.fsdecode(HERE / "config.site-wasm32-wasi") + + wasi_build_dir = working_dir.relative_to(CHECKOUT) + + python_build_dir = BUILD_DIR / "build" + lib_dirs = list(python_build_dir.glob("lib.*")) + assert len(lib_dirs) == 1, ( + f"Expected a single lib.* directory in {python_build_dir}" + ) + lib_dir = os.fsdecode(lib_dirs[0]) + python_version = lib_dir.rpartition("-")[-1] + sysconfig_data_dir = ( + f"{wasi_build_dir}/build/lib.wasi-wasm32-{python_version}" + ) + + # Use PYTHONPATH to include sysconfig data which must be anchored to the + # WASI guest's `/` directory. + args = { + "PYTHONPATH": f"/{sysconfig_data_dir}", + "PYTHON_WASM": working_dir / "python.wasm", + } + # Check dynamically for wasmtime in case it was specified manually via + # `--host-runner`. + if WASMTIME_HOST_RUNNER_VAR in context.host_runner: + if wasmtime := shutil.which("wasmtime"): + args[WASMTIME_VAR_NAME] = wasmtime + else: + raise FileNotFoundError( + "wasmtime not found; download from " + "https://github.com/bytecodealliance/wasmtime" + ) + host_runner = context.host_runner.format_map(args) + env_additions = {"CONFIG_SITE": config_site, "HOSTRUNNER": host_runner} + build_python = os.fsdecode(build_python_path()) + # The path to `configure` MUST be relative, else `python.wasm` is unable + # to find the stdlib due to Python not recognizing that it's being + # executed from within a checkout. + configure = [ + os.path.relpath(CHECKOUT / "configure", working_dir), + f"--host={context.host_triple}", + f"--build={BUILD_DIR.name}", + f"--with-build-python={build_python}", + ] + if build_python_is_pydebug(): + configure.append("--with-pydebug") + if context.args: + configure.extend(context.args) + call( + configure, + env=updated_env(env_additions | wasi_sdk_env(context)), + context=context, + ) + + python_wasm = working_dir / "python.wasm" + exec_script = working_dir / "python.sh" + with exec_script.open("w", encoding="utf-8") as file: + file.write(f'#!/bin/sh\nexec {host_runner} {python_wasm} "$@"\n') + exec_script.chmod(0o755) + log("๐Ÿƒ", f"Created {exec_script} (--host-runner)... ") + sys.stdout.flush() + + +@subdir(lambda context: CROSS_BUILD_DIR / context.host_triple) +def make_wasi_python(context, working_dir): + """Run `make` for the WASI/host build.""" + call( + ["make", "--jobs", str(cpu_count()), "all"], + env=updated_env(), + context=context, + ) + + exec_script = working_dir / "python.sh" + call([exec_script, "--version"], quiet=False) + log( + "๐ŸŽ‰", + f"Use `{exec_script.relative_to(context.init_dir)}` " + "to run CPython w/ the WASI host specified by --host-runner", + ) + + +def clean_contents(context): + """Delete all files created by this script.""" + if CROSS_BUILD_DIR.exists(): + log("๐Ÿงน", f"Deleting {CROSS_BUILD_DIR} ...") + shutil.rmtree(CROSS_BUILD_DIR) + + if LOCAL_SETUP.exists(): + if LOCAL_SETUP.read_bytes() == LOCAL_SETUP_MARKER: + log("๐Ÿงน", f"Deleting generated {LOCAL_SETUP} ...") + + +def build_steps(*steps): + """Construct a command from other steps.""" + + def builder(context): + for step in steps: + step(context) + + return builder + + +def main(): + with (HERE / "config.toml").open("rb") as file: + config = tomllib.load(file) + default_wasi_sdk = find_wasi_sdk(config) + default_host_triple = config["targets"]["host-triple"] + default_host_runner = ( + f"{WASMTIME_HOST_RUNNER_VAR} run " + # For setting PYTHONPATH to the sysconfig data directory. + "--env PYTHONPATH={PYTHONPATH} " + # Map the checkout to / to load the stdlib from /Lib. + f"--dir {os.fsdecode(CHECKOUT)}::/ " + # Flags involving --optimize, --codegen, --debug, --wasm, and --wasi can be kept + # in a config file. + # We are using such a file to act as defaults in case a user wants to override + # only some of the settings themselves, make it easy to modify settings + # post-build so that they immediately apply to the Makefile instead of having to + # regenerate it, and allow for easy copying of the settings for anyone else who + # may want to use them. + f"--config {os.fsdecode(HERE / 'wasmtime.toml')}" + ) + default_logdir = pathlib.Path(tempfile.gettempdir()) + + parser = argparse.ArgumentParser() + subcommands = parser.add_subparsers(dest="subcommand") + build = subcommands.add_parser("build", help="Build everything") + configure_build = subcommands.add_parser( + "configure-build-python", help="Run `configure` for the build Python" + ) + make_build = subcommands.add_parser( + "make-build-python", help="Run `make` for the build Python" + ) + build_python = subcommands.add_parser( + "build-python", help="Build the build Python" + ) + configure_host = subcommands.add_parser( + "configure-host", + help="Run `configure` for the " + "host/WASI (pydebug builds " + "are inferred from the build " + "Python)", + ) + make_host = subcommands.add_parser( + "make-host", help="Run `make` for the host/WASI" + ) + build_host = subcommands.add_parser( + "build-host", help="Build the host/WASI Python" + ) + subcommands.add_parser( + "clean", help="Delete files and directories created by this script" + ) + for subcommand in ( + build, + configure_build, + make_build, + build_python, + configure_host, + make_host, + build_host, + ): + subcommand.add_argument( + "--quiet", + action="store_true", + default=False, + dest="quiet", + help="Redirect output from subprocesses to a log file", + ) + subcommand.add_argument( + "--logdir", + type=pathlib.Path, + default=default_logdir, + help=f"Directory to store log files; defaults to {default_logdir}", + ) + for subcommand in ( + configure_build, + configure_host, + build_python, + build_host, + ): + subcommand.add_argument( + "--clean", + action="store_true", + default=False, + dest="clean", + help="Delete any relevant directories before building", + ) + for subcommand in ( + build, + configure_build, + configure_host, + build_python, + build_host, + ): + subcommand.add_argument( + "args", nargs="*", help="Extra arguments to pass to `configure`" + ) + for subcommand in build, configure_host, build_host: + subcommand.add_argument( + "--wasi-sdk", + type=pathlib.Path, + dest="wasi_sdk_path", + default=default_wasi_sdk, + help=f"Path to the WASI SDK; defaults to {default_wasi_sdk}", + ) + subcommand.add_argument( + "--host-runner", + action="store", + default=default_host_runner, + dest="host_runner", + help="Command template for running the WASI host; defaults to " + f"`{default_host_runner}`", + ) + for subcommand in build, configure_host, make_host, build_host: + subcommand.add_argument( + "--host-triple", + action="store", + default=default_host_triple, + help="The target triple for the WASI host build; " + f"defaults to {default_host_triple}", + ) + + context = parser.parse_args() + context.init_dir = pathlib.Path().absolute() + + build_build_python = build_steps(configure_build_python, make_build_python) + build_wasi_python = build_steps(configure_wasi_python, make_wasi_python) + + dispatch = { + "configure-build-python": configure_build_python, + "make-build-python": make_build_python, + "build-python": build_build_python, + "configure-host": configure_wasi_python, + "make-host": make_wasi_python, + "build-host": build_wasi_python, + "build": build_steps(build_build_python, build_wasi_python), + "clean": clean_contents, + } + dispatch[context.subcommand](context) + + +if __name__ == "__main__": + main() diff --git a/Tools/wasm/wasi/config.site-wasm32-wasi b/Platforms/WASI/config.site-wasm32-wasi similarity index 100% rename from Tools/wasm/wasi/config.site-wasm32-wasi rename to Platforms/WASI/config.site-wasm32-wasi diff --git a/Tools/wasm/wasi/config.toml b/Platforms/WASI/config.toml similarity index 100% rename from Tools/wasm/wasi/config.toml rename to Platforms/WASI/config.toml diff --git a/Tools/wasm/wasi/wasmtime.toml b/Platforms/WASI/wasmtime.toml similarity index 100% rename from Tools/wasm/wasi/wasmtime.toml rename to Platforms/WASI/wasmtime.toml diff --git a/Tools/wasm/README.md b/Tools/wasm/README.md index 35685db07b1431..46228a5212a315 100644 --- a/Tools/wasm/README.md +++ b/Tools/wasm/README.md @@ -1,19 +1,16 @@ -# Python WebAssembly (WASM) build +# Python Emscripten (wasm32-emscripten) build -**WASI support is [tier 2](https://peps.python.org/pep-0011/#tier-2).** **Emscripten support is [tier 3](https://peps.python.org/pep-0011/#tier-3).** This directory contains configuration and helpers to facilitate cross -compilation of CPython to WebAssembly (WASM). Python supports Emscripten -(*wasm32-emscripten*) and WASI (*wasm32-wasi*) targets. Emscripten builds -run in modern browsers and JavaScript runtimes like *Node.js*. WASI builds -use WASM runtimes such as *wasmtime*. +compilation of CPython to WebAssembly (WASM) using Emscripten. Emscripten builds +run in modern browsers and JavaScript runtimes like *Node.js*. + +For WASI builds, see [Platforms/WASI](../../Platforms/WASI/README.md). **NOTE**: If you are looking for general information about WebAssembly that is not directly related to CPython, please see https://github.com/psf/webassembly. -## Emscripten (wasm32-emscripten) - ### Build See [the devguide instructions for building for Emscripten](https://devguide.python.org/getting-started/setup-building/#emscripten). @@ -192,11 +189,7 @@ await createEmscriptenModule({ - Test modules are disabled by default. Use ``--enable-test-modules`` build test modules like ``_testcapi``. -## WASI (wasm32-wasi) - -See [the devguide on how to build and run for WASI](https://devguide.python.org/getting-started/setup-building/#wasi). - -## Detecting WebAssembly builds +## Detecting Emscripten builds ### Python code @@ -206,9 +199,6 @@ import os, sys if sys.platform == "emscripten": # Python on Emscripten ... -if sys.platform == "wasi": - # Python on WASI - ... if os.name == "posix": # WASM platforms identify as POSIX-like. @@ -251,31 +241,14 @@ sys._emscripten_info( ) ``` -```python ->>> import os, sys ->>> os.uname() -posix.uname_result( - sysname='wasi', - nodename='(none)', - release='0.0.0', - version='0.0.0', - machine='wasm32' -) ->>> os.name -'posix' ->>> sys.platform -'wasi' -``` - ### C code -Emscripten SDK and WASI SDK define several built-in macros. You can dump a -full list of built-ins with ``emcc -dM -E - < /dev/null`` and -``/path/to/wasi-sdk/bin/clang -dM -E - < /dev/null``. +Emscripten SDK defines several built-in macros. You can dump a full list of +built-ins with ``emcc -dM -E - < /dev/null``. * WebAssembly ``__wasm__`` (also ``__wasm``) * wasm32 ``__wasm32__`` (also ``__wasm32``) * wasm64 ``__wasm64__`` * Emscripten ``__EMSCRIPTEN__`` (also ``EMSCRIPTEN``) * Emscripten version ``__EMSCRIPTEN_major__``, ``__EMSCRIPTEN_minor__``, ``__EMSCRIPTEN_tiny__`` -* WASI ``__wasi__`` + diff --git a/Tools/wasm/wasi.py b/Tools/wasm/wasi.py index af55e03d10f754..72a708c45c02af 100644 --- a/Tools/wasm/wasi.py +++ b/Tools/wasm/wasi.py @@ -1,12 +1,5 @@ if __name__ == "__main__": import pathlib import runpy - import sys - - print( - "โš ๏ธ WARNING: This script is deprecated and slated for removal in Python 3.20; " - "execute the `wasi/` directory instead (i.e. `python Tools/wasm/wasi`)\n", - file=sys.stderr, - ) runpy.run_path(pathlib.Path(__file__).parent / "wasi", run_name="__main__") diff --git a/Tools/wasm/wasi/__main__.py b/Tools/wasm/wasi/__main__.py index 3d9a2472d4dcbf..e6126eddde1f84 100644 --- a/Tools/wasm/wasi/__main__.py +++ b/Tools/wasm/wasi/__main__.py @@ -1,554 +1,14 @@ -#!/usr/bin/env python3 - -import argparse -import contextlib -import functools -import os - -import tomllib - -try: - from os import process_cpu_count as cpu_count -except ImportError: - from os import cpu_count -import pathlib -import shutil -import subprocess -import sys -import sysconfig -import tempfile - -HERE = pathlib.Path(__file__).parent - -# Path is: cpython/Tools/wasm/wasi -CHECKOUT = HERE.parent.parent.parent -assert (CHECKOUT / "configure").is_file(), ( - "Please update the location of the file" -) - -CROSS_BUILD_DIR = CHECKOUT / "cross-build" -# Build platform can also be found via `config.guess`. -BUILD_DIR = CROSS_BUILD_DIR / sysconfig.get_config_var("BUILD_GNU_TYPE") - -LOCAL_SETUP = CHECKOUT / "Modules" / "Setup.local" -LOCAL_SETUP_MARKER = ( - b"# Generated by Tools/wasm/wasi .\n" - b"# Required to statically build extension modules." -) - -WASI_SDK_VERSION = 29 - -WASMTIME_VAR_NAME = "WASMTIME" -WASMTIME_HOST_RUNNER_VAR = f"{{{WASMTIME_VAR_NAME}}}" - - -def separator(): - """Print a separator line across the terminal width.""" - try: - tput_output = subprocess.check_output( - ["tput", "cols"], encoding="utf-8" - ) - except subprocess.CalledProcessError: - terminal_width = 80 - else: - terminal_width = int(tput_output.strip()) - print("โŽฏ" * terminal_width) - - -def log(emoji, message, *, spacing=None): - """Print a notification with an emoji. - - If 'spacing' is None, calculate the spacing based on the number of code points - in the emoji as terminals "eat" a space when the emoji has multiple code points. - """ - if spacing is None: - spacing = " " if len(emoji) == 1 else " " - print("".join([emoji, spacing, message])) - - -def updated_env(updates={}): - """Create a new dict representing the environment to use. - - The changes made to the execution environment are printed out. - """ - env_defaults = {} - # https://reproducible-builds.org/docs/source-date-epoch/ - git_epoch_cmd = ["git", "log", "-1", "--pretty=%ct"] - try: - epoch = subprocess.check_output( - git_epoch_cmd, encoding="utf-8" - ).strip() - env_defaults["SOURCE_DATE_EPOCH"] = epoch - except subprocess.CalledProcessError: - pass # Might be building from a tarball. - # This layering lets SOURCE_DATE_EPOCH from os.environ takes precedence. - environment = env_defaults | os.environ | updates - - env_diff = {} - for key, value in environment.items(): - if os.environ.get(key) != value: - env_diff[key] = value - - env_vars = ( - f"\n {key}={item}" for key, item in sorted(env_diff.items()) - ) - log("๐ŸŒŽ", f"Environment changes:{''.join(env_vars)}") - - return environment - - -def subdir(working_dir, *, clean_ok=False): - """Decorator to change to a working directory.""" - - def decorator(func): - @functools.wraps(func) - def wrapper(context): - nonlocal working_dir - - if callable(working_dir): - working_dir = working_dir(context) - separator() - log("๐Ÿ“", os.fsdecode(working_dir)) - if ( - clean_ok - and getattr(context, "clean", False) - and working_dir.exists() - ): - log("๐Ÿšฎ", "Deleting directory (--clean)...") - shutil.rmtree(working_dir) - - working_dir.mkdir(parents=True, exist_ok=True) - - with contextlib.chdir(working_dir): - return func(context, working_dir) - - return wrapper - - return decorator - - -def call(command, *, context=None, quiet=False, logdir=None, **kwargs): - """Execute a command. - - If 'quiet' is true, then redirect stdout and stderr to a temporary file. - """ - if context is not None: - quiet = context.quiet - logdir = context.logdir - elif quiet and logdir is None: - raise ValueError("When quiet is True, logdir must be specified") - - log("โฏ", " ".join(map(str, command)), spacing=" ") - if not quiet: - stdout = None - stderr = None - else: - stdout = tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - delete=False, - dir=logdir, - prefix="cpython-wasi-", - suffix=".log", - ) - stderr = subprocess.STDOUT - log("๐Ÿ“", f"Logging output to {stdout.name} (--quiet)...") - - subprocess.check_call(command, **kwargs, stdout=stdout, stderr=stderr) - - -def build_python_path(): - """The path to the build Python binary.""" - binary = BUILD_DIR / "python" - if not binary.is_file(): - binary = binary.with_suffix(".exe") - if not binary.is_file(): - raise FileNotFoundError( - f"Unable to find `python(.exe)` in {BUILD_DIR}" - ) - - return binary - - -def build_python_is_pydebug(): - """Find out if the build Python is a pydebug build.""" - test = "import sys, test.support; sys.exit(test.support.Py_DEBUG)" - result = subprocess.run( - [build_python_path(), "-c", test], - capture_output=True, - ) - return bool(result.returncode) - - -@subdir(BUILD_DIR, clean_ok=True) -def configure_build_python(context, working_dir): - """Configure the build/host Python.""" - if LOCAL_SETUP.exists(): - if LOCAL_SETUP.read_bytes() == LOCAL_SETUP_MARKER: - log("๐Ÿ‘", f"{LOCAL_SETUP} exists ...") - else: - log("โš ๏ธ", f"{LOCAL_SETUP} exists, but has unexpected contents") - else: - log("๐Ÿ“", f"Creating {LOCAL_SETUP} ...") - LOCAL_SETUP.write_bytes(LOCAL_SETUP_MARKER) - - configure = [os.path.relpath(CHECKOUT / "configure", working_dir)] - if context.args: - configure.extend(context.args) - - call(configure, context=context) - - -@subdir(BUILD_DIR) -def make_build_python(context, working_dir): - """Make/build the build Python.""" - call(["make", "--jobs", str(cpu_count()), "all"], context=context) - - binary = build_python_path() - cmd = [ - binary, - "-c", - "import sys; " - "print(f'{sys.version_info.major}.{sys.version_info.minor}')", - ] - version = subprocess.check_output(cmd, encoding="utf-8").strip() - - log("๐ŸŽ‰", f"{binary} {version}") - - -def find_wasi_sdk(config): - """Find the path to the WASI SDK.""" - wasi_sdk_path = None - wasi_sdk_version = config["targets"]["wasi-sdk"] - - if wasi_sdk_path_env_var := os.environ.get("WASI_SDK_PATH"): - wasi_sdk_path = pathlib.Path(wasi_sdk_path_env_var) - else: - opt_path = pathlib.Path("/opt") - # WASI SDK versions have a ``.0`` suffix, but it's a constant; the WASI SDK team - # has said they don't plan to ever do a point release and all of their Git tags - # lack the ``.0`` suffix. - # Starting with WASI SDK 23, the tarballs went from containing a directory named - # ``wasi-sdk-{WASI_SDK_VERSION}.0`` to e.g. - # ``wasi-sdk-{WASI_SDK_VERSION}.0-x86_64-linux``. - potential_sdks = [ - path - for path in opt_path.glob(f"wasi-sdk-{wasi_sdk_version}.0*") - if path.is_dir() - ] - if len(potential_sdks) == 1: - wasi_sdk_path = potential_sdks[0] - elif (default_path := opt_path / "wasi-sdk").is_dir(): - wasi_sdk_path = default_path - - # Starting with WASI SDK 25, a VERSION file is included in the root - # of the SDK directory that we can read to warn folks when they are using - # an unsupported version. - if wasi_sdk_path and (version_file := wasi_sdk_path / "VERSION").is_file(): - version_details = version_file.read_text(encoding="utf-8") - found_version = version_details.splitlines()[0] - # Make sure there's a trailing dot to avoid false positives if somehow the - # supported version is a prefix of the found version (e.g. `25` and `2567`). - if not found_version.startswith(f"{wasi_sdk_version}."): - major_version = found_version.partition(".")[0] - log( - "โš ๏ธ", - f" Found WASI SDK {major_version}, " - f"but WASI SDK {wasi_sdk_version} is the supported version", - ) - - return wasi_sdk_path - - -def wasi_sdk_env(context): - """Calculate environment variables for building with wasi-sdk.""" - wasi_sdk_path = context.wasi_sdk_path - sysroot = wasi_sdk_path / "share" / "wasi-sysroot" - env = { - "CC": "clang", - "CPP": "clang-cpp", - "CXX": "clang++", - "AR": "llvm-ar", - "RANLIB": "ranlib", - } - - for env_var, binary_name in list(env.items()): - env[env_var] = os.fsdecode(wasi_sdk_path / "bin" / binary_name) - - if not wasi_sdk_path.name.startswith("wasi-sdk"): - for compiler in ["CC", "CPP", "CXX"]: - env[compiler] += f" --sysroot={sysroot}" - - env["PKG_CONFIG_PATH"] = "" - env["PKG_CONFIG_LIBDIR"] = os.pathsep.join( - map( - os.fsdecode, - [sysroot / "lib" / "pkgconfig", sysroot / "share" / "pkgconfig"], - ) - ) - env["PKG_CONFIG_SYSROOT_DIR"] = os.fsdecode(sysroot) - - env["WASI_SDK_PATH"] = os.fsdecode(wasi_sdk_path) - env["WASI_SYSROOT"] = os.fsdecode(sysroot) - - env["PATH"] = os.pathsep.join([ - os.fsdecode(wasi_sdk_path / "bin"), - os.environ["PATH"], - ]) - - return env - - -@subdir(lambda context: CROSS_BUILD_DIR / context.host_triple, clean_ok=True) -def configure_wasi_python(context, working_dir): - """Configure the WASI/host build.""" - if not context.wasi_sdk_path or not context.wasi_sdk_path.exists(): - raise ValueError( - "WASI-SDK not found; " - "download from " - "https://github.com/WebAssembly/wasi-sdk and/or " - "specify via $WASI_SDK_PATH or --wasi-sdk" - ) - - config_site = os.fsdecode(HERE / "config.site-wasm32-wasi") - - wasi_build_dir = working_dir.relative_to(CHECKOUT) - - python_build_dir = BUILD_DIR / "build" - lib_dirs = list(python_build_dir.glob("lib.*")) - assert len(lib_dirs) == 1, ( - f"Expected a single lib.* directory in {python_build_dir}" - ) - lib_dir = os.fsdecode(lib_dirs[0]) - python_version = lib_dir.rpartition("-")[-1] - sysconfig_data_dir = ( - f"{wasi_build_dir}/build/lib.wasi-wasm32-{python_version}" - ) - - # Use PYTHONPATH to include sysconfig data which must be anchored to the - # WASI guest's `/` directory. - args = { - "PYTHONPATH": f"/{sysconfig_data_dir}", - "PYTHON_WASM": working_dir / "python.wasm", - } - # Check dynamically for wasmtime in case it was specified manually via - # `--host-runner`. - if WASMTIME_HOST_RUNNER_VAR in context.host_runner: - if wasmtime := shutil.which("wasmtime"): - args[WASMTIME_VAR_NAME] = wasmtime - else: - raise FileNotFoundError( - "wasmtime not found; download from " - "https://github.com/bytecodealliance/wasmtime" - ) - host_runner = context.host_runner.format_map(args) - env_additions = {"CONFIG_SITE": config_site, "HOSTRUNNER": host_runner} - build_python = os.fsdecode(build_python_path()) - # The path to `configure` MUST be relative, else `python.wasm` is unable - # to find the stdlib due to Python not recognizing that it's being - # executed from within a checkout. - configure = [ - os.path.relpath(CHECKOUT / "configure", working_dir), - f"--host={context.host_triple}", - f"--build={BUILD_DIR.name}", - f"--with-build-python={build_python}", - ] - if build_python_is_pydebug(): - configure.append("--with-pydebug") - if context.args: - configure.extend(context.args) - call( - configure, - env=updated_env(env_additions | wasi_sdk_env(context)), - context=context, - ) - - python_wasm = working_dir / "python.wasm" - exec_script = working_dir / "python.sh" - with exec_script.open("w", encoding="utf-8") as file: - file.write(f'#!/bin/sh\nexec {host_runner} {python_wasm} "$@"\n') - exec_script.chmod(0o755) - log("๐Ÿƒ", f"Created {exec_script} (--host-runner)... ") - sys.stdout.flush() - - -@subdir(lambda context: CROSS_BUILD_DIR / context.host_triple) -def make_wasi_python(context, working_dir): - """Run `make` for the WASI/host build.""" - call( - ["make", "--jobs", str(cpu_count()), "all"], - env=updated_env(), - context=context, - ) - - exec_script = working_dir / "python.sh" - call([exec_script, "--version"], quiet=False) - log( - "๐ŸŽ‰", - f"Use `{exec_script.relative_to(context.init_dir)}` " - "to run CPython w/ the WASI host specified by --host-runner", - ) - - -def clean_contents(context): - """Delete all files created by this script.""" - if CROSS_BUILD_DIR.exists(): - log("๐Ÿงน", f"Deleting {CROSS_BUILD_DIR} ...") - shutil.rmtree(CROSS_BUILD_DIR) - - if LOCAL_SETUP.exists(): - if LOCAL_SETUP.read_bytes() == LOCAL_SETUP_MARKER: - log("๐Ÿงน", f"Deleting generated {LOCAL_SETUP} ...") - - -def build_steps(*steps): - """Construct a command from other steps.""" - - def builder(context): - for step in steps: - step(context) - - return builder - - -def main(): - with (HERE / "config.toml").open("rb") as file: - config = tomllib.load(file) - default_wasi_sdk = find_wasi_sdk(config) - default_host_triple = config["targets"]["host-triple"] - default_host_runner = ( - f"{WASMTIME_HOST_RUNNER_VAR} run " - # For setting PYTHONPATH to the sysconfig data directory. - "--env PYTHONPATH={PYTHONPATH} " - # Map the checkout to / to load the stdlib from /Lib. - f"--dir {os.fsdecode(CHECKOUT)}::/ " - # Flags involving --optimize, --codegen, --debug, --wasm, and --wasi can be kept - # in a config file. - # We are using such a file to act as defaults in case a user wants to override - # only some of the settings themselves, make it easy to modify settings - # post-build so that they immediately apply to the Makefile instead of having to - # regenerate it, and allow for easy copying of the settings for anyone else who - # may want to use them. - f"--config {os.fsdecode(HERE / 'wasmtime.toml')}" - ) - default_logdir = pathlib.Path(tempfile.gettempdir()) +if __name__ == "__main__": + import pathlib + import runpy + import sys - parser = argparse.ArgumentParser() - subcommands = parser.add_subparsers(dest="subcommand") - build = subcommands.add_parser("build", help="Build everything") - configure_build = subcommands.add_parser( - "configure-build-python", help="Run `configure` for the build Python" - ) - make_build = subcommands.add_parser( - "make-build-python", help="Run `make` for the build Python" - ) - build_python = subcommands.add_parser( - "build-python", help="Build the build Python" - ) - configure_host = subcommands.add_parser( - "configure-host", - help="Run `configure` for the " - "host/WASI (pydebug builds " - "are inferred from the build " - "Python)", - ) - make_host = subcommands.add_parser( - "make-host", help="Run `make` for the host/WASI" + print( + "โš ๏ธ WARNING: This script is deprecated and slated for removal in Python 3.20; " + "execute the `Platforms/WASI/` directory instead (i.e. `python Platforms/WASI`)\n", + file=sys.stderr, ) - build_host = subcommands.add_parser( - "build-host", help="Build the host/WASI Python" - ) - subcommands.add_parser( - "clean", help="Delete files and directories created by this script" - ) - for subcommand in ( - build, - configure_build, - make_build, - build_python, - configure_host, - make_host, - build_host, - ): - subcommand.add_argument( - "--quiet", - action="store_true", - default=False, - dest="quiet", - help="Redirect output from subprocesses to a log file", - ) - subcommand.add_argument( - "--logdir", - type=pathlib.Path, - default=default_logdir, - help=f"Directory to store log files; defaults to {default_logdir}", - ) - for subcommand in ( - configure_build, - configure_host, - build_python, - build_host, - ): - subcommand.add_argument( - "--clean", - action="store_true", - default=False, - dest="clean", - help="Delete any relevant directories before building", - ) - for subcommand in ( - build, - configure_build, - configure_host, - build_python, - build_host, - ): - subcommand.add_argument( - "args", nargs="*", help="Extra arguments to pass to `configure`" - ) - for subcommand in build, configure_host, build_host: - subcommand.add_argument( - "--wasi-sdk", - type=pathlib.Path, - dest="wasi_sdk_path", - default=default_wasi_sdk, - help=f"Path to the WASI SDK; defaults to {default_wasi_sdk}", - ) - subcommand.add_argument( - "--host-runner", - action="store", - default=default_host_runner, - dest="host_runner", - help="Command template for running the WASI host; defaults to " - f"`{default_host_runner}`", - ) - for subcommand in build, configure_host, make_host, build_host: - subcommand.add_argument( - "--host-triple", - action="store", - default=default_host_triple, - help="The target triple for the WASI host build; " - f"defaults to {default_host_triple}", - ) - - context = parser.parse_args() - context.init_dir = pathlib.Path().absolute() - build_build_python = build_steps(configure_build_python, make_build_python) - build_wasi_python = build_steps(configure_wasi_python, make_wasi_python) + checkout = pathlib.Path(__file__).parent.parent.parent.parent - dispatch = { - "configure-build-python": configure_build_python, - "make-build-python": make_build_python, - "build-python": build_build_python, - "configure-host": configure_wasi_python, - "make-host": make_wasi_python, - "build-host": build_wasi_python, - "build": build_steps(build_build_python, build_wasi_python), - "clean": clean_contents, - } - dispatch[context.subcommand](context) - - -if __name__ == "__main__": - main() + runpy.run_path(checkout / "Platforms" / "WASI", run_name="__main__") From b0bd6b3930fbb9baa07d528544a8477f785e5e7d Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Fri, 16 Jan 2026 15:16:29 -0800 Subject: [PATCH 2/2] Update .github/CODEOWNERS Co-authored-by: Zachary Ware --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cdb07f9f33fb9d..6ec22cf9c60201 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -176,7 +176,7 @@ Tools/wasm/config.site-wasm32-emscripten @freakboy3742 @emmatyping Tools/wasm/emscripten @freakboy3742 @emmatyping # WebAssembly (WASI) -platforms/ WASI @brettcannon @emmatyping @savannahostrowski +Platforms/WASI @brettcannon @emmatyping @savannahostrowski Tools/wasm/wasi-env @brettcannon @emmatyping @savannahostrowski Tools/wasm/wasi.py @brettcannon @emmatyping @savannahostrowski Tools/wasm/wasi @brettcannon @emmatyping @savannahostrowski