From b51dd24be0bb4f98d888b155d0554e892b561d3e Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 30 Oct 2026 13:45:44 -0700 Subject: [PATCH 1/6] Add CPython to CI Resolves issue #28097. --- .circleci/config.yml | 25 +++++ test/cpython_test.py | 246 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100755 test/cpython_test.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 475ff7c736a05..a850f6cb96250 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1053,6 +1053,28 @@ jobs: - run: name: Test size benchmarker command: EMTEST_BENCHMARKERS=size ./test/runner benchmark.test_primes + test-cpython: + # Build CPython against this emscripten checkout and run its test suite. + # CPython's test suite exercises a lot of libc/syscall behaviour that isn't + # covered elsewhere, and emscripten releases have historically regressed + # CPython. + executor: ubuntu-lts + resource_class: large + steps: + - prepare-for-tests + - run: + name: Install Python for CPython build scripts + # CPython's Platforms/emscripten build scripts need Python >= 3.12 + # but the CI image only has Python 3.10, so install a newer one via uv. + command: | + python3 -m pip install uv + uv python install 3.13 + echo "export EMTEST_CPYTHON_PYTHON=$(uv python find 3.13)" >> $BASH_ENV + - run: + name: Build and test CPython + no_output_timeout: 30m + command: ./test/cpython_test.py all --junit-xml out/test-results.xml + - upload-test-results test-browser-chrome: executor: ubuntu-lts environment: @@ -1353,6 +1375,9 @@ workflows: - test-misc: requires: - build-linux + - test-cpython: + requires: + - build-linux - test-modularize-instance: requires: - build-linux diff --git a/test/cpython_test.py b/test/cpython_test.py new file mode 100755 index 0000000000000..158003612743b --- /dev/null +++ b/test/cpython_test.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Emscripten Authors. All rights reserved. +# Emscripten is available under two separate licenses, the MIT license and the +# University of Illinois/NCSA Open Source License. Both these licenses can be +# found in the LICENSE file. + +"""Build CPython against this emscripten checkout and run its test suite. + +This script is run in CI, but is also runnable locally. CPython is +shallow-cloned into `out/cpython` and synced to a pinned revision. Requires +Python >= 3.12. + + ./test/cpython_test.py # same as `all`: sync, build and test + ./test/cpython_test.py sync # clone/checkout the pinned CPython revision + ./test/cpython_test.py build # build an existing checkout + ./test/cpython_test.py test # run the tests on an existing build + ./test/cpython_test.py test test_os test_io # run only specific tests + +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +__rootpath__ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, __rootpath__) + +from tools import config +from tools.utils import path_from_root + +CPYTHON_REPO = 'https://github.com/python/cpython.git' + +# The CPython revision to test. This must be recent enough to not rely on +# `FS.filesystems`. Once CPython 3.16.0a2 comes out we can switch to a tag. +CPYTHON_REVISION = '13ca348ddee6db1b5f2216c293295ed3c04200f5' + +DEFAULT_CPYTHON_DIR = path_from_root('out', 'cpython') + +# Minimum Python version required to run CPython's Platforms/emscripten build +# scripts +MIN_BUILD_PYTHON = (3, 12) + + +def run(cmd, **kwargs): + print('+ ' + ' '.join(str(c) for c in cmd), flush=True) + return subprocess.run([str(c) for c in cmd], check=True, **kwargs) + + +def python_version(exe): + try: + out = subprocess.check_output( + [exe, '-c', 'import sys; print(sys.version_info[0], sys.version_info[1])'], + text=True, stderr=subprocess.DEVNULL) + major, minor = out.split() + return (int(major), int(minor)) + except (OSError, subprocess.CalledProcessError, ValueError): + return None + + +def find_build_python(explicit): + """Find a Python interpreter new enough to run CPython's build scripts. + + Uses `--python` if given, else `EMTEST_CPYTHON_PYTHON` if set, else + `python3` from PATH. Whichever is chosen must be >= MIN_BUILD_PYTHON. + """ + minver = '%d.%d' % MIN_BUILD_PYTHON + candidate = explicit or os.environ.get('EMTEST_CPYTHON_PYTHON') or 'python3' + exe = shutil.which(candidate) or candidate + version = python_version(exe) + if not version or version < MIN_BUILD_PYTHON: + found = f'{version[0]}.{version[1]}' if version else 'not a working Python' + sys.exit( + f'error: {exe} is {found}; Python >= {minver} is needed to run the CPython build scripts.\n' + 'Install one (e.g. `uv python install 3.13`) and pass `--python` or set EMTEST_CPYTHON_PYTHON.') + print(f'Using Python {version[0]}.{version[1]} at {exe} to drive the CPython build') + return exe + + +def node_path(): + node = config.NODE_JS + if isinstance(node, list): + node = node[0] + return node + + +def git_head(cpython_dir): + """Return the sha of HEAD, or None if the repo has no commits yet.""" + proc = subprocess.run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], cwd=cpython_dir, + text=True, capture_output=True) + return proc.stdout.strip() if proc.returncode == 0 else None + + +def sync(args): + """Ensure a CPython checkout exists at `cpython_dir` and is at `revision`.""" + cpython_dir = Path(args.cpython_dir) + revision = args.revision + if not (cpython_dir / '.git').exists(): + print(f'Cloning CPython into {cpython_dir}') + cpython_dir.mkdir(parents=True, exist_ok=True) + run(['git', 'init', '-q', cpython_dir]) + run(['git', 'remote', 'add', 'origin', CPYTHON_REPO], cwd=cpython_dir) + elif git_head(cpython_dir) == revision: + print(f'CPython checkout at {cpython_dir} is already at {revision}') + return 0 + + print(f'Syncing CPython checkout at {cpython_dir} to {revision}') + # Shallow fetch of a single commit rather than a full clone. GitHub allows + # fetching arbitrary commits by sha. + run(['git', 'fetch', '-q', '--depth=1', 'origin', revision], cwd=cpython_dir) + run(['git', 'checkout', '-q', '--detach', 'FETCH_HEAD'], cwd=cpython_dir) + return 0 + + +def build_env(): + """Environment for running the CPython build scripts and the built python. + + Ensures that emcc/emconfigure/etc from this checkout are used. + """ + env = os.environ.copy() + env['PATH'] = path_from_root() + os.pathsep + env.get('PATH', '') + # The CPython build scripts can install their own emsdk if this is set; + # we always want to use the local emscripten. + env.pop('EMSDK_CACHE', None) + if config.EM_CONFIG: + env['EM_CONFIG'] = config.EM_CONFIG + return env + + +def build_script_runner(args): + """Return (cpython_dir, step) where step(*cmd) runs CPython's Platforms/emscripten script.""" + build_python = find_build_python(args.python) + cpython_dir = Path(args.cpython_dir) + if not (cpython_dir / 'configure').exists(): + sys.exit(f'error: no CPython checkout at {cpython_dir}; run `{sys.argv[0]} sync` first') + build_script = cpython_dir / 'Platforms' / 'emscripten' + if not build_script.exists(): + sys.exit(f'error: {build_script} not found; CPython checkout is too old') + env = build_env() + + def step(*cmd): + run([build_python, build_script, *cmd], cwd=cpython_dir, env=env) + + return cpython_dir, step + + +def build(args): + cpython_dir, step = build_script_runner(args) + print(f'Building CPython checkout at {cpython_dir} ({git_head(cpython_dir)})') + + configure_args = ['--config-cache'] + if args.pydebug: + configure_args.append('--with-pydebug') + step('configure-build-python', '--', *configure_args) + step('make-build-python') + step('make-dependencies') + step('configure-host', '--host-runner', node_path(), '--', '--config-cache') + step('make-host') + step('pythoninfo-host') + return 0 + + +def test(args): + cpython_dir, step = build_script_runner(args) + python_sh = cpython_dir / 'cross-build' / 'wasm32-emscripten' / 'build' / 'python' / 'python.sh' + if not python_sh.exists(): + sys.exit(f'error: {python_sh} not found; run `{sys.argv[0]} build` first') + + # `run --test` prepends the default test arguments from CPython's config.toml + test_args = [] + if args.junit_xml: + junit_xml = os.path.abspath(args.junit_xml) + os.makedirs(os.path.dirname(junit_xml), exist_ok=True) + test_args += ['--junit-xml', junit_xml] + test_args += args.test_args + try: + step('run', '--test', '--', *test_args) + except subprocess.CalledProcessError as e: + return e.returncode + return 0 + + +def all_steps(args): + for step in (sync, build, test): + rtn = step(args) + if rtn: + return rtn + return 0 + + +def main(argv): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + subparsers = parser.add_subparsers(dest='subcommand') + + def add_common_args(p): + p.add_argument('--cpython-dir', default=DEFAULT_CPYTHON_DIR, + help=f'CPython checkout to use (default: {DEFAULT_CPYTHON_DIR})') + + def add_sync_args(p): + p.add_argument('--revision', default=CPYTHON_REVISION, + help=f'CPython git revision to sync to (default: {CPYTHON_REVISION})') + + def add_build_script_args(p): + p.add_argument('--python', help='Python >= 3.12 used to run the CPython build scripts') + + def add_build_args(p): + p.add_argument('--pydebug', action='store_true', help='Build CPython with --with-pydebug') + + def add_test_args(p): + p.add_argument('--junit-xml', help='Write test results in junit xml format to this file') + p.add_argument('test_args', nargs='*', + help='Extra arguments for `python -m test` (use `--` before any that start with `-`)') + + all_parser = subparsers.add_parser('all', help='Sync, build and test CPython (the default)') + sync_parser = subparsers.add_parser('sync', help='Clone/checkout the pinned CPython revision') + build_parser = subparsers.add_parser('build', help='Build an existing CPython checkout') + test_parser = subparsers.add_parser('test', help='Run the test suite of an existing CPython build') + for p in (all_parser, sync_parser, build_parser, test_parser): + add_common_args(p) + for p in (all_parser, sync_parser): + add_sync_args(p) + for p in (all_parser, build_parser, test_parser): + add_build_script_args(p) + for p in (all_parser, build_parser): + add_build_args(p) + for p in (all_parser, test_parser): + add_test_args(p) + + # Default to `all` if no subcommand is given. + if not argv or (argv[0] not in subparsers.choices and argv[0] not in {'-h', '--help'}): + argv = ['all', *argv] + args = parser.parse_args(argv) + + dispatch = { + 'all': all_steps, + 'sync': sync, + 'build': build, + 'test': test, + } + return dispatch[args.subcommand](args) + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) From f38b0aa407a3106da5a5a4b2348649149bf2282a Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 30 Oct 2026 14:15:14 -0700 Subject: [PATCH 2/6] Address review comments --- test/cpython_test.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/test/cpython_test.py b/test/cpython_test.py index 158003612743b..24ddbb89c2800 100755 --- a/test/cpython_test.py +++ b/test/cpython_test.py @@ -80,10 +80,10 @@ def find_build_python(explicit): def node_path(): - node = config.NODE_JS - if isinstance(node, list): - node = node[0] - return node + # config.NODE_JS is always normalized to a list (see config.listify). + if not config.NODE_JS: + sys.exit('error: NODE_JS not set in emscripten config') + return config.NODE_JS[0] def git_head(cpython_dir): @@ -175,18 +175,14 @@ def test(args): os.makedirs(os.path.dirname(junit_xml), exist_ok=True) test_args += ['--junit-xml', junit_xml] test_args += args.test_args - try: - step('run', '--test', '--', *test_args) - except subprocess.CalledProcessError as e: - return e.returncode + step('run', '--test', '--', *test_args) return 0 def all_steps(args): - for step in (sync, build, test): - rtn = step(args) - if rtn: - return rtn + sync(args) + build(args) + test(args) return 0 @@ -239,7 +235,13 @@ def add_test_args(p): 'build': build, 'test': test, } - return dispatch[args.subcommand](args) + try: + return dispatch[args.subcommand](args) + except subprocess.CalledProcessError as e: + # The failing command has already printed its own output; avoid a noisy + # traceback and just propagate its exit code. + print(f'error: command failed with exit code {e.returncode}: {" ".join(e.cmd)}', file=sys.stderr) + return e.returncode if __name__ == '__main__': From 395403dccb8d7712cc4ba10c840895715797f499 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 30 Oct 2026 14:24:33 -0700 Subject: [PATCH 3/6] Use cimg/python:3.13 executor instead of installing python via uv --- .circleci/config.yml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a850f6cb96250..d25bba17ad21f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,14 @@ executors: EMSDK_NOTTY: "1" EMTEST_WASI_SYSROOT: "~/wasi-sdk/wasi-sysroot" EMTEST_DETECT_TEMPFILE_LEAKS: "1" + linux-python: + # We need Python >= 3.12. + docker: + - image: cimg/python:3.13 + environment: + LANG: "C.UTF-8" + EMCC_CORES: "4" + EMSDK_NOTTY: "1" linux-arm64: machine: image: ubuntu-2204:current @@ -1058,18 +1066,13 @@ jobs: # CPython's test suite exercises a lot of libc/syscall behaviour that isn't # covered elsewhere, and emscripten releases have historically regressed # CPython. - executor: ubuntu-lts + # + # CPython's Platforms/emscripten build scripts need Python >= 3.12, hence + # the different executor. + executor: linux-python resource_class: large steps: - prepare-for-tests - - run: - name: Install Python for CPython build scripts - # CPython's Platforms/emscripten build scripts need Python >= 3.12 - # but the CI image only has Python 3.10, so install a newer one via uv. - command: | - python3 -m pip install uv - uv python install 3.13 - echo "export EMTEST_CPYTHON_PYTHON=$(uv python find 3.13)" >> $BASH_ENV - run: name: Build and test CPython no_output_timeout: 30m From d11da736d5a4bd6ccf011dc276d34602a1e08dbf Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 30 Oct 2026 14:38:07 -0700 Subject: [PATCH 4/6] Only use sys.executable, don't find any other Python --- test/cpython_test.py | 42 ++++-------------------------------------- 1 file changed, 4 insertions(+), 38 deletions(-) diff --git a/test/cpython_test.py b/test/cpython_test.py index 24ddbb89c2800..032b3a114f001 100755 --- a/test/cpython_test.py +++ b/test/cpython_test.py @@ -48,37 +48,6 @@ def run(cmd, **kwargs): print('+ ' + ' '.join(str(c) for c in cmd), flush=True) return subprocess.run([str(c) for c in cmd], check=True, **kwargs) - -def python_version(exe): - try: - out = subprocess.check_output( - [exe, '-c', 'import sys; print(sys.version_info[0], sys.version_info[1])'], - text=True, stderr=subprocess.DEVNULL) - major, minor = out.split() - return (int(major), int(minor)) - except (OSError, subprocess.CalledProcessError, ValueError): - return None - - -def find_build_python(explicit): - """Find a Python interpreter new enough to run CPython's build scripts. - - Uses `--python` if given, else `EMTEST_CPYTHON_PYTHON` if set, else - `python3` from PATH. Whichever is chosen must be >= MIN_BUILD_PYTHON. - """ - minver = '%d.%d' % MIN_BUILD_PYTHON - candidate = explicit or os.environ.get('EMTEST_CPYTHON_PYTHON') or 'python3' - exe = shutil.which(candidate) or candidate - version = python_version(exe) - if not version or version < MIN_BUILD_PYTHON: - found = f'{version[0]}.{version[1]}' if version else 'not a working Python' - sys.exit( - f'error: {exe} is {found}; Python >= {minver} is needed to run the CPython build scripts.\n' - 'Install one (e.g. `uv python install 3.13`) and pass `--python` or set EMTEST_CPYTHON_PYTHON.') - print(f'Using Python {version[0]}.{version[1]} at {exe} to drive the CPython build') - return exe - - def node_path(): # config.NODE_JS is always normalized to a list (see config.listify). if not config.NODE_JS: @@ -131,7 +100,6 @@ def build_env(): def build_script_runner(args): """Return (cpython_dir, step) where step(*cmd) runs CPython's Platforms/emscripten script.""" - build_python = find_build_python(args.python) cpython_dir = Path(args.cpython_dir) if not (cpython_dir / 'configure').exists(): sys.exit(f'error: no CPython checkout at {cpython_dir}; run `{sys.argv[0]} sync` first') @@ -141,7 +109,7 @@ def build_script_runner(args): env = build_env() def step(*cmd): - run([build_python, build_script, *cmd], cwd=cpython_dir, env=env) + run([sys.executable, build_script, *cmd], cwd=cpython_dir, env=env) return cpython_dir, step @@ -187,6 +155,9 @@ def all_steps(args): def main(argv): + if sys.version_info < (3, 12): + sys.exit("Requires Python version >= 3.12.0") + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) subparsers = parser.add_subparsers(dest='subcommand') @@ -198,9 +169,6 @@ def add_sync_args(p): p.add_argument('--revision', default=CPYTHON_REVISION, help=f'CPython git revision to sync to (default: {CPYTHON_REVISION})') - def add_build_script_args(p): - p.add_argument('--python', help='Python >= 3.12 used to run the CPython build scripts') - def add_build_args(p): p.add_argument('--pydebug', action='store_true', help='Build CPython with --with-pydebug') @@ -217,8 +185,6 @@ def add_test_args(p): add_common_args(p) for p in (all_parser, sync_parser): add_sync_args(p) - for p in (all_parser, build_parser, test_parser): - add_build_script_args(p) for p in (all_parser, build_parser): add_build_args(p) for p in (all_parser, test_parser): From 14e7051013bbcac7e2ae7f8acf3ee72ea96fc2f9 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 30 Oct 2026 15:04:10 -0700 Subject: [PATCH 5/6] Rename argument name of git_head --- test/cpython_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cpython_test.py b/test/cpython_test.py index 032b3a114f001..c70d04df4dfad 100755 --- a/test/cpython_test.py +++ b/test/cpython_test.py @@ -55,9 +55,9 @@ def node_path(): return config.NODE_JS[0] -def git_head(cpython_dir): +def git_head(cwd): """Return the sha of HEAD, or None if the repo has no commits yet.""" - proc = subprocess.run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], cwd=cpython_dir, + proc = subprocess.run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], cwd=cwd, text=True, capture_output=True) return proc.stdout.strip() if proc.returncode == 0 else None From 4a63cb20d0387cba2c4b622710be3b24a8d561af Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Sat, 31 Oct 2026 11:43:19 -0700 Subject: [PATCH 6/6] Fix CI --- .circleci/config.yml | 4 ++-- test/cpython_test.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d25bba17ad21f..1166f0cd09f1a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1076,8 +1076,8 @@ jobs: - run: name: Build and test CPython no_output_timeout: 30m - command: ./test/cpython_test.py all --junit-xml out/test-results.xml - - upload-test-results + # TODO(hoodmane): Pass --junit-xml and add upload-test-results + command: ./test/cpython_test.py all test-browser-chrome: executor: ubuntu-lts environment: diff --git a/test/cpython_test.py b/test/cpython_test.py index c70d04df4dfad..1101837f8a9bf 100755 --- a/test/cpython_test.py +++ b/test/cpython_test.py @@ -20,7 +20,6 @@ import argparse import os -import shutil import subprocess import sys from pathlib import Path @@ -44,10 +43,17 @@ MIN_BUILD_PYTHON = (3, 12) +IGNORED_TESTS = [ + # Asserts that Emscripten version matches the one pinned by CPython + 'test_libc_ver', +] + + def run(cmd, **kwargs): print('+ ' + ' '.join(str(c) for c in cmd), flush=True) return subprocess.run([str(c) for c in cmd], check=True, **kwargs) + def node_path(): # config.NODE_JS is always normalized to a list (see config.listify). if not config.NODE_JS: @@ -138,6 +144,8 @@ def test(args): # `run --test` prepends the default test arguments from CPython's config.toml test_args = [] + for t in IGNORED_TESTS: + test_args += ['-i', t] if args.junit_xml: junit_xml = os.path.abspath(args.junit_xml) os.makedirs(os.path.dirname(junit_xml), exist_ok=True)