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
26 changes: 23 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,31 @@ jobs:
bin/maturin build -i python3.12 -m test-crates/pyo3-mixed/Cargo.toml --target x86_64-pc-windows-msvc

test-emscripten:
name: Test Emscripten
name: Test Emscripten (Pyodide ${{ matrix.pyodide-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Cover the two Emscripten platform-tag families exposed by current
# Pyodide releases:
# - Pyodide 0.29 (Python 3.13) → `pyodide_2025_0_wasm32`
# (pre-PEP 783, exposed via `info.abi_version` in pyodide-lock.json).
# - Pyodide 314.0.0-alpha.1 (Python 3.14) → `pyemscripten_2026_0_wasm32`
# (PEP 783, see https://peps.python.org/pep-0783/). The lock file
# only exposes `info.abi_version = 2026_0`, so noxfile uses the
# Python version to pick the new `pyemscripten_*` tag.
# The legacy `emscripten_<emcc-version>_wasm32` family (Pyodide
# <= 0.27) is no longer exercised end-to-end because no maintained
# Pyodide release uses it; it remains covered by the cascade unit
# tests in `src/target/platform_tag.rs`.
include:
- pyodide-version: "0.29.0"
python-version: "3.13"
- pyodide-version: "314.0.0-alpha.1"
python-version: "3.14"
env:
PYODIDE_VERSION: "0.29.0"
PYTHON_VERSION: "3.13"
PYODIDE_VERSION: ${{ matrix.pyodide-version }}
PYTHON_VERSION: ${{ matrix.python-version }}
NODE_VERSION: 18
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
Expand Down
16 changes: 15 additions & 1 deletion guide/src/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,21 @@ Config-settings take priority over `MATURIN_PEP517_ARGS`; the environment variab
* `MACOSX_DEPLOYMENT_TARGET`: The minimum macOS version to target
* `IPHONEOS_DEPLOYMENT_TARGET`: The minimum iOS version to target
* `SOURCE_DATE_EPOCH`: The time to use for the timestamp in the wheel metadata
* `MATURIN_EMSCRIPTEN_VERSION`: The version of emscripten to use for emscripten builds
* `MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION` / `PYEMSCRIPTEN_PLATFORM_VERSION`: The
[PEP 783](https://peps.python.org/pep-0783/) PyEmscripten platform version
(e.g. `2026_0`) used to derive the `pyemscripten_<year>_<patch>_wasm32`
wheel platform tag. Pyodide 0.30+ exposes this via
`sysconfig.get_config_var("PYEMSCRIPTEN_PLATFORM_VERSION")` and `pyodide
config get pyemscripten_platform_version`.
* `MATURIN_PYODIDE_ABI_VERSION` / `PYODIDE_ABI_VERSION`: Pre-PEP 783 Pyodide
ABI version (e.g. `2025_0`) used to derive the
`pyodide_<year>_<patch>_wasm32` tag. Used when targeting Pyodide
0.28 / 0.29. Available via `pyodide config get pyodide_abi_version`.
* `MATURIN_EMSCRIPTEN_VERSION`: The version of emscripten to use for emscripten
builds (legacy `emscripten_<emcc-version>_wasm32` tag, used for Pyodide
&le; 0.27 only). Prefer `MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION` /
`MATURIN_PYODIDE_ABI_VERSION` when possible — wheels built with the legacy
tag are not installable on PEP 783-compliant runtimes.
* `MATURIN_STRIP`: Strip the library for minimum file size
* `MATURIN_NO_MISSING_BUILD_BACKEND_WARNING`: Suppress missing build backend warning
* `MATURIN_USE_XWIN`: Set to `1` to force to use `xwin` for cross compiling even on Windows that supports native compilation
Expand Down
97 changes: 87 additions & 10 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def append_to_github_env(name: str, value: str):
if not GITHUB_ACTIONS or not GITHUB_ENV:
return

with open(GITHUB_ENV, "w+") as f:
with open(GITHUB_ENV, "a") as f:
f.write(f"{name}={value}\n")


Expand Down Expand Up @@ -91,6 +91,63 @@ def update_pyo3(session: nox.Session):
session.run("cargo", f"+{MSRV}", "update", "-p", crate, external=True)


def _resolve_pyodide_platform_inputs(info: dict) -> dict:
"""Map a `pyodide-lock.json` `info` block to the env vars and expected
wheel platform tag that maturin should produce when targeting that
Pyodide release.

Mirrors the cascade in `src/target/platform_tag.rs::emscripten_platform_tag`;
keep the two in sync.

Pyodide encodes platform metadata across (overlapping) fields:

* `info.platform` is `emscripten_<X>_<Y>_<Z>` and contains the emcc
version that built the runtime (used by `setup-emsdk`).
* `info.abi_version` (Pyodide >= 0.28) is `<year>_<patch>` and drives either
the pre-PEP 783 `pyodide_<year>_<patch>_wasm32` wheel tag, or the PEP 783
`pyemscripten_<year>_<patch>_wasm32` tag for Python 3.14+ lock files.
* A future `info.pyemscripten_platform_version` (Pyodide >= 0.30) drives
the PEP 783 `pyemscripten_<year>_<patch>_wasm32` wheel tag.

See https://peps.python.org/pep-0783/.
"""
platform = info.get("platform", "")
if platform.startswith("emscripten_"):
emscripten_version = platform.removeprefix("emscripten_").replace("_", ".")
else:
emscripten_version = info.get("emscripten_version", "")

pyemscripten = info.get("pyemscripten_platform_version", "")
abi_version = info.get("abi_version", "")

# Only export `EMSCRIPTEN_VERSION` when we actually resolved one — an
# empty value would clobber a previously-set `EMSCRIPTEN_VERSION` in
# `$GITHUB_ENV` and break `setup-emsdk`.
env: dict[str, str] = {}
if emscripten_version:
env["EMSCRIPTEN_VERSION"] = emscripten_version
if pyemscripten:
env["PYEMSCRIPTEN_PLATFORM_VERSION"] = pyemscripten
env["EXPECTED_PLATFORM_TAG"] = f"pyemscripten_{pyemscripten}_wasm32"
elif abi_version and _pyodide_python_at_least(info, 3, 14):
env["PYEMSCRIPTEN_PLATFORM_VERSION"] = abi_version
env["EXPECTED_PLATFORM_TAG"] = f"pyemscripten_{abi_version}_wasm32"
elif abi_version:
env["PYODIDE_ABI_VERSION"] = abi_version
env["EXPECTED_PLATFORM_TAG"] = f"pyodide_{abi_version}_wasm32"
elif emscripten_version:
legacy = emscripten_version.replace(".", "_").replace("-", "_")
env["EXPECTED_PLATFORM_TAG"] = f"emscripten_{legacy}_wasm32"
return env


def _pyodide_python_at_least(info: dict, major: int, minor: int) -> bool:
match = re.match(r"^(\d+)\.(\d+)", info.get("python", ""))
if not match:
return False
return (int(match.group(1)), int(match.group(2))) >= (major, minor)


@nox.session(name="setup-pyodide", python=False)
def setup_pyodide(session: nox.Session):
tests_dir = Path("./tests").resolve()
Expand All @@ -104,21 +161,30 @@ def setup_pyodide(session: nox.Session):
external=True,
)
with session.chdir(tests_dir / "node_modules" / "pyodide"):
session.run(
"node",
"../prettier/bin/prettier.cjs",
"-w",
"pyodide.asm.js",
external=True,
)
# Pyodide ships its Emscripten output as a single very long line
# named `pyodide.asm.js` (Pyodide <= 0.29) or `pyodide.asm.mjs`
# (Pyodide >= 314.0.0a1). Prettifying it makes Node-side errors
# readable. Run prettier on whichever of the two exists.
asm_file = next(Path(".").glob("pyodide.asm.*js"), None)
if asm_file is not None:
session.run(
"node",
"../prettier/bin/prettier.cjs",
"-w",
asm_file.name,
external=True,
)
with open("pyodide-lock.json") as f:
emscripten_version = json.load(f)["info"]["platform"].split("_", 1)[1].replace("_", ".")
append_to_github_env("EMSCRIPTEN_VERSION", emscripten_version)
info = json.load(f)["info"]
for name, value in _resolve_pyodide_platform_inputs(info).items():
session.log(f"Pyodide {PYODIDE_VERSION}: {name}={value}")
append_to_github_env(name, value)


@nox.session(name="test-emscripten", python=False)
def test_emscripten(session: nox.Session):
tests_dir = Path("./tests").resolve()
expected_tag = os.getenv("EXPECTED_PLATFORM_TAG")

test_crates = [
"test-crates/pyo3-pure",
Expand All @@ -141,5 +207,16 @@ def test_emscripten(session: nox.Session):
external=True,
)

if expected_tag:
wheels_dir = crate / "target" / "wheels"
wheels = sorted(wheels_dir.glob("*.whl"))
if not wheels:
session.error(f"No wheel produced in {wheels_dir}")
mismatched = [w for w in wheels if expected_tag not in w.name]
if mismatched:
names = ", ".join(w.name for w in mismatched)
session.error(f"Expected platform tag {expected_tag} in wheel name(s): {names}")
session.log(f"Verified {len(wheels)} wheel(s) carry platform tag {expected_tag}")

with session.chdir(tests_dir):
session.run("node", "emscripten_runner.js", str(crate), external=True)
12 changes: 12 additions & 0 deletions src/ci/github/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,18 @@ fn emit_emscripten_setup(y: &mut Yaml) {
.indent();
y.line("echo EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version) >> $GITHUB_ENV");
y.line("echo PYTHON_VERSION=$(pyodide config get python_version | cut -d '.' -f 1-2) >> $GITHUB_ENV");
// Pre-PEP 783 ABI / PEP 783 platform-tag input. Pyodide >= 0.28 exposes
// `pyodide_abi_version` (e.g. `2025_0` for 0.28/0.29 on Python 3.13,
// `2026_0` for 0.30 / 314.0.0a1 on Python 3.14). `pyodide config get`
// exits 1 and prints an error to stdout for unknown keys, so we use an
// `if` to only export `PYODIDE_ABI_VERSION` when the key is recognised.
// Older Pyodide releases simply leave it unset and maturin falls back to
// the legacy `emscripten_*` tag. PEP 783 (pyemscripten_*) is then
// selected by the cascade in `src/target/platform_tag.rs` based on
// `PYTHON_VERSION` (>= 3.14).
y.line(
"if v=$(pyodide config get pyodide_abi_version 2>/dev/null); then echo PYODIDE_ABI_VERSION=$v >> $GITHUB_ENV; fi",
);
Comment thread
messense marked this conversation as resolved.
y.line("pip uninstall -y pyodide-build");
y.dedent_by(2)
.line("- uses: mymindstorm/setup-emsdk@v14")
Expand Down
125 changes: 120 additions & 5 deletions src/target/platform_tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,7 @@ pub fn get_platform_tag(
)
}
// Emscripten
(Os::Emscripten, Arch::Wasm32) => {
let release = emscripten_version()?.replace(['.', '-'], "_");
format!("emscripten_{release}_wasm32")
}
(Os::Emscripten, Arch::Wasm32) => emscripten_platform_tag()?,
(Os::Wasi, Arch::Wasm32) => "any".to_string(),
// Cygwin
(Os::Cygwin, _) => {
Expand Down Expand Up @@ -335,6 +332,110 @@ pub(crate) fn rustc_macosx_target_version(target: &str) -> (u16, u16) {
rustc_target_version().unwrap_or(fallback_version)
}

/// Resolve the platform tag for `wasm32-unknown-emscripten`.
///
/// This implements the priority cascade required to support both
/// [PEP 783](https://peps.python.org/pep-0783/) and pre-PEP 783 Pyodide
/// releases:
///
/// 1. **PEP 783** (Pyodide >= 0.30, Python 3.14+) — emit
/// `pyemscripten_{YEAR}_{PATCH}_wasm32`. Resolved from
/// `MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION` /
/// `PYEMSCRIPTEN_PLATFORM_VERSION` (the sysconfig variable Pyodide 0.30+
/// exposes), falling back to `pyodide config get
/// pyemscripten_platform_version`.
/// 2. **Pre-PEP 783 standardized tag** (Pyodide 0.28 / 0.29) — emit
/// `pyodide_{YEAR}_{PATCH}_wasm32`. Resolved from
/// `MATURIN_PYODIDE_ABI_VERSION` / `PYODIDE_ABI_VERSION`, falling back to
/// `pyodide config get pyodide_abi_version`. For Python 3.14+ lock
/// files the same input maps to the PEP 783 `pyemscripten_*` tag.
/// 3. **Legacy** (Pyodide <= 0.27) — emit
/// `emscripten_{EMCC_VERSION}_wasm32`. Resolved from
/// `MATURIN_EMSCRIPTEN_VERSION` or `emcc -dumpversion`. Emits a warning
/// explaining that the tag is not PEP 783 compliant.
fn emscripten_platform_tag() -> Result<String> {
if let Some(ver) = first_non_empty_env(&[
"MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION",
"PYEMSCRIPTEN_PLATFORM_VERSION",
])
.or_else(|| pyodide_config_get("pyemscripten_platform_version"))
{
return Ok(format!("pyemscripten_{ver}_wasm32"));
}
if let Some(ver) = first_non_empty_env(&["MATURIN_PYODIDE_ABI_VERSION", "PYODIDE_ABI_VERSION"])
.or_else(|| pyodide_config_get("pyodide_abi_version"))
{
let py = first_non_empty_env(&["PYTHON_VERSION"])
.or_else(|| pyodide_config_get("python_version"));
return Ok(if is_python_3_14_or_later(py.as_deref()) {
format!("pyemscripten_{ver}_wasm32")
} else {
format!("pyodide_{ver}_wasm32")
});
}
let release = emscripten_version()?.replace(['.', '-'], "_");
eprintln!(
"⚠️ Falling back to legacy `emscripten_{release}_wasm32` platform tag. \
This wheel will not be installable on PEP 783-compliant Pyodide runtimes. \
Set `MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION` (PEP 783) or \
`MATURIN_PYODIDE_ABI_VERSION` (Pyodide 0.28+) to produce a portable tag."
);
Comment thread
messense marked this conversation as resolved.
Ok(format!("emscripten_{release}_wasm32"))
}

/// Return the first env var in `names` that is set to a non-empty (after
/// trim) value.
fn first_non_empty_env(names: &[&str]) -> Option<String> {
names.iter().find_map(|name| {
env::var(name).ok().and_then(|v| {
let t = v.trim();
(!t.is_empty()).then(|| t.to_string())
})
})
}

fn is_python_3_14_or_later(version: Option<&str>) -> bool {
let Some(version) = version else {
return false;
};
let mut parts = version.split('.');
let Some(Ok(major)) = parts.next().map(str::parse::<u32>) else {
return false;
};
let Some(Ok(minor)) = parts.next().map(str::parse::<u32>) else {
return false;
};
(major, minor) >= (3, 14)
}

/// Best-effort `pyodide config get <key>` invocation.
///
/// Returns `None` if `pyodide` is not on `PATH`, the command fails, or the
/// reported value is empty / `None`.
fn pyodide_config_get(key: &str) -> Option<String> {
use std::process::Command;

let output = Command::new(if cfg!(windows) {
"pyodide.bat"
} else {
"pyodide"
})
.arg("config")
.arg("get")
.arg(key)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
return None;
}
Some(trimmed.to_string())
}

/// Emscripten version
fn emscripten_version() -> Result<String> {
let os_version = env::var("MATURIN_EMSCRIPTEN_VERSION");
Expand Down Expand Up @@ -421,9 +522,23 @@ fn find_android_api_level(target_triple: &str, manifest_path: &Path) -> Result<S

#[cfg(test)]
mod tests {
use super::{extract_android_api_level, iphoneos_deployment_target, macosx_deployment_target};
use super::{
extract_android_api_level, iphoneos_deployment_target, is_python_3_14_or_later,
macosx_deployment_target,
};
use pretty_assertions::assert_eq;

#[test]
fn test_is_python_3_14_or_later() {
assert!(is_python_3_14_or_later(Some("3.14")));
assert!(is_python_3_14_or_later(Some("3.14.2")));
assert!(is_python_3_14_or_later(Some("3.15.0")));
assert!(!is_python_3_14_or_later(Some("3.13.9")));
assert!(!is_python_3_14_or_later(Some("3")));
assert!(!is_python_3_14_or_later(Some("invalid")));
assert!(!is_python_3_14_or_later(None));
}

#[test]
fn test_macosx_deployment_target() {
let rustc_ver = rustc_version::version().unwrap();
Expand Down
11 changes: 10 additions & 1 deletion tests/emscripten_runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ const { opendir } = require("node:fs/promises");
const { loadPyodide } = require("pyodide");

async function findWheel(distDir) {
// Match every Emscripten / Pyodide platform tag family in the wheel
// filename's *platform tag* field. Wheel filenames are
// `{name}-{ver}-{python tag}-{abi tag}-{platform tag}.whl`, so anchor to
// the trailing `-{platform tag}.whl` segment to avoid false positives
// from a project name that happens to contain `pyodide_` etc.
// - `pyemscripten_<year>_<patch>_wasm32` (PEP 783, Pyodide >= 0.30)
// - `pyodide_<year>_<patch>_wasm32` (pre-PEP 783, Pyodide 0.28/0.29)
// - `emscripten_<emcc-version>_wasm32` (legacy, Pyodide <= 0.27)
const tagRegex = /-(pyemscripten|pyodide|emscripten)_[^-]+_wasm32\.whl$/;
const dir = await opendir(distDir);
for await (const dirent of dir) {
if (dirent.name.includes("emscripten") && dirent.name.endsWith("whl")) {
if (tagRegex.test(dirent.name)) {
return dirent.name;
}
}
Expand Down
Loading