Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.34.0] - 2026/03/31

### Added

- pyodide xbuildenv install can skip eager installation, and cross-build packages are installed on first build-time use.
Expand Down
18 changes: 2 additions & 16 deletions pyodide_build/build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,6 @@ def ensure_emscripten(skip_install: bool = False) -> None:
RuntimeError
If emcc is not found and auto-install is skipped, or if version mismatch.
"""
from pyodide_build.logger import logger
from pyodide_build.xbuildenv import CrossBuildEnvManager # avoid circular import

# Check if auto-install should be skipped
env_skip = os.environ.get("PYODIDE_SKIP_EMSCRIPTEN_INSTALL", "")
Expand All @@ -380,20 +378,8 @@ def ensure_emscripten(skip_install: bool = False) -> None:
f"No Emscripten compiler found. Need Emscripten version {needed_version}"
) from None

# Get the xbuildenv path from the already-initialized pyodide root
# pyodide_root is at {xbuild_root}/xbuildenv/pyodide-root
# so xbuild_root is pyodide_root.parent.parent
pyodide_root = get_pyodide_root()
xbuild_root = pyodide_root.parent.parent
emsdk_dir = xbuild_root / "emsdk"
emsdk_env_script = emsdk_dir / "emsdk_env.sh"

if emsdk_env_script.exists():
logger.info("Emscripten found but not activated, activating...")
else:
logger.info("Emscripten not found, installing...")
manager = CrossBuildEnvManager(xbuild_root.parent)
emsdk_dir = manager.install_emscripten(needed_version)
manager = get_current_xbuildenv_manager()
emsdk_dir = manager.install_emscripten(needed_version)

env_vars = activate_emscripten_env(emsdk_dir)
os.environ.update(env_vars)
Expand Down
13 changes: 12 additions & 1 deletion pyodide_build/cli/xbuildenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,14 +263,25 @@ def _search(
default=DEFAULT_PATH,
help="Pyodide cross-env path",
)
@click.option(
"--force",
"-f",
is_flag=True,
default=False,
help="force reinstallation even if the same version is already installed.",
)
def _install_emscripten(
version: str | None,
path: Path,
force: bool,
) -> None:
"""Install Emscripten SDK into the cross-build environment.

This command clones the emsdk repository, installs and activates the specified
Emscripten version, and applies Pyodide-specific patches.

