diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 72d62ffdc2a..598bc6ecc65 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +import os +import sys from pathlib import Path from typing import TYPE_CHECKING, Literal @@ -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, @@ -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 @@ -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 @@ -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 @@ -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, @@ -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") @@ -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]") @@ -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( @@ -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": + 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" diff --git a/marimo/_cli/sandbox.py b/marimo/_cli/sandbox.py index 15e5f5a89ca..8435a0b8e2d 100644 --- a/marimo/_cli/sandbox.py +++ b/marimo/_cli/sandbox.py @@ -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 @@ -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: + 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()]) @@ -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: @@ -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 @@ -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. @@ -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) @@ -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 diff --git a/marimo/_pyodide/pyodide_constraints.py b/marimo/_pyodide/pyodide_constraints.py new file mode 100644 index 00000000000..bb13a956121 --- /dev/null +++ b/marimo/_pyodide/pyodide_constraints.py @@ -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" + +# 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__}" + + +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/` 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() diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index efe7e358bfd..18473723924 100644 --- a/marimo/_server/export/__init__.py +++ b/marimo/_server/export/__init__.py @@ -41,6 +41,9 @@ from marimo._session.model import ConnectionState, SessionMode from marimo._session.notebook import AppFileManager, load_notebook from marimo._types.ids import ConsumerId +from marimo._utils.inline_script_metadata import ( + pin_pep723_dependencies_for_wasm, +) from marimo._utils.marimo_path import MarimoPath LOGGER = _loggers.marimo_logger() @@ -391,11 +394,13 @@ async def run_app_then_export_as_wasm( session_view, file_manager.app.cell_manager ) + code = pin_pep723_dependencies_for_wasm(file_manager.app.to_py(), path) + html, filename = Exporter().export_as_wasm( filename=file_manager.filename, app=file_manager.app, display_config=display_config, - code=file_manager.app.to_py(), + code=code, mode=mode, show_code=show_code, asset_url=asset_url, diff --git a/marimo/_utils/inline_script_metadata.py b/marimo/_utils/inline_script_metadata.py index 0b7acaf2dc3..451b696874b 100644 --- a/marimo/_utils/inline_script_metadata.py +++ b/marimo/_utils/inline_script_metadata.py @@ -2,16 +2,21 @@ from __future__ import annotations import json +import os import re from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from marimo import _loggers from marimo._cli.files.file_path import FileContentReader +from marimo._cli.print import echo from marimo._utils.code import hash_code from marimo._utils.paths import normalize_path from marimo._utils.scripts import read_pyproject_from_script +if TYPE_CHECKING: + from marimo._utils.marimo_path import MarimoPath + LOGGER = _loggers.marimo_logger() @@ -211,6 +216,185 @@ def is_marimo_dependency(dependency: str) -> bool: return without_version == "marimo" or without_version.startswith("marimo[") +def _normalize_pep503(name: str) -> str: + """Normalize a project name per PEP 503.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +# Captures the leading bare project name (with optional extras) of a PEP 508 +# dependency string. Stops at the first version specifier, marker, or URL +# delimiter. +_DEP_NAME_RE = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[^\]]+\])?)" +) + + +def _pin_dep(dep: str, pins: dict[str, str]) -> str: + """Replace any version specifier in `dep` with the pinned version. + + Returns `dep` unchanged if the name doesn't appear in `pins`, or if the + dependency uses a URL/VCS source (we don't override explicit URLs). + """ + stripped = dep.strip() + if ( + not stripped + or "@" in stripped + or stripped.startswith(("git+", "http")) + ): + return dep + + match = _DEP_NAME_RE.match(stripped) + if match is None: + return dep + + name_with_extras = match.group("name") + bare_name = name_with_extras.split("[", 1)[0] + canonical = _normalize_pep503(bare_name) + if canonical not in pins: + return dep + + rest = stripped[match.end() :] + # Preserve any environment marker (`; python_version >= ...`). + marker = "" + if ";" in rest: + _, _, marker_text = rest.partition(";") + marker = f";{marker_text}" + + return f"{name_with_extras}=={pins[canonical]}{marker}" + + +def with_pinned_dependencies( + code: str, + pins: dict[str, str], + *, + lock_kind: str, +) -> str: + """Rewrite the PEP 723 [run] dependencies block to pin top-level names. + + Args: + code: Notebook source containing (optionally) a PEP 723 block. + pins: Mapping of canonical (PEP 503) package name → version string. + lock_kind: Annotation written to `[tool.marimo.export] lock_kind`, + e.g. "resolved" or "observed". + + Names not in `pins` are left as-is. URL/VCS dependencies are not + rewritten. If the script has no PEP 723 block, `code` is returned + unchanged. + """ + from marimo._utils.scripts import ( + REGEX, + read_pyproject_from_script, + write_pyproject_to_script, + ) + + project = read_pyproject_from_script(code) + if project is None: + return code + + deps = project.get("dependencies") + if isinstance(deps, list): + project["dependencies"] = [_pin_dep(str(dep), pins) for dep in deps] + + tool = project.setdefault("tool", {}) + if not isinstance(tool, dict): + tool = {} + project["tool"] = tool + marimo_tool = tool.setdefault("marimo", {}) + if not isinstance(marimo_tool, dict): + marimo_tool = {} + tool["marimo"] = marimo_tool + export_section = marimo_tool.setdefault("export", {}) + if not isinstance(export_section, dict): + export_section = {} + marimo_tool["export"] = export_section + export_section["lock_kind"] = lock_kind + + new_block = write_pyproject_to_script(project) + return re.sub(REGEX, new_block, code, count=1) + + +def pin_pep723_dependencies_for_wasm(code: str, path: MarimoPath) -> str: + """Pin a notebook's PEP 723 deps for embedding in a WASM HTML export. + + For each top-level dep also shipped in the Pyodide lockfile, pin to the + *lockfile* version — that's what micropip will install in the browser, + so embedding any other pin would be a lie that breaks the export. If + the lockfile fetch fails, degrade to pinning the locally installed + version (best-effort; the WASM runtime falls back to its bundled + lockfile). Also warns about top-level dependencies that aren't shipped + in pyodide-lock — those may fail to install via micropip. The lock + kind is "resolved" when we ran inside the html-wasm sandbox, otherwise + "observed". + """ + from importlib.metadata import distributions + + from marimo._pyodide.pyodide_constraints import ( + fetch_pyodide_package_versions, + normalize_package_name, + ) + + installed: dict[str, str] = {} + for dist in distributions(): + name = dist.metadata["Name"] + version = dist.version + if name and version: + installed[normalize_package_name(name)] = version + + pyodide_versions: dict[str, str] = {} + try: + pyodide_versions = { + normalize_package_name(n): v + for n, v in fetch_pyodide_package_versions().items() + } + # Pin to lockfile versions for names the browser can actually + # install. Restricting to installed names keeps the pin set small + # and noise-free (an irrelevant pin for an unused dep is harmless + # but unnecessary churn). + pins = { + name: pyodide_versions[name] + for name in installed + if name in pyodide_versions + } + except Exception: + # Fetch failures degrade to pinning whatever is installed; the + # WASM micropip will still try its bundled lockfile first. + pins = installed + pyodide_names = set(pyodide_versions) + + # Warn about top-level deps not bundled in pyodide. micropip *may* + # still install pure-python ones from PyPI in the browser; native + # ones (jax, torch, numpy alternatives) will fail. We don't know + # which from here, so emit a single advisory. + if pyodide_names: + try: + pyproject = PyProjectReader.from_filename(path.absolute_name) + top_level: list[str] = [] + for dep in pyproject.dependencies: + match = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)", dep.strip()) + if match is None: + continue + canonical = normalize_package_name(match.group(1)) + if canonical == "marimo" or canonical in pyodide_names: + continue + top_level.append(match.group(1)) + if top_level: + echo( + "warn: these dependencies are not bundled in the " + "Pyodide lockfile and may fail to install in the " + "browser: " + ", ".join(sorted(set(top_level))), + err=True, + ) + except Exception as e: + LOGGER.debug("Skipped wasm compat warn: %s", e) + + lock_kind = ( + "resolved" + if os.environ.get("MARIMO_HTML_WASM_SANDBOX_BOOTSTRAPPED") == "1" + else "observed" + ) + return with_pinned_dependencies(code, pins, lock_kind=lock_kind) + + def get_headers_from_markdown(contents: str) -> dict[str, str]: from marimo._convert.markdown.to_ir import extract_frontmatter diff --git a/tests/_cli/test_sandbox.py b/tests/_cli/test_sandbox.py index 312ee22a21f..9059dc2030e 100644 --- a/tests/_cli/test_sandbox.py +++ b/tests/_cli/test_sandbox.py @@ -874,3 +874,50 @@ def test_resolve_local_path_line() -> None: assert _resolve_local_path_line("numpy==1.26.0", d) == "numpy==1.26.0" assert _resolve_local_path_line("/absolute/path", d) == "/absolute/path" assert _resolve_local_path_line("", d) == "" + + +def test_python_version_override_takes_precedence(tmp_path: Path) -> None: + """Override beats both PEP 723 metadata and the host interpreter.""" + script_path = tmp_path / "test.py" + script_path.write_text( + """ +# /// script +# requires-python = ">=3.11" +# dependencies = ["numpy"] +# /// +import marimo +""" + ) + uv_cmd = construct_uv_command( + ["edit", str(script_path)], + str(script_path), + additional_features=[], + additional_deps=[], + python_version_override="3.12", + ) + python_idx = uv_cmd.index("--python") + assert uv_cmd[python_idx + 1] == "3.12" + # Original metadata version should not appear. + assert ">=3.11" not in uv_cmd + + +def test_python_version_override_without_metadata(tmp_path: Path) -> None: + """Override applies even when the script has no requires-python.""" + script_path = tmp_path / "test.py" + script_path.write_text( + """ +# /// script +# dependencies = ["numpy"] +# /// +import marimo +""" + ) + uv_cmd = construct_uv_command( + ["edit", str(script_path)], + str(script_path), + additional_features=[], + additional_deps=[], + python_version_override="3.12", + ) + python_idx = uv_cmd.index("--python") + assert uv_cmd[python_idx + 1] == "3.12" diff --git a/tests/_pyodide/test_pyodide_constraints.py b/tests/_pyodide/test_pyodide_constraints.py new file mode 100644 index 00000000000..30d55cc3d7f --- /dev/null +++ b/tests/_pyodide/test_pyodide_constraints.py @@ -0,0 +1,118 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from marimo._pyodide.pyodide_constraints import ( + fetch_pyodide_package_versions, + normalize_package_name, + write_constraint_file, +) +from marimo._utils.requests import Response + +if TYPE_CHECKING: + from pathlib import Path + +_FAKE_LOCKFILE = { + "packages": { + "numpy": { + "name": "numpy", + "version": "2.0.2", + "package_type": "package", + }, + "pandas": { + "name": "pandas", + "version": "2.2.3", + "package_type": "package", + }, + "numpy-tests": { + "name": "numpy-tests", + "version": "2.0.2", + "package_type": "package", + }, + "pyodide-runtime": { + "name": "pyodide-runtime", + "version": "1.0", + "package_type": "shared_library", + }, + } +} + + +def _fake_response(payload: dict[str, object]) -> Response: + return Response( + status_code=200, + content=json.dumps(payload).encode("utf-8"), + headers={}, + ) + + +def _patched_get(payload: dict[str, object]): + return patch( + "marimo._pyodide.pyodide_constraints.requests.get", + return_value=_fake_response(payload), + ) + + +def test_fetch_pyodide_package_versions_filters_test_and_non_package() -> None: + with _patched_get(_FAKE_LOCKFILE): + versions = fetch_pyodide_package_versions() + assert versions == {"numpy": "2.0.2", "pandas": "2.2.3"} + + +def test_write_constraint_file_sorts_and_pins(tmp_path: Path) -> None: + target = tmp_path / "constraints.txt" + with _patched_get(_FAKE_LOCKFILE): + ok = write_constraint_file(str(target)) + assert ok is True + assert target.read_text() == "numpy==2.0.2\npandas==2.2.3\n" + + +def test_write_constraint_file_returns_false_on_fetch_failure( + tmp_path: Path, +) -> None: + target = tmp_path / "constraints.txt" + with patch( + "marimo._pyodide.pyodide_constraints.requests.get", + side_effect=OSError("offline"), + ): + ok = write_constraint_file(str(target)) + assert ok is False + # We didn't write anything. + assert not target.exists() + + +def test_normalize_package_name_pep503() -> None: + assert normalize_package_name("My_Pkg.Name") == "my-pkg-name" + assert normalize_package_name("Pkg__Name") == "pkg-name" + assert normalize_package_name("simple") == "simple" + + +def test_lockfile_env_override_reads_local_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """MARIMO_PYODIDE_LOCK_FILE skips the network and reads from disk.""" + lockfile = tmp_path / "pyodide-lock.json" + lockfile.write_text(json.dumps(_FAKE_LOCKFILE)) + monkeypatch.setenv("MARIMO_PYODIDE_LOCK_FILE", str(lockfile)) + + # requests.get would raise loudly if hit; assert we never call it. + with patch( + "marimo._pyodide.pyodide_constraints.requests.get", + side_effect=AssertionError("requests.get should not be called"), + ): + versions = fetch_pyodide_package_versions() + assert versions == {"numpy": "2.0.2", "pandas": "2.2.3"} + + +def test_lockfile_env_override_missing_file_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bad path bubbles the OSError so write_constraint_file degrades.""" + monkeypatch.setenv("MARIMO_PYODIDE_LOCK_FILE", "/no/such/file") + with pytest.raises(OSError): + fetch_pyodide_package_versions() diff --git a/tests/_utils/test_inline_script_metadata.py b/tests/_utils/test_inline_script_metadata.py index 3199e1ded8e..24c044bbf6b 100644 --- a/tests/_utils/test_inline_script_metadata.py +++ b/tests/_utils/test_inline_script_metadata.py @@ -432,3 +432,261 @@ def test_script_metadata_hash_from_filename_changes_with_dependencies( assert script_metadata_hash_from_filename( str(first) ) != script_metadata_hash_from_filename(str(second)) + + +def test_with_pinned_dependencies_pins_known_names() -> None: + from marimo._utils.inline_script_metadata import ( + with_pinned_dependencies, + ) + + src = """# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "polars", +# "duckdb>=1.0", +# ] +# /// + +import polars +""" + out = with_pinned_dependencies( + src, + {"polars": "1.20.0", "duckdb": "1.1.3"}, + lock_kind="resolved", + ) + assert "polars==1.20.0" in out + assert "duckdb==1.1.3" in out + assert 'lock_kind = "resolved"' in out + + +def test_with_pinned_dependencies_leaves_unpinned_alone() -> None: + from marimo._utils.inline_script_metadata import ( + with_pinned_dependencies, + ) + + src = """# /// script +# dependencies = [ +# "polars", +# "scikit-learn", +# ] +# /// +""" + out = with_pinned_dependencies( + src, + {"polars": "1.20.0"}, + lock_kind="observed", + ) + assert "polars==1.20.0" in out + # scikit-learn wasn't in pins; left untouched. + assert "scikit-learn" in out + assert "scikit-learn==" not in out + + +def test_with_pinned_dependencies_preserves_url_deps() -> None: + from marimo._utils.inline_script_metadata import ( + with_pinned_dependencies, + ) + + src = """# /// script +# dependencies = [ +# "polars", +# "private @ https://example.com/private-1.0-py3-none-any.whl", +# ] +# /// +""" + out = with_pinned_dependencies( + src, + {"polars": "1.20.0", "private": "9.9.9"}, + lock_kind="resolved", + ) + assert "polars==1.20.0" in out + assert "https://example.com/private-1.0-py3-none-any.whl" in out + assert "private==9.9.9" not in out + + +def test_with_pinned_dependencies_canonicalizes_names() -> None: + from marimo._utils.inline_script_metadata import ( + with_pinned_dependencies, + ) + + src = """# /// script +# dependencies = ["Scikit_Learn"] +# /// +""" + out = with_pinned_dependencies( + src, + {"scikit-learn": "1.5.0"}, + lock_kind="observed", + ) + assert "Scikit_Learn==1.5.0" in out + + +def test_with_pinned_dependencies_no_block_returns_unchanged() -> None: + from marimo._utils.inline_script_metadata import ( + with_pinned_dependencies, + ) + + src = "import polars\n" + out = with_pinned_dependencies( + src, + {"polars": "1.20.0"}, + lock_kind="resolved", + ) + assert out == src + + +# ----- pin_pep723_dependencies_for_wasm ------------------------------------- + + +class _FakeDist: + """Minimal stand-in for `importlib.metadata.Distribution`.""" + + def __init__(self, name: str, version: str) -> None: + self.metadata = {"Name": name} + self.version = version + + +_WASM_SRC = """# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "numpy", +# "pandas", +# "jax", +# ] +# /// +""" + + +def _wasm_path(tmp_path: Path) -> object: + from marimo._utils.marimo_path import MarimoPath + + script = tmp_path / "notebook.py" + script.write_text(_WASM_SRC) + return MarimoPath(script) + + +def test_pin_for_wasm_pins_to_lockfile_versions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pins to the lockfile version, not the locally installed one.""" + import importlib.metadata + + from marimo._utils.inline_script_metadata import ( + pin_pep723_dependencies_for_wasm, + ) + + monkeypatch.delenv("MARIMO_HTML_WASM_SANDBOX_BOOTSTRAPPED", raising=False) + monkeypatch.setattr( + importlib.metadata, + "distributions", + lambda: [_FakeDist("numpy", "1.99.0"), _FakeDist("pandas", "2.99.0")], + ) + # Need to patch where it's looked up. The function imports inside its + # body, so patch the source module. + import marimo._pyodide.pyodide_constraints as constraints + + monkeypatch.setattr( + constraints, + "fetch_pyodide_package_versions", + lambda: {"numpy": "2.0.2", "pandas": "2.2.3"}, + ) + + out = pin_pep723_dependencies_for_wasm(_WASM_SRC, _wasm_path(tmp_path)) + assert "numpy==2.0.2" in out + assert "pandas==2.2.3" in out + # Locally-installed mismatched version should NOT appear. + assert "numpy==1.99.0" not in out + assert "pandas==2.99.0" not in out + # jax isn't in the lockfile, left unpinned. + assert "jax" in out + assert "jax==" not in out + assert 'lock_kind = "observed"' in out + + +def test_pin_for_wasm_lock_kind_resolved_inside_sandbox( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The bootstrap env var flips lock_kind to "resolved".""" + import importlib.metadata + + from marimo._utils.inline_script_metadata import ( + pin_pep723_dependencies_for_wasm, + ) + + monkeypatch.setenv("MARIMO_HTML_WASM_SANDBOX_BOOTSTRAPPED", "1") + monkeypatch.setattr( + importlib.metadata, + "distributions", + lambda: [_FakeDist("numpy", "2.0.2")], + ) + import marimo._pyodide.pyodide_constraints as constraints + + monkeypatch.setattr( + constraints, + "fetch_pyodide_package_versions", + lambda: {"numpy": "2.0.2"}, + ) + + out = pin_pep723_dependencies_for_wasm(_WASM_SRC, _wasm_path(tmp_path)) + assert 'lock_kind = "resolved"' in out + + +def test_pin_for_wasm_warns_about_non_pyodide_deps( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Top-level deps not in the pyodide lockfile trigger a stderr warning.""" + import importlib.metadata + + from marimo._utils.inline_script_metadata import ( + pin_pep723_dependencies_for_wasm, + ) + + monkeypatch.setattr( + importlib.metadata, + "distributions", + lambda: [_FakeDist("numpy", "2.0.2")], + ) + import marimo._pyodide.pyodide_constraints as constraints + + monkeypatch.setattr( + constraints, + "fetch_pyodide_package_versions", + lambda: {"numpy": "2.0.2"}, + ) + + pin_pep723_dependencies_for_wasm(_WASM_SRC, _wasm_path(tmp_path)) + err = capsys.readouterr().err + # jax and pandas aren't in the lockfile mock; both should be flagged. + assert "jax" in err + assert "pandas" in err + # numpy is in the lockfile; should not be flagged. + assert "numpy" not in err + + +def test_pin_for_wasm_falls_back_to_installed_on_fetch_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """When the lockfile is unreachable, we still pin from the local env.""" + import importlib.metadata + + from marimo._utils.inline_script_metadata import ( + pin_pep723_dependencies_for_wasm, + ) + + monkeypatch.setattr( + importlib.metadata, + "distributions", + lambda: [_FakeDist("numpy", "1.99.0")], + ) + import marimo._pyodide.pyodide_constraints as constraints + + def _boom() -> dict[str, str]: + raise RuntimeError("offline") + + monkeypatch.setattr(constraints, "fetch_pyodide_package_versions", _boom) + + out = pin_pep723_dependencies_for_wasm(_WASM_SRC, _wasm_path(tmp_path)) + # Fall-through path pins to whatever's installed. + assert "numpy==1.99.0" in out