#!/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 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 = '069aac92c8c40e53b6931a4d31029c5c7bef0c15'
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)
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:
sys.exit('error: NODE_JS not set in emscripten config')
return config.NODE_JS[0]
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=cwd,
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', '')
return env
def build_script_runner(args):
"""Return (cpython_dir, step) where step(*cmd) runs CPython's Platforms/emscripten script."""
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([sys.executable, 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 = []
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)
test_args += ['--junit-xml', junit_xml]
test_args += args.test_args
step('run', '--test', '--', *test_args)
return 0
def all_steps(args):
sync(args)
build(args)
test(args)
return 0
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')
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_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):
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,
}
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__':
sys.exit(main(sys.argv[1:]))