If the requested version is already installed, the command is a no-op unless
--force is passed.
"""
check_xbuildenv_root(path)
manager = CrossBuildEnvManager(path)
Expand All @@ -280,7 +291,7 @@ def _install_emscripten(

print("Installing emsdk...")

emsdk_dir = manager.install_emscripten(version)
emsdk_dir = manager.install_emscripten(version, force=force)

print("Installing emsdk complete.")
print(f"Use `source {emsdk_dir}/emsdk_env.sh` to set up the environment.")
48 changes: 42 additions & 6 deletions pyodide_build/tests/test_cli_install_emscripten.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_install_emscripten_default_version(tmp_path, monkeypatch):

called = {}

def fake_install(self, version):
def fake_install(self, version, *, force=False):
called["version"] = version
return self.env_dir / "emsdk"

Expand Down Expand Up @@ -72,7 +72,7 @@ def test_install_emscripten_specific_version(tmp_path, monkeypatch):

called = {}

def fake_install(self, version):
def fake_install(self, version, *, force=False):
called["version"] = version
return self.env_dir / "emsdk"

Expand Down Expand Up @@ -112,7 +112,7 @@ def test_install_emscripten_with_existing_emsdk(tmp_path, monkeypatch):
lambda name: "latest",
)

def fake_install(self, version):
def fake_install(self, version, *, force=False):
assert version == "latest"
assert existing_emsdk.exists()
return existing_emsdk
Expand Down Expand Up @@ -144,7 +144,7 @@ def test_install_emscripten_git_failure(tmp_path, monkeypatch):

monkeypatch.setattr(
"pyodide_build.xbuildenv.CrossBuildEnvManager.install_emscripten",
lambda self, version: (_ for _ in ()).throw(
lambda self, version, *, force=False: (_ for _ in ()).throw(
subprocess.CalledProcessError(1, "git clone")
),
)
Expand All @@ -170,7 +170,7 @@ def test_install_emscripten_emsdk_install_failure(tmp_path, monkeypatch):

monkeypatch.setattr(
"pyodide_build.xbuildenv.CrossBuildEnvManager.install_emscripten",
lambda self, version: (_ for _ in ()).throw(
lambda self, version, *, force=False: (_ for _ in ()).throw(
subprocess.CalledProcessError(1, "./emsdk install")
),
)
Expand All @@ -189,6 +189,42 @@ def test_install_emscripten_emsdk_install_failure(tmp_path, monkeypatch):
assert isinstance(result.exception, subprocess.CalledProcessError)


def test_install_emscripten_force_flag(tmp_path, monkeypatch):
"""Test that --force flag triggers reinstallation"""
envpath = Path(tmp_path) / ".xbuildenv"
envpath.mkdir()

called = {}

def fake_install(self, version, *, force=False):
called["version"] = version
called["force"] = force
return self.env_dir / "emsdk"

monkeypatch.setattr(
"pyodide_build.cli.xbuildenv.get_build_flag",
lambda name: "3.1.46",
)
monkeypatch.setattr(
"pyodide_build.xbuildenv.CrossBuildEnvManager.install_emscripten",
fake_install,
)

result = runner.invoke(
xbuildenv.app,
[
"install-emscripten",
"--force",
"--path",
str(envpath),
],
)

assert result.exit_code == 0, result.output
assert called["force"] is True
assert called["version"] == "3.1.46"


def test_install_emscripten_output_format(tmp_path, monkeypatch):
"""Test that the output message format is correct"""
envpath = Path(tmp_path) / ".xbuildenv"
Expand All @@ -203,7 +239,7 @@ def test_install_emscripten_output_format(tmp_path, monkeypatch):

monkeypatch.setattr(
"pyodide_build.xbuildenv.CrossBuildEnvManager.install_emscripten",
lambda self, version: expected_path,
lambda self, version, *, force=False: expected_path,
)

result = runner.invoke(
Expand Down
155 changes: 131 additions & 24 deletions pyodide_build/tests/test_install_emscripten.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,33 @@ def test_clone_emscripten_fresh_clone(tmp_path, monkeypatch):


def test_clone_emscripten_already_exists(tmp_path, monkeypatch):
"""Test that _clone_emscripten pulls updates when emsdk already exists"""
"""Test that _clone_emscripten removes existing dir and clones fresh"""
manager = CrossBuildEnvManager(tmp_path)

# Setup: create active xbuildenv with existing emsdk
version_dir = tmp_path / "0.28.0"
version_dir.mkdir()
emsdk_dir = version_dir / "emsdk"
emsdk_dir.mkdir() # Simulate existing emsdk
emsdk_dir.mkdir()
manager.use_version("0.28.0")

# Mock subprocess.run
mock_run = MagicMock(return_value=subprocess.CompletedProcess([], 0))
monkeypatch.setattr(subprocess, "run", mock_run)

# Execute
result = manager._clone_emscripten()

# Verify - should pull instead of clone
assert result == emsdk_dir
mock_run.assert_called_once_with(["git", "-C", str(emsdk_dir), "pull"], check=True)
assert not emsdk_dir.exists()
mock_run.assert_called_once_with(
[
"git",
"clone",
"--depth",
"1",
"https://github.com/emscripten-core/emsdk.git",
str(emsdk_dir),
],
check=True,
)


def test_install_emscripten_no_active_xbuildenv(tmp_path):
Expand Down Expand Up @@ -210,58 +217,158 @@ def mock_run_side_effect(cmd, **kwargs):


def test_install_emscripten_with_existing_emsdk(tmp_path, monkeypatch):
"""Test installing Emscripten when emsdk directory already exists"""
"""Test installing Emscripten removes existing emsdk and clones fresh"""
manager = CrossBuildEnvManager(tmp_path)

# Setup: create active xbuildenv with existing emsdk
version_dir = tmp_path / "0.28.0"
version_dir.mkdir()
emsdk_dir = version_dir / "emsdk"
emsdk_dir.mkdir() # Simulate existing emsdk
patches_dir = emsdk_dir / "patches"
patches_dir.mkdir()
(patches_dir / "test.patch").write_text("--- a/test\n+++ b/test\n")
emsdk_dir.mkdir()
upstream_emscripten = emsdk_dir / "upstream" / "emscripten"
upstream_emscripten.mkdir(parents=True)
manager.use_version("0.28.0")

# Mock subprocess.run
mock_run = MagicMock(return_value=subprocess.CompletedProcess([], 0))
def mock_run_side_effect(cmd, **kwargs):
if isinstance(cmd, list) and "clone" in cmd:
upstream_emscripten.mkdir(parents=True, exist_ok=True)
return subprocess.CompletedProcess([], 0)

mock_run = MagicMock(side_effect=mock_run_side_effect)
monkeypatch.setattr(subprocess, "run", mock_run)
manager.use_version("0.28.0")

# Execute
result = manager.install_emscripten()

# Verify - should pull, then install, patch, and activate
assert result == emsdk_dir
assert mock_run.call_count == 4

calls = mock_run.call_args_list

# 1. Pull latest changes (not clone)
assert calls[0] == call(["git", "-C", str(emsdk_dir), "pull"], check=True)
assert calls[0] == call(
[
"git",
"clone",
"--depth",
"1",
"https://github.com/emscripten-core/emsdk.git",
str(emsdk_dir),
],
check=True,
)

# 2. Install emsdk
assert calls[1] == call(
["./emsdk", "install", "--build=Release", "latest"],
cwd=emsdk_dir,
check=True,
)

# 3. Apply patches
patch_cmd = calls[2][0][0]
assert "patch" in patch_cmd
assert calls[2][1]["shell"] is True
assert calls[2][1]["cwd"] == upstream_emscripten

# 4. Activate emsdk
assert calls[3] == call(
["./emsdk", "activate", "--embedded", "--build=Release", "latest"],
cwd=emsdk_dir,
check=True,
)


def test_install_emscripten_skips_when_already_installed(tmp_path, monkeypatch):
manager = CrossBuildEnvManager(tmp_path)

version_dir = tmp_path / "0.28.0"
version_dir.mkdir()
manager.use_version("0.28.0")

emsdk_dir = version_dir / "emsdk"
emsdk_dir.mkdir()

marker = version_dir / ".emscripten-version"
marker.write_text("3.1.46")

mock_run = MagicMock(return_value=subprocess.CompletedProcess([], 0))
monkeypatch.setattr(subprocess, "run", mock_run)

result = manager.install_emscripten("3.1.46")

assert result == emsdk_dir
mock_run.assert_not_called()


def test_install_emscripten_reinstalls_different_version(tmp_path, monkeypatch):
manager = CrossBuildEnvManager(tmp_path)

version_dir = tmp_path / "0.28.0"
version_dir.mkdir()
manager.use_version("0.28.0")

emsdk_dir = version_dir / "emsdk"
emsdk_dir.mkdir()
upstream_emscripten = emsdk_dir / "upstream" / "emscripten"
upstream_emscripten.mkdir(parents=True)

marker = version_dir / ".emscripten-version"
marker.write_text("3.1.45")

mock_run = MagicMock(return_value=subprocess.CompletedProcess([], 0))
monkeypatch.setattr(subprocess, "run", mock_run)

result = manager.install_emscripten("3.1.46")

assert result == emsdk_dir
assert mock_run.call_count == 4
assert marker.read_text() == "3.1.46"


def test_install_emscripten_force_reinstalls_same_version(tmp_path, monkeypatch):
manager = CrossBuildEnvManager(tmp_path)

version_dir = tmp_path / "0.28.0"
version_dir.mkdir()
manager.use_version("0.28.0")

emsdk_dir = version_dir / "emsdk"
emsdk_dir.mkdir()
upstream_emscripten = emsdk_dir / "upstream" / "emscripten"
upstream_emscripten.mkdir(parents=True)

marker = version_dir / ".emscripten-version"
marker.write_text("3.1.46")

mock_run = MagicMock(return_value=subprocess.CompletedProcess([], 0))
monkeypatch.setattr(subprocess, "run", mock_run)

result = manager.install_emscripten("3.1.46", force=True)

assert result == emsdk_dir
assert mock_run.call_count == 4
assert marker.read_text() == "3.1.46"


def test_install_emscripten_writes_marker_on_success(tmp_path, monkeypatch):
manager = CrossBuildEnvManager(tmp_path)

version_dir = tmp_path / "0.28.0"
version_dir.mkdir()
manager.use_version("0.28.0")

emsdk_dir = version_dir / "emsdk"
upstream_emscripten = emsdk_dir / "upstream" / "emscripten"

def mock_run_side_effect(cmd, **kwargs):
if isinstance(cmd, list) and "clone" in cmd:
upstream_emscripten.mkdir(parents=True, exist_ok=True)
return subprocess.CompletedProcess([], 0)

mock_run = MagicMock(side_effect=mock_run_side_effect)
monkeypatch.setattr(subprocess, "run", mock_run)

manager.install_emscripten("3.1.46")

marker = version_dir / ".emscripten-version"
assert marker.exists()
assert marker.read_text() == "3.1.46"


def test_install_emscripten_patch_fails(tmp_path, monkeypatch):
"""Test handling of patch application failure due to version mismatch"""
manager = CrossBuildEnvManager(tmp_path)
Expand Down
Loading
Loading