diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f478cb60..14f837126 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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__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 diff --git a/guide/src/environment-variables.md b/guide/src/environment-variables.md index d8f5f5470..6477d4209 100644 --- a/guide/src/environment-variables.md +++ b/guide/src/environment-variables.md @@ -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___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___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__wasm32` tag, used for Pyodide + ≤ 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 diff --git a/noxfile.py b/noxfile.py index 910c2572c..0df334e6d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -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") @@ -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___` and contains the emcc + version that built the runtime (used by `setup-emsdk`). + * `info.abi_version` (Pyodide >= 0.28) is `_` and drives either + the pre-PEP 783 `pyodide___wasm32` wheel tag, or the PEP 783 + `pyemscripten___wasm32` tag for Python 3.14+ lock files. + * A future `info.pyemscripten_platform_version` (Pyodide >= 0.30) drives + the PEP 783 `pyemscripten___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() @@ -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", @@ -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) diff --git a/src/ci/github/render.rs b/src/ci/github/render.rs index 26b103ed2..d05fc2452 100644 --- a/src/ci/github/render.rs +++ b/src/ci/github/render.rs @@ -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", + ); y.line("pip uninstall -y pyodide-build"); y.dedent_by(2) .line("- uses: mymindstorm/setup-emsdk@v14") diff --git a/src/target/platform_tag.rs b/src/target/platform_tag.rs index b4a7359e2..5550b884c 100644 --- a/src/target/platform_tag.rs +++ b/src/target/platform_tag.rs @@ -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, _) => { @@ -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 { + 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." + ); + 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 { + 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::) else { + return false; + }; + let Some(Ok(minor)) = parts.next().map(str::parse::) else { + return false; + }; + (major, minor) >= (3, 14) +} + +/// Best-effort `pyodide config get ` 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 { + 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 { let os_version = env::var("MATURIN_EMSCRIPTEN_VERSION"); @@ -421,9 +522,23 @@ fn find_android_api_level(target_triple: &str, manifest_path: &Path) -> Result__wasm32` (PEP 783, Pyodide >= 0.30) + // - `pyodide___wasm32` (pre-PEP 783, Pyodide 0.28/0.29) + // - `emscripten__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; } }