https://github.com/python/cpython/commit/b4201c7a502be3406fa5ebf99241402f994f4b82
commit: b4201c7a502be3406fa5ebf99241402f994f4b82
branch: main
author: Brett Cannon <[email protected]>
committer: brettcannon <[email protected]>
date: 2026-11-09T15:52:10-07:00
summary:

GH-161367: Introduce `Platforms/WASI test` command (#161435)

Co-authored-by: Hood Chatham <[email protected]>

files:
A Misc/NEWS.d/next/WASI/2026-11-04-16-34-48.gh-issue-161367.bO5MGy.rst
A Platforms/WASI/_test.py
M .github/workflows/reusable-wasi.yml
M Platforms/WASI/README.md
M Platforms/WASI/__main__.py
M Platforms/WASI/_build.py
M Platforms/WASI/_package.py
M Platforms/WASI/_shared.py

diff --git a/.github/workflows/reusable-wasi.yml 
b/.github/workflows/reusable-wasi.yml
index 12dd790897ad392..5b2fda1fe4f82dc 100644
--- a/.github/workflows/reusable-wasi.yml
+++ b/.github/workflows/reusable-wasi.yml
@@ -64,6 +64,4 @@ jobs:
     - name: "Display build info of the host/WASI Python"
       run: python3 Platforms/WASI pythoninfo-host
     - name: "Test"
-      run: |
-        CROSS_BUILD_WASI=$(python3 Platforms/WASI path)
-        make --directory "${CROSS_BUILD_WASI}" test
+      run: python3 Platforms/WASI test
diff --git 
a/Misc/NEWS.d/next/WASI/2026-11-04-16-34-48.gh-issue-161367.bO5MGy.rst 
b/Misc/NEWS.d/next/WASI/2026-11-04-16-34-48.gh-issue-161367.bO5MGy.rst
new file mode 100644
index 000000000000000..7d3e16444338007
--- /dev/null
+++ b/Misc/NEWS.d/next/WASI/2026-11-04-16-34-48.gh-issue-161367.bO5MGy.rst
@@ -0,0 +1,3 @@
+Add a ``test`` command to :file:`Platforms/WASI` with ``--fast-ci`` and
+``--slow-ci`` to select fast CI or slow Buildbot tests and ``--target`` to
+test the build, gathered distribution, or release archive.
diff --git a/Platforms/WASI/README.md b/Platforms/WASI/README.md
index 5fe610615aec336..c99b628d7b88a7e 100644
--- a/Platforms/WASI/README.md
+++ b/Platforms/WASI/README.md
@@ -199,7 +199,36 @@ python3 Platforms/WASI clean
 
 ### Testing
 
-To find out where the WASI build directory is, you can run:
+After building, you can run the CI tests with:
+
+```shell
+python3 Platforms/WASI test
+```
+
+The mutually exclusive CI flags to select which tests to run:
+
+- `--fast-ci` (the default): `make test`
+- `--slow-ci`: `make buildbottest`
+
+The `--target` flag selects the files to test:
+
+- `build` (the default): the WASI build in `cross-build/`.
+- `dist`: the distribution files gathered in `dist/`.
+- `archive`: the contents of the release archive, unpacked into `dist/`.
+
+To thoroughly test the contents of the release archive, use:
+
+```shell
+python3 Platforms/WASI test --slow-ci --target archive
+```
+
+This requires an archive created by `package` or `release`. It deletes the
+selected versioned distribution directory inside `dist/`, then restores it
+from the matching `.tar.xz` archive before running the tests.
+The archive and other distributions in `dist/` are left untouched.
+
+You can also run tests more directly. To find out where the WASI build 
directory
+is, you can run:
 
 ```shell
 python3 Platforms/WASI path
diff --git a/Platforms/WASI/__main__.py b/Platforms/WASI/__main__.py
index 0d504d43283297a..2fea2b7d4476cb0 100644
--- a/Platforms/WASI/__main__.py
+++ b/Platforms/WASI/__main__.py
@@ -5,6 +5,7 @@
     "_build",
     "_package",
     "_shared",
+    "_test",
 ]
 
 import argparse
@@ -14,6 +15,7 @@
 import _build
 import _package
 import _shared
+import _test
 
 
 def main():
@@ -76,6 +78,30 @@ def main():
     release = subcommands.add_parser(
         "release", help="Build and package for a release"
     )
+    test = subcommands.add_parser("test", help="Run the tests")
+    ci_mode = test.add_mutually_exclusive_group()
+    ci_mode.add_argument(
+        "--fast-ci",
+        action="store_const",
+        dest="ci_mode",
+        const="fast",
+        default="fast",
+        help="Run `make test` (the default)",
+    )
+    ci_mode.add_argument(
+        "--slow-ci",
+        action="store_const",
+        dest="ci_mode",
+        const="slow",
+        help="Run `make buildbottest`",
+    )
+    test.add_argument(
+        "--target",
+        choices={"build", "dist", "archive"},
+        default="build",
+        help="Test the build (the default), gathered distribution, or release 
archive; "
+        "'archive' replaces the distribution directory with the archive's 
contents",
+    )
     path = subcommands.add_parser(
         "path", help="Print the path to a build or distribution directory"
     )
@@ -100,6 +126,7 @@ def main():
         pythoninfo_host,
         build_host,
         release,
+        test,
     ):
         subcommand.add_argument(
             "--quiet",
@@ -129,6 +156,13 @@ def main():
             dest="clean",
             help="Delete any relevant directories before building",
         )
+    for subcommand in build, configure_build, configure_host:
+        subcommand.add_argument(
+            "--release",
+            help="Configure build for release mode",
+            action="store_true",
+            default=False,
+        )
     for subcommand in (
         build,
         configure_build,
@@ -137,7 +171,7 @@ def main():
         build_host,
     ):
         subcommand.add_argument(
-            "args", nargs="*", help="Extra arguments to pass to `configure`"
+            "_args", nargs="*", help="Extra arguments to pass to `configure`"
         )
     for subcommand in build, configure_host, build_host, release:
         subcommand.add_argument(
@@ -167,6 +201,7 @@ def main():
         package,
         path,
         release,
+        test,
     ):
         subcommand.add_argument(
             "--host-triple",
@@ -219,7 +254,7 @@ def main():
             _package.gather(context)
             _package.archive(context)
         case "release":
-            context.args = ["--without-static-libpython"]
+            context.release = True
             _build.clean_contents(context)
             _build.configure_build_python(context)
             _build.make_build_python(context)
@@ -227,6 +262,8 @@ def main():
             _build.make_wasi_python(context)
             _package.gather(context)
             _package.archive(context)
+        case "test":
+            _test.test(context)
         case "path":
             paths = {
                 "build-python": "build_python_path",
diff --git a/Platforms/WASI/_build.py b/Platforms/WASI/_build.py
index 9ea92a58ef5bb34..8efda324c4b048a 100644
--- a/Platforms/WASI/_build.py
+++ b/Platforms/WASI/_build.py
@@ -1,22 +1,16 @@
 __lazy_modules__ = [
-    "contextlib",
-    "functools",
     "os",
     "pathlib",
     "shutil",
     "subprocess",
     "sys",
-    "tempfile",
 ]
 
-import contextlib
-import functools
 import os
 import pathlib
 import shutil
 import subprocess
 import sys
-import tempfile
 
 try:
     from os import process_cpu_count as cpu_count
@@ -29,131 +23,19 @@
 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 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())
-    ]
-    _shared.log("๐ŸŒŽ", f"Environment changes:{''.join(env_vars)}")
-
-    return environment
-
-
-def subdir(context_attr, *, clean_ok=False):
-    """Decorator to change to a working directory."""
-
-    def decorator(func):
-        @functools.wraps(func)
-        def wrapper(context):
-            nonlocal context_attr
-
-            working_dir = getattr(context, context_attr)
-            separator()
-            _shared.log("๐Ÿ“", os.fsdecode(working_dir))
-            if clean_ok and context.clean and working_dir.exists():
-                _shared.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, **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
-
-    _shared.log("โฏ", " ".join(map(str, command)), spacing="  ")
-    if not quiet:
-        stdout = None
-        stderr = None
-    else:
-        if (log_path := getattr(context, "log_path", None)) is None:
-            log_path = pathlib.Path(tempfile.gettempdir())
-        log_path.mkdir(parents=True, exist_ok=True)
-        stdout = tempfile.NamedTemporaryFile(
-            "w",
-            encoding="utf-8",
-            delete=False,
-            dir=log_path,
-            prefix="cpython-wasi-",
-            suffix=".log",
-        )
-        stderr = subprocess.STDOUT
-        _shared.log("๐Ÿ“", f"Logging output to {stdout.name} (--quiet)...")
-
-    try:
-        subprocess.check_call(command, **kwargs, stdout=stdout, stderr=stderr)
-    except subprocess.CalledProcessError as error:
-        if quiet:
-            _shared.log("โŒ", f"Exit code {error.returncode}")
-            separator()
-            with open(stdout.name, encoding="utf-8") as file:
-                lines = file.readlines()
-                # Inefficient, but the log shouldn't be dramatically large.
-                print("".join(lines[-10:]), end="")
-                if not lines[-1].endswith("\n"):
-                    print()
-        sys.exit(error.returncode)
-
-
-@subdir("build_python_path", clean_ok=True)
+@_shared.subdir("build_python_path", clean_ok=True)
 def configure_build_python(context, working_dir):
     """Configure the build/host Python."""
     configure = [os.path.relpath(context.checkout / "configure", working_dir)]
-    if context.args:
-        configure.extend(context.args)
+    configure.extend(context.configure_args)
 
-    call(configure, context=context)
+    context.call(configure)
 
 
-@subdir("build_python_path")
+@_shared.subdir("build_python_path")
 def make_build_python(context, _working_dir):
     """Make/build the build Python."""
-    call(["make", "--jobs", str(cpu_count()), "all"], context=context)
+    context.call(["make", "--jobs", str(cpu_count()), "all"])
 
     binary = context.build_python_interpreter
     cmd = [
@@ -206,7 +88,7 @@ def wasi_sdk_env(context):
     return env
 
 
-@subdir("wasi_build_path", clean_ok=True)
+@_shared.subdir("wasi_build_path", clean_ok=True)
 def configure_wasi_python(context, working_dir):
     """Configure the WASI/host build."""
     config_site = os.fsdecode(context.here / "config.site-wasm32-wasi")
@@ -247,12 +129,10 @@ def configure_wasi_python(context, working_dir):
     ]
     if context.is_debug:
         configure.append("--with-pydebug")
-    if context.args:
-        configure.extend(context.args)
-    call(
+    configure.extend(context.configure_args)
+    context.call(
         configure,
-        env=updated_env(env_additions | wasi_sdk_env(context)),
-        context=context,
+        env=_shared.updated_env(env_additions | wasi_sdk_env(context)),
     )
 
     python_wasm = working_dir / "python.wasm"
@@ -264,17 +144,18 @@ def configure_wasi_python(context, working_dir):
     sys.stdout.flush()
 
 
-@subdir("wasi_build_path")
+@_shared.subdir("wasi_build_path")
 def make_wasi_python(context, working_dir):
     """Run `make` for the WASI/host build."""
-    call(
+    context.call(
         ["make", "--jobs", str(cpu_count()), "all"],
-        env=updated_env(),
-        context=context,
+        env=_shared.updated_env(),
     )
 
     exec_script = working_dir / "python.sh"
-    call([exec_script, "-c", "import sys; print(sys.version)"], quiet=False)
+    context.call(
+        [exec_script, "-c", "import sys; print(sys.version)"], quiet=False
+    )
     _shared.log(
         "๐ŸŽ‰",
         f"Use `{exec_script.relative_to(pathlib.Path().absolute())}` "
@@ -291,13 +172,13 @@ def clean_contents(context):
             shutil.rmtree(path)
 
 
-@subdir("build_python_path")
+@_shared.subdir("build_python_path")
 def pythoninfo_build_python(context, working_dir):
     """Display build info of the build Python."""
-    call(["make", "pythoninfo"], context=context)
+    context.call(["make", "pythoninfo"])
 
 
-@subdir("wasi_build_path")
+@_shared.subdir("wasi_build_path")
 def pythoninfo_wasi_python(context, working_dir):
     """Display build info of the host/WASI Python."""
-    call(["make", "pythoninfo"], context=context)
+    context.call(["make", "pythoninfo"])
diff --git a/Platforms/WASI/_package.py b/Platforms/WASI/_package.py
index 862ffb30130d048..403247d4734a3d6 100644
--- a/Platforms/WASI/_package.py
+++ b/Platforms/WASI/_package.py
@@ -26,32 +26,18 @@ def wasmtime_script(context):
 
 script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd -P)
 root=$(CDPATH= cd "$script_dir/.." && pwd -P)
-wasm_file="$root/lib/python{python_version(context)}/lib-wasm/python{python_version(context,
 debug_ok=True)}.wasm"
+wasm_file="$root/lib/python{context.python_version()}/lib-wasm/python{context.python_version(debug_ok=True)}.wasm"
 
 exec wasmtime run \\
     --argv0 "$wasm_file" \\
-    --config "$root/etc/python{python_version(context)}/wasmtime.toml" \\
+    --config "$root/etc/python{context.python_version()}/wasmtime.toml" \\
     --dir "/" \\
     "$wasm_file" "$@"
 """
 
 
-def python_version(context, debug_ok=False):
-    """Calculate the M.N part of Python's version.
-
-    If *debug_ok* is True, then "d" is appended as appropriate.
-    """
-    details = context.wasi_build_details
-    major = details["language"]["version_info"]["major"]
-    minor = details["language"]["version_info"]["minor"]
-    version = f"{major}.{minor}"
-    if debug_ok and context.is_debug:
-        version += "d"
-    return version
-
-
 def lib_python(context):
-    return pathlib.PurePath("lib") / f"python{python_version(context)}"
+    return pathlib.PurePath("lib") / f"python{context.python_version()}"
 
 
 def license_file(context):
@@ -101,12 +87,12 @@ def pkgconfig_files(context):
     pkgconfig = pathlib.PurePath("lib") / "pkgconfig"
     return [
         (
-            pkgconfig / f"python-{python_version(context, debug_ok=True)}.pc",
+            pkgconfig / f"python-{context.python_version(debug_ok=True)}.pc",
             misc_dir / "python.pc",
         ),
         (
             pkgconfig
-            / f"python-{python_version(context, debug_ok=True)}-embed.pc",
+            / f"python-{context.python_version(debug_ok=True)}-embed.pc",
             misc_dir / "python-embed.pc",
         ),
     ]
@@ -127,11 +113,11 @@ def pkgconfig_symlinks(pkgconfig_files, context):
         paths += [
             (
                 embed_path.parent
-                / f"python-{python_version(context)}-embed.pc",
+                / f"python-{context.python_version()}-embed.pc",
                 embed_path,
             ),
             (
-                plain_path.parent / f"python-{python_version(context)}.pc",
+                plain_path.parent / f"python-{context.python_version()}.pc",
                 plain_path,
             ),
         ]
@@ -143,7 +129,7 @@ def pyconfig_file(context):
     """Have <build>/pyconfig.h end up in include/pythonXYd?/pyconfig.h."""
     return (
         pathlib.PurePath("include")
-        / f"python{python_version(context, debug_ok=True)}"
+        / f"python{context.python_version(debug_ok=True)}"
         / "pyconfig.h",
         context.wasi_build_path / "pyconfig.h",
     )
@@ -158,7 +144,7 @@ def header_files(context):
             file_path = pathlib.Path(root) / filename
             details = (
                 pathlib.PurePath("include")
-                / f"python{python_version(context, debug_ok=True)}"
+                / f"python{context.python_version(debug_ok=True)}"
                 / file_path.relative_to(include_dir),
                 file_path,
             )
@@ -171,7 +157,7 @@ def man_file(context):
     man_dir = pathlib.PurePath("share", "man", "man1")
     man_file = context.checkout / "Misc" / "python.man"
     return (
-        man_dir / f"python{python_version(context)}.1",
+        man_dir / f"python{context.python_version()}.1",
         man_file,
     )
 
@@ -187,7 +173,7 @@ def wasmtime_config_file(context):
     config = context.checkout / "Platforms" / "WASI" / "wasmtime.toml"
     return (
         pathlib.PurePath("etc")
-        / f"python{python_version(context)}"
+        / f"python{context.python_version()}"
         / "wasmtime.toml",
         config,
     )
@@ -198,17 +184,15 @@ def wasm_file(context):
     return (
         lib_python(context)
         / "lib-wasm"
-        / f"python{python_version(context, debug_ok=True)}.wasm",
+        / f"python{context.python_version(debug_ok=True)}.wasm",
         context.wasi_build_path / "python.wasm",
     )
 
 
-def python_wasmtime_script(base, context):
+def python_wasmtime_script(context):
     """Create bin/pythonN.Md?.wasmtime."""
     script = wasmtime_script(context)
-    path = (
-        base / f"bin/python{python_version(context, debug_ok=True)}.wasmtime"
-    )
+    path = context.dist_python_interpreter
     path.parent.mkdir(parents=True, exist_ok=True)
     with open(path, "w") as f:
         f.write(script)
@@ -221,7 +205,7 @@ def python_wasmtime_symlink(path, context):
     """Symlink bin/pythonN.Md?.wasmtime.
 
     - bin/pythonN.M.wasmtime (if debug build)
-    - bin/pythonN.wasmtime (if debug build)
+    - bin/pythonN.wasmtime
     """
     symlinks = [
         path.parent / f"python{context.wasi_build_version['major']}.wasmtime"
@@ -229,7 +213,7 @@ def python_wasmtime_symlink(path, context):
     if context.is_debug:
         # The file already has the debug name, so the missing symlink is the 
non-debug name.
         symlinks.append(
-            path.parent / f"python{python_version(context)}.wasmtime"
+            path.parent / f"python{context.python_version()}.wasmtime"
         )
 
     return [(symlink, path) for symlink in symlinks]
@@ -239,7 +223,7 @@ def config_file(context):
     """Have Misc/config.sh end up at bin/pythonN.Md?.config."""
     return (
         pathlib.PurePath("bin")
-        / f"python{python_version(context, debug_ok=True)}-config",
+        / f"python{context.python_version(debug_ok=True)}-config",
         context.wasi_build_path / "Misc" / "python-config.sh",
     )
 
@@ -258,7 +242,7 @@ def config_symlink(config_path, context):
     if context.is_debug:
         # The file already has the debug name, so the missing symlink is the 
non-debug name.
         symlinks.append(
-            config_path.parent / f"python{python_version(context)}-config"
+            config_path.parent / f"python{context.python_version()}-config"
         )
 
     return [(symlink, config_path) for symlink in symlinks]
@@ -281,8 +265,8 @@ def symlink_files(files, base):
 
 
 def gather(context):
-    py_version = python_version(context)
-    py_d_version = python_version(context, debug_ok=True)
+    py_version = context.python_version()
+    py_d_version = context.python_version(debug_ok=True)
 
     dist = context.dist_path
     if dist.exists():
@@ -300,7 +284,7 @@ def gather(context):
     symlink_files(config_symlink(config_location[0], context), base)
 
     _shared.log("๐Ÿ“„", f"python{py_d_version}.wasmtime", spacing=indent * 3)
-    script_path = python_wasmtime_script(base, context)
+    script_path = python_wasmtime_script(context)
     symlink_files(python_wasmtime_symlink(script_path, context), base)
 
     _shared.log("๐Ÿ“", "etc/", spacing=indent * 2)
@@ -353,8 +337,7 @@ def gather(context):
 
 
 def archive(context):
-    file_name = f"{context.archive_stem}.tar.xz"
-    file_path = context.dist_path / file_name
+    file_path = context.archive_path
     if file_path.exists():
         _shared.log("๐Ÿงน", f"Deleting {file_path} ...")
         file_path.unlink()
@@ -399,6 +382,7 @@ def archive(context):
                 "--owner=0",
                 "--group=0",
                 "--numeric-owner",
+                "--format=pax",
                 
"--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime",
                 "--mode=go+u,go-w",
                 # Explicitly using `-T` because if you don't compress with 
threads you can't
diff --git a/Platforms/WASI/_shared.py b/Platforms/WASI/_shared.py
index 306aabd1f1c0977..8e5f13d03adc57a 100644
--- a/Platforms/WASI/_shared.py
+++ b/Platforms/WASI/_shared.py
@@ -1,19 +1,25 @@
 __lazy_modules__ = [
+    "contextlib",
     "functools",
     "json",
     "os",
     "pathlib",
+    "shutil",
     "subprocess",
+    "sys",
     "sysconfig",
     "tempfile",
     "tomllib",
 ]
 
+import contextlib
 import functools
 import json
 import os
 import pathlib
+import shutil
 import subprocess
+import sys
 import sysconfig
 import tempfile
 import tomllib
@@ -178,6 +184,13 @@ def wasi_sdk_path(self):
 
         return wasi_sdk_path
 
+    @functools.cached_property
+    def configure_args(self):
+        args = getattr(self, "_args", [])
+        if self.release:
+            args.extend(["--without-static-libpython"])
+        return args
+
     @functools.cached_property
     def log_path(self):
         if self._log_path is not None:
@@ -206,6 +219,83 @@ def archive_stem(self):
     def archive_dir(self):
         return self.dist_path / self.archive_stem
 
+    @functools.cached_property
+    def archive_path(self):
+        return self.dist_path / f"{self.archive_stem}.tar.xz"
+
+    @functools.cached_property
+    def dist_python_interpreter(self):
+        version = self.python_version(debug_ok=True)
+        return self.archive_dir / "bin" / f"python{version}.wasmtime"
+
+    def python_version(self, debug_ok=False):
+        """Calculate the M.N part of Python's version.
+
+        If *debug_ok* is True, then "d" is appended as appropriate.
+        """
+        major = self.wasi_build_version["major"]
+        minor = self.wasi_build_version["minor"]
+        version = f"{major}.{minor}"
+        if debug_ok and "d" in self.wasi_build_details["abi"]["flags"]:
+            version += "d"
+        return version
+
+    def call(self, command, *, env=None, quiet=None, **kwargs):
+        """Execute a command.
+
+        Print changes to the execution environment if 'env' is provided.
+        If 'quiet' is None, use the context's setting. If true, redirect
+        stdout and stderr to a temporary file.
+        """
+        if quiet is None:
+            quiet = self.quiet
+
+        if env is not None:
+            env_diff = {}
+            for key, value in env.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)}")
+
+        log("โฏ", " ".join(map(str, command)), spacing="  ")
+        if not quiet:
+            stdout = None
+            stderr = None
+        else:
+            log_path = self.log_path
+            log_path.mkdir(parents=True, exist_ok=True)
+            stdout = tempfile.NamedTemporaryFile(
+                "w",
+                encoding="utf-8",
+                delete=False,
+                dir=log_path,
+                prefix="cpython-wasi-",
+                suffix=".log",
+            )
+            stderr = subprocess.STDOUT
+            log("๐Ÿ“", f"Logging output to {stdout.name} (--quiet)...")
+
+        try:
+            subprocess.check_call(
+                command, **kwargs, env=env, stdout=stdout, stderr=stderr
+            )
+        except subprocess.CalledProcessError as error:
+            if quiet:
+                log("โŒ", f"Exit code {error.returncode}")
+                separator()
+                with open(stdout.name, encoding="utf-8") as file:
+                    lines = file.readlines()
+                    # Inefficient, but the log shouldn't be dramatically large.
+                    print("".join(lines[-10:]), end="")
+                    if not lines[-1].endswith("\n"):
+                        print()
+            sys.exit(error.returncode)
+
 
 def log(emoji, message, *, spacing=None):
     """Print a notification with an emoji.
@@ -216,3 +306,59 @@ def log(emoji, message, *, spacing=None):
     if spacing is None:
         spacing = " " if len(emoji) == 1 else "  "
     print("".join([emoji, spacing, message]))
+
+
+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 updated_env(updates={}):
+    """Create a new dict representing the environment to use."""
+    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
+
+    return environment
+
+
+def subdir(context_attr, *, clean_ok=False):
+    """Decorator to change to a working directory."""
+
+    def decorator(func):
+        @functools.wraps(func)
+        def wrapper(context):
+            nonlocal context_attr
+
+            working_dir = getattr(context, context_attr)
+            separator()
+            log("๐Ÿ“", os.fsdecode(working_dir))
+            if clean_ok and context.clean 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
diff --git a/Platforms/WASI/_test.py b/Platforms/WASI/_test.py
new file mode 100644
index 000000000000000..a44838659c70111
--- /dev/null
+++ b/Platforms/WASI/_test.py
@@ -0,0 +1,73 @@
+__lazy_modules__ = [
+    "shlex",
+    "shutil",
+    "_shared",
+]
+
+import shlex
+import shutil
+
+import _shared
+
+
+def dist_test_args(context):
+    runner = context.dist_python_interpreter
+    if not runner.is_file():
+        raise FileNotFoundError(
+            f"Packaged WASI interpreter not found at {runner}; "
+            "run `gather`, `package`, or `release` first"
+        )
+
+    test_opts = shlex.join(["--python", shlex.quote(str(runner))])
+    return [
+        "TESTPYTHON=$(RUNSHARED) $(PYTHON_FOR_FREEZE) $(TESTPYTHONOPTS)",
+        f"TESTOPTS=$(EXTRATESTOPTS) {test_opts}",
+    ]
+
+
+@_shared.subdir("wasi_build_path")
+def test(context, _working_dir):
+    match context.ci_mode:
+        case "fast":
+            make_target = "test"
+        case "slow":
+            make_target = "buildbottest"
+        case _:
+            raise ValueError(f"Unknown CI mode {context.ci_mode}")
+    command = ["make", make_target]
+    match context.target:
+        case "archive":
+            archive = context.archive_path
+            if not archive.is_file():
+                raise FileNotFoundError(
+                    f"Release archive not found at {archive}; "
+                    "run `package` or `release` first"
+                )
+            if context.archive_dir.exists():
+                _shared.log("๐Ÿงน", f"Deleting {context.archive_dir} ...")
+                shutil.rmtree(context.archive_dir)
+            context.call(
+                [
+                    "tar",
+                    "--extract",
+                    "--file",
+                    archive,
+                    "--directory",
+                    context.dist_path,
+                    "--",
+                    context.archive_dir.name,
+                ],
+            )
+            command.extend(dist_test_args(context))
+        case "dist":
+            if not context.dist_python_interpreter.is_file():
+                raise FileNotFoundError(
+                    f"Python not found at {context.dist_python_interpreter}"
+                )
+            command.extend(dist_test_args(context))
+        case "build":
+            pass
+        case _:
+            raise ValueError(f"Unknown target {context.target}")
+
+    context.call(command, env=_shared.updated_env())

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to