Skip to content
78 changes: 40 additions & 38 deletions marimo/_cli/export/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from __future__ import annotations

import asyncio
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Literal

Expand All @@ -18,9 +20,11 @@
echo,
green,
)
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox, run_in_sandbox
from marimo._cli.utils import prompt_to_overwrite
from marimo._dependencies.dependencies import DependencyManager
from marimo._dependencies.errors import ManyModulesNotFoundError
from marimo._pyodide.pyodide_constraints import PYODIDE_PYTHON_VERSION
from marimo._server.api.utils import parse_title
from marimo._server.export import (
ExportResult,
Expand Down Expand Up @@ -230,17 +234,11 @@ def html(
args: tuple[str],
) -> None:
"""Run a notebook and export it as an HTML file."""
import sys

# Set default, if not provided
if sandbox is None:
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox

sandbox = maybe_prompt_run_in_sandbox(name)

if sandbox:
from marimo._cli.sandbox import run_in_sandbox

run_in_sandbox(sys.argv[1:], name=name)
return

Expand Down Expand Up @@ -316,11 +314,7 @@ def script(
"""
Export a marimo notebook as a flat script, in topological order.
"""
import sys

if sandbox:
from marimo._cli.sandbox import run_in_sandbox

run_in_sandbox(sys.argv[1:], name=name)
return

Expand Down Expand Up @@ -387,11 +381,7 @@ def md(
"""
Export a marimo notebook as a code fenced markdown document.
"""
import sys

if sandbox:
from marimo._cli.sandbox import run_in_sandbox

run_in_sandbox(sys.argv[1:], name=name)
return

Expand Down Expand Up @@ -484,18 +474,12 @@ def ipynb(
"""
Export a marimo notebook as a Jupyter notebook in topological order.
"""
import sys

if include_outputs:
# Set default, if not provided
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox

if sandbox is None:
sandbox = maybe_prompt_run_in_sandbox(name)

if sandbox:
from marimo._cli.sandbox import run_in_sandbox

run_in_sandbox(
sys.argv[1:],
name=name,
Expand Down Expand Up @@ -656,8 +640,6 @@ def pdf(
args: tuple[str],
) -> None:
"""Run a notebook and export it as a PDF file."""
import sys

if not include_outputs:
rasterize_source = ctx.get_parameter_source("rasterize_outputs")
raster_scale_source = ctx.get_parameter_source("raster_scale")
Expand All @@ -674,13 +656,9 @@ def pdf(
if include_outputs:
# Set default, if not provided
if sandbox is None:
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox

sandbox = maybe_prompt_run_in_sandbox(name)

if sandbox:
from marimo._cli.sandbox import run_in_sandbox

export_deps = ["nbformat"]
# Adding webpdf extras to sandbox even if `webpdf` is False, since standard PDF export may fall back to it.
export_deps.append("nbconvert[webpdf]")
Expand Down Expand Up @@ -876,7 +854,8 @@ def export_callback_impl(file_path: MarimoPath) -> ExportResult:
default=False,
help=(
"Execute the notebook before exporting and embed outputs as a "
"preview. Runs in the current Python environment."
"preview. Runs in an isolated environment pinned to WASM-compatible "
"packages when possible."
),
)
@click.argument(
Expand All @@ -898,24 +877,47 @@ def html_wasm(
args: tuple[str, ...],
) -> None:
"""Export a notebook as a WASM-powered standalone HTML file."""
import sys

if execute and watch:
raise click.UsageError(
"--execute and --watch cannot be used together."
)

# Set default, if not provided
if sandbox is None:
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox

sandbox = maybe_prompt_run_in_sandbox(name)
# When --execute is set, take ownership of sandboxing so we can layer
# the pyodide-lock constraints on top. Re-entry marker keeps the
# in-sandbox invocation from looping back here.
_BOOTSTRAPPED_ENV = "MARIMO_HTML_WASM_SANDBOX_BOOTSTRAPPED"
if execute and os.environ.get(_BOOTSTRAPPED_ENV) != "1":
Comment thread
dmadisetti marked this conversation as resolved.
if sandbox is not False and DependencyManager.which("uv"):
# Surface inner export failures via the outer process exit code
# — the bootstrap shell is a transparent wrapper, not its own
# success/failure boundary.
sys.exit(
run_in_sandbox(
sys.argv[1:],
name=name,
pyodide_constraints=True,
python_version_override=PYODIDE_PYTHON_VERSION,
extra_env={_BOOTSTRAPPED_ENV: "1"},
)
)
if sandbox is not False:
echo(
"warn: uv not found; running --execute in current "
"environment without isolation or pyodide-lock "
"verification. Install uv "
"(https://docs.astral.sh/uv) for verified exports.",
err=True,
)

if sandbox:
from marimo._cli.sandbox import run_in_sandbox
# No --execute (or already bootstrapped): keep the standard
# --sandbox prompt path.
if not execute:
if sandbox is None:
sandbox = maybe_prompt_run_in_sandbox(name)

run_in_sandbox(sys.argv[1:], name=name)
return
if sandbox:
run_in_sandbox(sys.argv[1:], name=name)
return

out_dir = output
filename = "index.html"
Expand Down
54 changes: 47 additions & 7 deletions marimo/_cli/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ def construct_uv_flags(
temp_file: "tempfile._TemporaryFileWrapper[str]", # noqa: UP037
additional_features: list[DepFeatures],
additional_deps: list[str],
python_version_override: str | None = None,
) -> list[str]:
# NB. Used in quarto plugin

Expand Down Expand Up @@ -283,11 +284,15 @@ def construct_uv_flags(
if uv_needs_refresh:
uv_flags.append("--refresh")

# We use the specified Python version (if any), otherwise
# the current Python version
python_version = pyproject.python_version
if python_version:
uv_flags.extend(["--python", python_version])
# Python version: explicit override > script metadata > current interpreter.
# The override deliberately wins over the script's `requires-python` —
# `html-wasm --execute` needs the sandbox interpreter to match Pyodide
# (3.12), even if the script declares something else. Any resulting
# desync from the script's stated requirement is by design.
if python_version_override:
Comment thread
dmadisetti marked this conversation as resolved.
uv_flags.extend(["--python", python_version_override])
elif pyproject.python_version:
uv_flags.extend(["--python", pyproject.python_version])
else:
uv_flags.extend(["--python", platform.python_version()])

Expand All @@ -314,6 +319,7 @@ def construct_uv_command(
name: str | None,
additional_features: list[DepFeatures],
additional_deps: list[str],
python_version_override: str | None = None,
) -> list[str]:
cmd = ["marimo"] + args
if "--sandbox" in cmd:
Expand All @@ -332,7 +338,11 @@ def construct_uv_command(
temp_file_path = temp_file.name
uv_cmd.extend(
construct_uv_flags(
pyproject, temp_file, additional_features, additional_deps
pyproject,
temp_file,
additional_features,
additional_deps,
python_version_override=python_version_override,
)
)
# Clean up the temporary file after the subprocess has run
Expand Down Expand Up @@ -435,6 +445,8 @@ def run_in_sandbox(
additional_features: list[DepFeatures] | None = None,
additional_deps: list[str] | None = None,
extra_env: dict[str, str] | None = None,
python_version_override: str | None = None,
pyodide_constraints: bool = False,
) -> int:
"""Run marimo in a sandboxed uv environment.

Expand All @@ -459,7 +471,11 @@ def run_in_sandbox(
_ensure_python_version_in_script_metadata(name)

uv_cmd = construct_uv_command(
args, name, additional_features or [], additional_deps or []
args,
name,
additional_features or [],
additional_deps or [],
python_version_override=python_version_override,
)

echo(f"Running in a sandbox: {muted(' '.join(uv_cmd))}", err=True)
Expand All @@ -472,6 +488,30 @@ def run_in_sandbox(
if extra_env:
env.update(extra_env)

if pyodide_constraints:
from marimo._pyodide.pyodide_constraints import (
write_constraint_file,
)

constraint_tmp = tempfile.NamedTemporaryFile(
mode="w",
delete=False,
suffix="-pyodide-constraints.txt",
encoding="utf-8",
)
constraint_tmp.close()
constraint_path = constraint_tmp.name
if write_constraint_file(constraint_path):
env["UV_CONSTRAINT"] = constraint_path

def cleanup_constraint_file() -> None:
try:
os.unlink(constraint_path)
except FileNotFoundError:
pass

atexit.register(cleanup_constraint_file)

# On Unix, run `uv` in its own session so that (a) the tty no longer
# delivers SIGINT/SIGTERM to it directly and (b) we can signal the whole
# subtree with a single killpg. The signal handlers below are then the
Expand Down
111 changes: 111 additions & 0 deletions marimo/_pyodide/pyodide_constraints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Copyright 2026 Marimo. All rights reserved.
"""Resolve Pyodide-compatible package constraints from pyodide-lock.json.

The lockfile is fetched from `wasm.marimo.app` (which serves a marimo-patched
fork of upstream `pyodide-lock.json`). Set `MARIMO_PYODIDE_LOCK_FILE` to read
a local copy instead — useful for offline / air-gapped environments and for
testing.
"""

from __future__ import annotations

import os
import re

import msgspec

from marimo import _loggers
from marimo._utils import requests
from marimo._version import __version__

LOGGER = _loggers.marimo_logger()

# Pyodide version matching frontend/package.json — update together.
PYODIDE_VERSION = "0.27.7"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on keeping this file in _pyodide/?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or at least the constants


# Derived from the lockfile's info.python field (Pyodide 0.27.7 → 3.12.7).
PYODIDE_PYTHON_VERSION = "3.12"

# Env var pointing at a local pyodide-lock.json. Lets offline / air-gapped
# users supply the lockfile out-of-band instead of fetching from the host.
PYODIDE_LOCK_FILE_ENV = "MARIMO_PYODIDE_LOCK_FILE"

# We host our own version of
# "https://cdn.jsdelivr.net/pyodide/v{version}/full/pyodide-lock.json"
# as marimo contains patches to support more packages. The `v` query is
# the marimo version (cache-buster); the server selects the lockfile for
# whichever pyodide release that marimo build was pinned to.
_LOCKFILE_URL = f"https://wasm.marimo.app/pyodide-lock.json?v={__version__}"
Comment thread
dmadisetti marked this conversation as resolved.


class PyodidePackage(msgspec.Struct, kw_only=True):
"""A single package entry inside pyodide-lock.json."""

name: str
version: str
package_type: str = ""


class PyodideLockfile(msgspec.Struct, kw_only=True):
"""Top-level shape of pyodide-lock.json.

Other fields (`info`, `package` indices, etc.) are ignored.
"""

packages: dict[str, PyodidePackage] = msgspec.field(default_factory=dict)


def _read_lockfile() -> PyodideLockfile:
override = os.environ.get(PYODIDE_LOCK_FILE_ENV)
if override:
with open(override, "rb") as f:
payload = f.read()
else:
# `marimo._utils.requests` ships its own `marimo/<version>` UA.
payload = (
requests.get(_LOCKFILE_URL, timeout=30).raise_for_status().content
)
return msgspec.json.decode(payload, type=PyodideLockfile)


def fetch_pyodide_package_versions() -> dict[str, str]:
"""Fetch pyodide-lock.json and return {package_name: version}.

Reads from the path in `$MARIMO_PYODIDE_LOCK_FILE` if set, otherwise
fetches from `wasm.marimo.app`. Only entries with
`package_type == "package"` are included; test packages
(names ending in `-tests`) are excluded.
"""
lockfile = _read_lockfile()
return {
spec.name: spec.version
for spec in lockfile.packages.values()
if spec.package_type == "package" and not spec.name.endswith("-tests")
}


def write_constraint_file(
path: str,
) -> bool:
"""Fetch Pyodide lockfile and write a pip constraints file.

Returns True on success, False if the lockfile couldn't be fetched.
"""
try:
versions = fetch_pyodide_package_versions()
except Exception:
LOGGER.warning(
"Could not fetch Pyodide lockfile — "
"running without package version constraints."
)
return False
with open(path, "w", encoding="utf-8") as f:
f.writelines(
f"{pkg}=={version}\n" for pkg, version in sorted(versions.items())
)
return True


def normalize_package_name(name: str) -> str:
"""Normalize package name per PEP 503."""
return re.sub(r"[-_.]+", "-", name).lower()
Loading
Loading