From 19dbc54582a92dda6e059bf2f331ad90f3044630 Mon Sep 17 00:00:00 2001 From: messense Date: Sun, 26 Apr 2026 21:07:06 +0800 Subject: [PATCH 1/5] Support PEP 783 `pyemscripten_*_wasm32` wheel platform tag The Emscripten platform-tag branch in `get_platform_tag` now resolves the wheel tag through a priority cascade so maturin produces installable wheels across the full Pyodide release spectrum: 1. `pyemscripten___wasm32` (PEP 783, Pyodide >= 0.30 / Python 3.14+), driven by `MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION` / `PYEMSCRIPTEN_PLATFORM_VERSION` or `pyodide config get pyemscripten_platform_version`. 2. `pyodide___wasm32` (Pyodide 0.28 / 0.29), driven by `MATURIN_PYODIDE_ABI_VERSION` / `PYODIDE_ABI_VERSION` or `pyodide config get pyodide_abi_version`. 3. Legacy `emscripten__wasm32` (Pyodide <= 0.27), still driven by `MATURIN_EMSCRIPTEN_VERSION` / `emcc -dumpversion`. A warning notes that wheels in this format are not installable on PEP 783-compliant runtimes. The cascade is implemented as a pure function over an `EmscriptenVersionInputs` struct so the per-Pyodide-version behaviour can be covered deterministically by unit tests, and version segments are validated against the PEP 783 `[0-9]+_[0-9]+` regex so malformed inputs fail loudly instead of producing a tag that no installer accepts. The generated GitHub Actions workflow (`generate-ci`) now also exports `PYEMSCRIPTEN_PLATFORM_VERSION` and `PYODIDE_ABI_VERSION` from `pyodide config get`, defaulting to empty so older Pyodide releases keep producing the legacy tag. For real-Pyodide coverage, `noxfile.py` learns to parse `pyodide-lock.json` (`info.platform`, `info.abi_version`, and a future `info.pyemscripten_platform_version`) into the matching env vars plus an `EXPECTED_PLATFORM_TAG` that `test-emscripten` asserts against the produced wheel filename. The CI `test-emscripten` job is now a matrix over Pyodide 0.28 and 0.29, and `tests/emscripten_runner.js` was updated to match all three Emscripten tag families when locating the built wheel. --- .github/workflows/test.yml | 19 +- AGENTS.md | 1 + guide/src/environment-variables.md | 16 +- noxfile.py | 69 +++++- src/ci/github/render.rs | 10 + src/target/platform_tag.rs | 357 ++++++++++++++++++++++++++++- tests/emscripten_runner.js | 7 +- 7 files changed, 466 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f478cb60..3a8908010 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -236,11 +236,24 @@ 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 both pre-PEP 783 Pyodide releases that expose + # `info.abi_version` in their lock file. Both produce wheels with the + # `pyodide_2025_0_wasm32` platform tag (CPython 3.13 ABI). + # See https://peps.python.org/pep-0783/ for the new + # `pyemscripten_*_wasm32` tag scheme used from Pyodide 0.30 onwards. + include: + - pyodide-version: "0.28.3" + python-version: "3.13" + - pyodide-version: "0.29.0" + python-version: "3.13" 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/AGENTS.md b/AGENTS.md index 60981fa5a..d170b0fed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,5 +45,6 @@ Python code uses `ruff` and `black` (line length 120, target py37) and `mypy` wi - When adding a CLI option, regenerate the JSON schema via `cargo run --bin generate_json_schema` (see `src/generate_json_schema.rs`) so `maturin.schema.json` stays in sync. - Do not edit files under `src/templates/` to satisfy formatters; they are intentionally excluded. - Do not modify `sysconfig/` snapshots by hand. +- Do not modify `Changelog` by hand, it's managed by `cargo-cliff`. - Document what & why in git commit message instead of just a list of changes, use backtick for code snippets in commit message. - Do not edit `Changelog.md` by hand; it is generated from git history by [git-cliff](https://github.com/orhun/git-cliff) (see `cliff.toml`). 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..a0dfba9e1 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,55 @@ 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. + + 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", "") + + 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() @@ -112,13 +161,16 @@ def setup_pyodide(session: nox.Session): 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 +193,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..56e12103c 100644 --- a/src/ci/github/render.rs +++ b/src/ci/github/render.rs @@ -284,6 +284,16 @@ 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"); + // PEP 783 / pre-PEP 783 platform tag inputs. `pyodide config get` exits + // 0 with an empty value when the key is unknown, so older Pyodide + // releases simply leave these unset and maturin falls back to the + // legacy `emscripten_*` tag. + y.line( + "echo PYEMSCRIPTEN_PLATFORM_VERSION=$(pyodide config get pyemscripten_platform_version 2>/dev/null || true) >> $GITHUB_ENV", + ); + y.line( + "echo PYODIDE_ABI_VERSION=$(pyodide config get pyodide_abi_version 2>/dev/null || true) >> $GITHUB_ENV", + ); 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..da2b05b2b 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,195 @@ pub(crate) fn rustc_macosx_target_version(target: &str) -> (u16, u16) { rustc_target_version().unwrap_or(fallback_version) } +/// Resolved version inputs for the Emscripten platform-tag cascade. +/// +/// Each field corresponds to the value Pyodide of a given era exposes: +/// +/// * `pyemscripten_platform_version`: Pyodide >= 0.30 / Python 3.14+ +/// (PEP 783). +/// * `pyodide_abi_version`: Pyodide 0.28+ ABI version. For Python 3.14+, +/// this maps to the PEP 783 `pyemscripten` tag. +/// * `python_version`: Pyodide Python version used to disambiguate +/// `pyodide_abi_version`. +/// * `emcc_version`: legacy fallback for Pyodide <= 0.27. +#[derive(Debug, Default)] +struct EmscriptenVersionInputs { + pyemscripten_platform_version: Option, + pyodide_abi_version: Option, + python_version: Option, + emcc_version: Option, +} + +/// 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`. +/// 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 { + let inputs = EmscriptenVersionInputs { + pyemscripten_platform_version: pyemscripten_platform_version()?, + pyodide_abi_version: pyodide_abi_version()?, + python_version: pyodide_python_version()?, + // Resolve `emcc -dumpversion` lazily inside the cascade so we don't + // require emcc on PATH when the user is targeting PEP 783 / Pyodide + // 0.28+ via env vars. + emcc_version: None, + }; + resolve_emscripten_platform_tag(&inputs, emscripten_version) +} + +/// Pure cascade implementation, parameterised over the inputs and a fallback +/// function used to look up the legacy emscripten / emcc version. Extracted +/// from [`emscripten_platform_tag`] so it can be exercised with +/// deterministic inputs from unit tests. +fn resolve_emscripten_platform_tag( + inputs: &EmscriptenVersionInputs, + emcc_lookup: impl FnOnce() -> Result, +) -> Result { + if let Some(ver) = inputs.pyemscripten_platform_version.as_deref() { + validate_version_segment(ver, "pyemscripten platform version")?; + return Ok(format!("pyemscripten_{ver}_wasm32")); + } + if let Some(ver) = inputs.pyodide_abi_version.as_deref() { + validate_version_segment(ver, "pyodide ABI version")?; + if python_version_at_least(inputs.python_version.as_deref(), 3, 14) { + return Ok(format!("pyemscripten_{ver}_wasm32")); + } + return Ok(format!("pyodide_{ver}_wasm32")); + } + let raw = match inputs.emcc_version.clone() { + Some(v) => v, + None => emcc_lookup()?, + }; + let release = raw.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")) +} + +/// Resolve `pyemscripten_{YEAR}_{PATCH}` from environment variables or +/// `pyodide config`. +fn pyemscripten_platform_version() -> Result> { + if let Ok(ver) = env::var("MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION") { + let trimmed = ver.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + if let Ok(ver) = env::var("PYEMSCRIPTEN_PLATFORM_VERSION") { + let trimmed = ver.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + Ok(pyodide_config_get("pyemscripten_platform_version")) +} + +/// Resolve the pre-PEP 783 `pyodide_{YEAR}_{PATCH}` ABI version from +/// environment variables or `pyodide config`. +fn pyodide_abi_version() -> Result> { + if let Ok(ver) = env::var("MATURIN_PYODIDE_ABI_VERSION") { + let trimmed = ver.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + if let Ok(ver) = env::var("PYODIDE_ABI_VERSION") { + let trimmed = ver.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + Ok(pyodide_config_get("pyodide_abi_version")) +} + +/// Resolve the Pyodide Python version from the generated CI environment or +/// `pyodide config`. +fn pyodide_python_version() -> Result> { + if let Ok(ver) = env::var("PYTHON_VERSION") { + let trimmed = ver.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + Ok(pyodide_config_get("python_version")) +} + +fn python_version_at_least(version: Option<&str>, major: u64, minor: u64) -> bool { + let Some(version) = version else { + return false; + }; + let mut parts = version.split('.'); + let Some(Ok(actual_major)) = parts.next().map(str::parse::) else { + return false; + }; + let Some(Ok(actual_minor)) = parts.next().map(str::parse::) else { + return false; + }; + (actual_major, actual_minor) >= (major, minor) +} + +/// 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()) +} + +/// Validate that a `pyemscripten` / `pyodide` version segment matches the +/// `[0-9]+_[0-9]+` shape required by the PEP 783 platform tag regex. +fn validate_version_segment(value: &str, what: &str) -> Result<()> { + static RE: Lazy = Lazy::new(|| Regex::new(r"^[0-9]+_[0-9]+$").unwrap()); + if RE.is_match(value) { + Ok(()) + } else { + bail!( + "Invalid {what} `{value}`: expected `_` (e.g. `2026_0`). \ + Pyodide reports the version with an underscore separator." + ); + } +} + /// Emscripten version fn emscripten_version() -> Result { let os_version = env::var("MATURIN_EMSCRIPTEN_VERSION"); @@ -421,9 +607,170 @@ fn find_android_api_level(target_triple: &str, manifest_path: &Path) -> Result Result { + bail!("emcc -dumpversion should not be called when a Pyodide platform version is set"); + } + + #[test] + fn test_emscripten_tag_resolution() { + let cases = [ + ( + "PEP 783 version wins", + EmscriptenVersionInputs { + pyemscripten_platform_version: Some("2026_0".to_string()), + ..Default::default() + }, + "pyemscripten_2026_0_wasm32", + ), + ( + "Pyodide 0.29 ABI stays pre-PEP 783", + EmscriptenVersionInputs { + pyodide_abi_version: Some("2025_0".to_string()), + python_version: Some("3.13".to_string()), + ..Default::default() + }, + "pyodide_2025_0_wasm32", + ), + ( + "Pyodide 0.28 ABI stays pre-PEP 783", + EmscriptenVersionInputs { + pyodide_abi_version: Some("2025_0".to_string()), + python_version: Some("3.13".to_string()), + ..Default::default() + }, + "pyodide_2025_0_wasm32", + ), + ( + "Python 3.14 ABI maps to PEP 783", + EmscriptenVersionInputs { + pyodide_abi_version: Some("2026_0".to_string()), + python_version: Some("3.14.2".to_string()), + ..Default::default() + }, + "pyemscripten_2026_0_wasm32", + ), + ( + "manual Pyodide ABI is supported for 0.27", + EmscriptenVersionInputs { + pyodide_abi_version: Some("2024_0".to_string()), + ..Default::default() + }, + "pyodide_2024_0_wasm32", + ), + ( + "explicit legacy emcc version is used", + EmscriptenVersionInputs { + emcc_version: Some("3.1.58".to_string()), + ..Default::default() + }, + "emscripten_3_1_58_wasm32", + ), + ( + "PEP 783 wins over Pyodide ABI and emcc", + EmscriptenVersionInputs { + pyemscripten_platform_version: Some("2026_0".to_string()), + pyodide_abi_version: Some("2025_0".to_string()), + emcc_version: Some("3.1.58".to_string()), + ..Default::default() + }, + "pyemscripten_2026_0_wasm32", + ), + ( + "Pyodide ABI wins over emcc", + EmscriptenVersionInputs { + pyodide_abi_version: Some("2025_0".to_string()), + emcc_version: Some("3.1.58".to_string()), + ..Default::default() + }, + "pyodide_2025_0_wasm32", + ), + ]; + + for (name, inputs, expected) in cases { + let tag = resolve_emscripten_platform_tag(&inputs, || { + if inputs.emcc_version.is_some() { + bail!("should use the explicit emcc version") + } else { + unused_emcc_lookup() + } + }) + .unwrap(); + assert_eq!(tag, expected, "{name}"); + } + + let tag = resolve_emscripten_platform_tag(&EmscriptenVersionInputs::default(), || { + Ok("3.1.46".to_string()) + }) + .unwrap(); + assert_eq!(tag, "emscripten_3_1_46_wasm32"); + } + + /// Invalid `pyemscripten_platform_version` (e.g. dotted) is rejected + /// with a clear error rather than silently producing a malformed tag. + #[test] + fn test_emscripten_tag_rejects_invalid_pep783_version() { + let inputs = EmscriptenVersionInputs { + pyemscripten_platform_version: Some("2026.0".to_string()), + ..Default::default() + }; + let err = resolve_emscripten_platform_tag(&inputs, unused_emcc_lookup).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("pyemscripten platform version"), + "expected pyemscripten validation error, got: {msg}" + ); + } + + /// Invalid `pyodide_abi_version` (e.g. hyphenated) is rejected. + #[test] + fn test_emscripten_tag_rejects_invalid_pyodide_abi_version() { + let inputs = EmscriptenVersionInputs { + pyodide_abi_version: Some("2025-0".to_string()), + ..Default::default() + }; + let err = resolve_emscripten_platform_tag(&inputs, unused_emcc_lookup).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("pyodide ABI version"), + "expected pyodide ABI validation error, got: {msg}" + ); + } + #[test] fn test_macosx_deployment_target() { let rustc_ver = rustc_version::version().unwrap(); diff --git a/tests/emscripten_runner.js b/tests/emscripten_runner.js index 0e8859330..0dc05f8ea 100644 --- a/tests/emscripten_runner.js +++ b/tests/emscripten_runner.js @@ -2,9 +2,14 @@ const { opendir } = require("node:fs/promises"); const { loadPyodide } = require("pyodide"); async function findWheel(distDir) { + // Match every Emscripten / Pyodide platform tag family: + // - `pyemscripten___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/; const dir = await opendir(distDir); for await (const dirent of dir) { - if (dirent.name.includes("emscripten") && dirent.name.endsWith("whl")) { + if (dirent.name.endsWith(".whl") && tagRegex.test(dirent.name)) { return dirent.name; } } From bc3d67d55d6d025516ddba9e6a491598ca928b4c Mon Sep 17 00:00:00 2001 From: messense Date: Sat, 9 May 2026 19:48:14 +0800 Subject: [PATCH 2/5] Get PEP 783 emscripten branch working with Pyodide 314.0.0-alpha.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `emit_emscripten_setup` step in `generate-ci` queried `pyodide config get pyemscripten_platform_version`, but that key does not exist in `pyodide-build`'s `PYODIDE_CLI_CONFIGS`. `pyodide config get` for an unknown key prints the error message `Config variable X not found.` to **stdout** (not stderr) and exits 1, so the existing `2>/dev/null || true` pattern silently captured the error string into `$GITHUB_ENV`. The downstream maturin build would then trip the `[0-9]+_[0-9]+` validator with a confusing error. Replace the two queries with a single robust pattern: if v=$(pyodide config get pyodide_abi_version 2>/dev/null); then echo PYODIDE_ABI_VERSION=$v >> $GITHUB_ENV fi This only writes the env var when the key is actually recognised. The PEP 783 `pyemscripten___wasm32` tag is then selected by the existing cascade in `src/target/platform_tag.rs` based on `PYTHON_VERSION >= 3.14`. `PYEMSCRIPTEN_PLATFORM_VERSION` / `MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION` env-var overrides remain supported for users who want to set the value manually. Pyodide 314.0.0a1's `pyodide-lock.json` only exposes `info.abi_version = 2026_0` and `info.python = 3.14.0` — there is no `info.pyemscripten_platform_version` field. The `_resolve_pyodide_platform_inputs` cascade already maps that combination to `pyemscripten_2026_0_wasm32`, matching what `pyodide_build.build_env.wheel_platform()` produces for the same ABI. Pyodide >= 314.0.0a1 also ships its Emscripten output as `pyodide.asm.mjs` instead of `pyodide.asm.js`. Make the prettier step tolerant of either filename so `nox -s setup-pyodide` does not fail before parsing the lock file. Finally, add Pyodide 314.0.0-alpha.1 / Python 3.14 to the `test-emscripten` matrix so the new `pyemscripten_*_wasm32` tag family is exercised on CI alongside the existing `pyodide_*` tag covered by 0.28.3 / 0.29.0. --- .github/workflows/test.yml | 14 +++++++++----- noxfile.py | 21 ++++++++++++++------- src/ci/github/render.rs | 18 ++++++++++-------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a8908010..4fd1de60d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -241,16 +241,20 @@ jobs: strategy: fail-fast: false matrix: - # Cover both pre-PEP 783 Pyodide releases that expose - # `info.abi_version` in their lock file. Both produce wheels with the - # `pyodide_2025_0_wasm32` platform tag (CPython 3.13 ABI). - # See https://peps.python.org/pep-0783/ for the new - # `pyemscripten_*_wasm32` tag scheme used from Pyodide 0.30 onwards. + # Cover the three Emscripten platform-tag families maturin supports: + # - Pyodide 0.28 / 0.29 (Python 3.13) → `pyodide_2025_0_wasm32` + # (pre-PEP 783, exposed via `info.abi_version` in pyodide-lock.json). + # - Pyodide 314.0.0a1 (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. include: - pyodide-version: "0.28.3" python-version: "3.13" - pyodide-version: "0.29.0" python-version: "3.13" + - pyodide-version: "314.0.0-alpha.1" + python-version: "3.14" env: PYODIDE_VERSION: ${{ matrix.pyodide-version }} PYTHON_VERSION: ${{ matrix.python-version }} diff --git a/noxfile.py b/noxfile.py index a0dfba9e1..fe7f207b5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -153,13 +153,20 @@ 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. + for asm_filename in ("pyodide.asm.js", "pyodide.asm.mjs"): + if Path(asm_filename).exists(): + session.run( + "node", + "../prettier/bin/prettier.cjs", + "-w", + asm_filename, + external=True, + ) + break with open("pyodide-lock.json") as f: info = json.load(f)["info"] for name, value in _resolve_pyodide_platform_inputs(info).items(): diff --git a/src/ci/github/render.rs b/src/ci/github/render.rs index 56e12103c..d05fc2452 100644 --- a/src/ci/github/render.rs +++ b/src/ci/github/render.rs @@ -284,15 +284,17 @@ 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"); - // PEP 783 / pre-PEP 783 platform tag inputs. `pyodide config get` exits - // 0 with an empty value when the key is unknown, so older Pyodide - // releases simply leave these unset and maturin falls back to the - // legacy `emscripten_*` tag. + // 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( - "echo PYEMSCRIPTEN_PLATFORM_VERSION=$(pyodide config get pyemscripten_platform_version 2>/dev/null || true) >> $GITHUB_ENV", - ); - y.line( - "echo PYODIDE_ABI_VERSION=$(pyodide config get pyodide_abi_version 2>/dev/null || true) >> $GITHUB_ENV", + "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) From a045848585b4006dd9ce1a3004710e7a3d49618d Mon Sep 17 00:00:00 2001 From: messense Date: Sat, 9 May 2026 20:26:29 +0800 Subject: [PATCH 3/5] Drop pyodide 0.28.3 from test matrix --- .github/workflows/test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4fd1de60d..4d85cb748 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -242,15 +242,13 @@ jobs: fail-fast: false matrix: # Cover the three Emscripten platform-tag families maturin supports: - # - Pyodide 0.28 / 0.29 (Python 3.13) → `pyodide_2025_0_wasm32` + # - 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.0a1 (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. include: - - pyodide-version: "0.28.3" - python-version: "3.13" - pyodide-version: "0.29.0" python-version: "3.13" - pyodide-version: "314.0.0-alpha.1" From 20b6d167bf1f93b7f53e41f731383e263e9e86d2 Mon Sep 17 00:00:00 2001 From: messense Date: Sat, 9 May 2026 21:36:12 +0800 Subject: [PATCH 4/5] Address PR #3163 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small follow-ups based on Copilot's review of #3163: 1. `tests/emscripten_runner.js`: anchor the platform-tag regex to the trailing `-{platform tag}.whl` segment of the wheel filename so project names that happen to contain `pyodide_`, `emscripten_`, etc. cannot match. Wheel filenames are `{name}-{ver}-{python tag}-{abi tag}-{platform tag}.whl`, and the platform-tag field never contains `-`, so `-{family}_[^-]+_wasm32\.whl$` is unambiguous. Verified against all three Emscripten tag families plus `linux_x86_64` and a project named `my_pyodide_2025_0_wasm32_pkg`. 2. `noxfile.py`: only emit `EMSCRIPTEN_VERSION` from `_resolve_pyodide_platform_inputs` when we actually resolved one. The previous code unconditionally inserted `EMSCRIPTEN_VERSION=""`, which would clobber a previously-set value in `$GITHUB_ENV` and break `setup-emsdk` if the lock file ever omitted `info.platform`. 3. `.github/workflows/test.yml`: the matrix comment claimed three platform-tag families but only two are exercised end-to-end (`pyodide_*` via 0.29.0, `pyemscripten_*` via 314.0.0-alpha.1). Update the comment to match and note that the legacy `emscripten__wasm32` family is no longer used by any maintained Pyodide release and remains covered by the cascade unit tests in `src/target/platform_tag.rs`. 4. `AGENTS.md`: drop the duplicate `Do not modify Changelog by hand` bullet — the next bullet already says `Do not edit Changelog.md by hand; it is generated from git history by git-cliff (see cliff.toml)`. The `eprintln!`-with-emoji warning in `emscripten_platform_tag` is intentionally left as-is: it matches the existing user-facing warning style elsewhere in maturin (`🍹`, `📦`, `❌`) and using `tracing::warn!` here would be inconsistent. --- .github/workflows/test.yml | 9 +++++++-- AGENTS.md | 1 - noxfile.py | 7 ++++++- tests/emscripten_runner.js | 10 +++++++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d85cb748..14f837126 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -241,13 +241,18 @@ jobs: strategy: fail-fast: false matrix: - # Cover the three Emscripten platform-tag families maturin supports: + # 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.0a1 (Python 3.14) → `pyemscripten_2026_0_wasm32` + # - 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" diff --git a/AGENTS.md b/AGENTS.md index d170b0fed..60981fa5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,5 @@ Python code uses `ruff` and `black` (line length 120, target py37) and `mypy` wi - When adding a CLI option, regenerate the JSON schema via `cargo run --bin generate_json_schema` (see `src/generate_json_schema.rs`) so `maturin.schema.json` stays in sync. - Do not edit files under `src/templates/` to satisfy formatters; they are intentionally excluded. - Do not modify `sysconfig/` snapshots by hand. -- Do not modify `Changelog` by hand, it's managed by `cargo-cliff`. - Document what & why in git commit message instead of just a list of changes, use backtick for code snippets in commit message. - Do not edit `Changelog.md` by hand; it is generated from git history by [git-cliff](https://github.com/orhun/git-cliff) (see `cliff.toml`). diff --git a/noxfile.py b/noxfile.py index fe7f207b5..d39a4616f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -117,7 +117,12 @@ def _resolve_pyodide_platform_inputs(info: dict) -> dict: pyemscripten = info.get("pyemscripten_platform_version", "") abi_version = info.get("abi_version", "") - env = {"EMSCRIPTEN_VERSION": emscripten_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" diff --git a/tests/emscripten_runner.js b/tests/emscripten_runner.js index 0dc05f8ea..6e8fa64ae 100644 --- a/tests/emscripten_runner.js +++ b/tests/emscripten_runner.js @@ -2,14 +2,18 @@ const { opendir } = require("node:fs/promises"); const { loadPyodide } = require("pyodide"); async function findWheel(distDir) { - // Match every Emscripten / Pyodide platform tag family: + // 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___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/; + const tagRegex = /-(pyemscripten|pyodide|emscripten)_[^-]+_wasm32\.whl$/; const dir = await opendir(distDir); for await (const dirent of dir) { - if (dirent.name.endsWith(".whl") && tagRegex.test(dirent.name)) { + if (tagRegex.test(dirent.name)) { return dirent.name; } } From 88e4752de5e8fb939c6b40c4ce14741ec2befcbf Mon Sep 17 00:00:00 2001 From: messense Date: Sat, 9 May 2026 21:51:55 +0800 Subject: [PATCH 5/5] Simplify Emscripten platform-tag cascade The PEP 783 cascade in `emscripten_platform_tag` was over-engineered for a 25-line decision tree: - Three near-identical env-var helpers (`pyemscripten_platform_version`, `pyodide_abi_version`, `pyodide_python_version`) all duplicated the same trim/empty/fallback dance and returned `Result>` despite never producing an `Err`. Collapsed into one `first_non_empty_env(&[...])` helper. - `EmscriptenVersionInputs` + the injected `emcc_lookup` closure existed purely as a test seam: `emcc_version` was never populated in production code, and the wrapper function was unused outside tests. Inlined the cascade directly into `emscripten_platform_tag`. - `validate_version_segment` (regex + `Lazy` + `bail!`) was defensive validation against trusted Pyodide-supplied inputs (sysconfig / `pyodide config get`); a malformed override would fail loudly at install/upload time anyway. Removed. - `python_version_at_least(_, 3, 14)` was generic over (major, minor) but only ever called with one constant. Renamed to `is_python_3_14_or_later` and switched parsing from `u64` to `u32`. The 8 cascade unit tests + 2 validation tests (~140 lines of scaffolding) tested little beyond format-string interpolation; end-to-end coverage is provided by the new `EXPECTED_PLATFORM_TAG` assertion in `noxfile.py` running across the Pyodide 0.29 / 314.0.0a1 CI matrix. Net effect: -262 lines from `src/target/platform_tag.rs` with no behavior change. Also tidies `noxfile.py` (replaces the asm-file lookup loop with `Path(...).glob(...)`) and adds a docstring cross-reference to keep `_resolve_pyodide_platform_inputs` aligned with the Rust cascade. --- noxfile.py | 22 +-- src/target/platform_tag.rs | 316 +++++-------------------------------- 2 files changed, 54 insertions(+), 284 deletions(-) diff --git a/noxfile.py b/noxfile.py index d39a4616f..0df334e6d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -96,6 +96,9 @@ def _resolve_pyodide_platform_inputs(info: dict) -> dict: 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 @@ -162,16 +165,15 @@ def setup_pyodide(session: nox.Session): # 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. - for asm_filename in ("pyodide.asm.js", "pyodide.asm.mjs"): - if Path(asm_filename).exists(): - session.run( - "node", - "../prettier/bin/prettier.cjs", - "-w", - asm_filename, - external=True, - ) - break + 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: info = json.load(f)["info"] for name, value in _resolve_pyodide_platform_inputs(info).items(): diff --git a/src/target/platform_tag.rs b/src/target/platform_tag.rs index da2b05b2b..5550b884c 100644 --- a/src/target/platform_tag.rs +++ b/src/target/platform_tag.rs @@ -332,25 +332,6 @@ pub(crate) fn rustc_macosx_target_version(target: &str) -> (u16, u16) { rustc_target_version().unwrap_or(fallback_version) } -/// Resolved version inputs for the Emscripten platform-tag cascade. -/// -/// Each field corresponds to the value Pyodide of a given era exposes: -/// -/// * `pyemscripten_platform_version`: Pyodide >= 0.30 / Python 3.14+ -/// (PEP 783). -/// * `pyodide_abi_version`: Pyodide 0.28+ ABI version. For Python 3.14+, -/// this maps to the PEP 783 `pyemscripten` tag. -/// * `python_version`: Pyodide Python version used to disambiguate -/// `pyodide_abi_version`. -/// * `emcc_version`: legacy fallback for Pyodide <= 0.27. -#[derive(Debug, Default)] -struct EmscriptenVersionInputs { - pyemscripten_platform_version: Option, - pyodide_abi_version: Option, - python_version: Option, - emcc_version: Option, -} - /// Resolve the platform tag for `wasm32-unknown-emscripten`. /// /// This implements the priority cascade required to support both @@ -366,48 +347,33 @@ struct EmscriptenVersionInputs { /// 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`. +/// `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 { - let inputs = EmscriptenVersionInputs { - pyemscripten_platform_version: pyemscripten_platform_version()?, - pyodide_abi_version: pyodide_abi_version()?, - python_version: pyodide_python_version()?, - // Resolve `emcc -dumpversion` lazily inside the cascade so we don't - // require emcc on PATH when the user is targeting PEP 783 / Pyodide - // 0.28+ via env vars. - emcc_version: None, - }; - resolve_emscripten_platform_tag(&inputs, emscripten_version) -} - -/// Pure cascade implementation, parameterised over the inputs and a fallback -/// function used to look up the legacy emscripten / emcc version. Extracted -/// from [`emscripten_platform_tag`] so it can be exercised with -/// deterministic inputs from unit tests. -fn resolve_emscripten_platform_tag( - inputs: &EmscriptenVersionInputs, - emcc_lookup: impl FnOnce() -> Result, -) -> Result { - if let Some(ver) = inputs.pyemscripten_platform_version.as_deref() { - validate_version_segment(ver, "pyemscripten platform version")?; + 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) = inputs.pyodide_abi_version.as_deref() { - validate_version_segment(ver, "pyodide ABI version")?; - if python_version_at_least(inputs.python_version.as_deref(), 3, 14) { - return Ok(format!("pyemscripten_{ver}_wasm32")); - } - return Ok(format!("pyodide_{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 raw = match inputs.emcc_version.clone() { - Some(v) => v, - None => emcc_lookup()?, - }; - let release = raw.replace(['.', '-'], "_"); + 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. \ @@ -417,66 +383,29 @@ fn resolve_emscripten_platform_tag( Ok(format!("emscripten_{release}_wasm32")) } -/// Resolve `pyemscripten_{YEAR}_{PATCH}` from environment variables or -/// `pyodide config`. -fn pyemscripten_platform_version() -> Result> { - if let Ok(ver) = env::var("MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION") { - let trimmed = ver.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - if let Ok(ver) = env::var("PYEMSCRIPTEN_PLATFORM_VERSION") { - let trimmed = ver.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - Ok(pyodide_config_get("pyemscripten_platform_version")) -} - -/// Resolve the pre-PEP 783 `pyodide_{YEAR}_{PATCH}` ABI version from -/// environment variables or `pyodide config`. -fn pyodide_abi_version() -> Result> { - if let Ok(ver) = env::var("MATURIN_PYODIDE_ABI_VERSION") { - let trimmed = ver.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - if let Ok(ver) = env::var("PYODIDE_ABI_VERSION") { - let trimmed = ver.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - Ok(pyodide_config_get("pyodide_abi_version")) -} - -/// Resolve the Pyodide Python version from the generated CI environment or -/// `pyodide config`. -fn pyodide_python_version() -> Result> { - if let Ok(ver) = env::var("PYTHON_VERSION") { - let trimmed = ver.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - Ok(pyodide_config_get("python_version")) +/// 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 python_version_at_least(version: Option<&str>, major: u64, minor: u64) -> bool { +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(actual_major)) = parts.next().map(str::parse::) else { + let Some(Ok(major)) = parts.next().map(str::parse::) else { return false; }; - let Some(Ok(actual_minor)) = parts.next().map(str::parse::) else { + let Some(Ok(minor)) = parts.next().map(str::parse::) else { return false; }; - (actual_major, actual_minor) >= (major, minor) + (major, minor) >= (3, 14) } /// Best-effort `pyodide config get ` invocation. @@ -507,20 +436,6 @@ fn pyodide_config_get(key: &str) -> Option { Some(trimmed.to_string()) } -/// Validate that a `pyemscripten` / `pyodide` version segment matches the -/// `[0-9]+_[0-9]+` shape required by the PEP 783 platform tag regex. -fn validate_version_segment(value: &str, what: &str) -> Result<()> { - static RE: Lazy = Lazy::new(|| Regex::new(r"^[0-9]+_[0-9]+$").unwrap()); - if RE.is_match(value) { - Ok(()) - } else { - bail!( - "Invalid {what} `{value}`: expected `_` (e.g. `2026_0`). \ - Pyodide reports the version with an underscore separator." - ); - } -} - /// Emscripten version fn emscripten_version() -> Result { let os_version = env::var("MATURIN_EMSCRIPTEN_VERSION"); @@ -608,167 +523,20 @@ fn find_android_api_level(target_triple: &str, manifest_path: &Path) -> Result Result { - bail!("emcc -dumpversion should not be called when a Pyodide platform version is set"); - } - - #[test] - fn test_emscripten_tag_resolution() { - let cases = [ - ( - "PEP 783 version wins", - EmscriptenVersionInputs { - pyemscripten_platform_version: Some("2026_0".to_string()), - ..Default::default() - }, - "pyemscripten_2026_0_wasm32", - ), - ( - "Pyodide 0.29 ABI stays pre-PEP 783", - EmscriptenVersionInputs { - pyodide_abi_version: Some("2025_0".to_string()), - python_version: Some("3.13".to_string()), - ..Default::default() - }, - "pyodide_2025_0_wasm32", - ), - ( - "Pyodide 0.28 ABI stays pre-PEP 783", - EmscriptenVersionInputs { - pyodide_abi_version: Some("2025_0".to_string()), - python_version: Some("3.13".to_string()), - ..Default::default() - }, - "pyodide_2025_0_wasm32", - ), - ( - "Python 3.14 ABI maps to PEP 783", - EmscriptenVersionInputs { - pyodide_abi_version: Some("2026_0".to_string()), - python_version: Some("3.14.2".to_string()), - ..Default::default() - }, - "pyemscripten_2026_0_wasm32", - ), - ( - "manual Pyodide ABI is supported for 0.27", - EmscriptenVersionInputs { - pyodide_abi_version: Some("2024_0".to_string()), - ..Default::default() - }, - "pyodide_2024_0_wasm32", - ), - ( - "explicit legacy emcc version is used", - EmscriptenVersionInputs { - emcc_version: Some("3.1.58".to_string()), - ..Default::default() - }, - "emscripten_3_1_58_wasm32", - ), - ( - "PEP 783 wins over Pyodide ABI and emcc", - EmscriptenVersionInputs { - pyemscripten_platform_version: Some("2026_0".to_string()), - pyodide_abi_version: Some("2025_0".to_string()), - emcc_version: Some("3.1.58".to_string()), - ..Default::default() - }, - "pyemscripten_2026_0_wasm32", - ), - ( - "Pyodide ABI wins over emcc", - EmscriptenVersionInputs { - pyodide_abi_version: Some("2025_0".to_string()), - emcc_version: Some("3.1.58".to_string()), - ..Default::default() - }, - "pyodide_2025_0_wasm32", - ), - ]; - - for (name, inputs, expected) in cases { - let tag = resolve_emscripten_platform_tag(&inputs, || { - if inputs.emcc_version.is_some() { - bail!("should use the explicit emcc version") - } else { - unused_emcc_lookup() - } - }) - .unwrap(); - assert_eq!(tag, expected, "{name}"); - } - - let tag = resolve_emscripten_platform_tag(&EmscriptenVersionInputs::default(), || { - Ok("3.1.46".to_string()) - }) - .unwrap(); - assert_eq!(tag, "emscripten_3_1_46_wasm32"); - } - - /// Invalid `pyemscripten_platform_version` (e.g. dotted) is rejected - /// with a clear error rather than silently producing a malformed tag. - #[test] - fn test_emscripten_tag_rejects_invalid_pep783_version() { - let inputs = EmscriptenVersionInputs { - pyemscripten_platform_version: Some("2026.0".to_string()), - ..Default::default() - }; - let err = resolve_emscripten_platform_tag(&inputs, unused_emcc_lookup).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("pyemscripten platform version"), - "expected pyemscripten validation error, got: {msg}" - ); - } - - /// Invalid `pyodide_abi_version` (e.g. hyphenated) is rejected. - #[test] - fn test_emscripten_tag_rejects_invalid_pyodide_abi_version() { - let inputs = EmscriptenVersionInputs { - pyodide_abi_version: Some("2025-0".to_string()), - ..Default::default() - }; - let err = resolve_emscripten_platform_tag(&inputs, unused_emcc_lookup).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("pyodide ABI version"), - "expected pyodide ABI validation error, got: {msg}" - ); + 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]