diff --git a/marimo/_runtime/marimo_browser.py b/marimo/_runtime/marimo_browser.py
new file mode 100644
index 00000000000..8cf2114bea9
--- /dev/null
+++ b/marimo/_runtime/marimo_browser.py
@@ -0,0 +1,91 @@
+# Copyright 2025 Marimo. All rights reserved.
+from __future__ import annotations
+
+import webbrowser
+
+from marimo._runtime.context import get_context
+from marimo._runtime.context.types import ContextNotInitializedError
+
+
+def browser_open_fallback(
+ url: str, new: int = 0, autoraise: bool = False
+) -> bool:
+ """
+ Inserts an iframe with the given URL into the output.
+
+ NB Returns false on failure.
+ """
+ import inspect
+
+ import marimo as mo
+
+ del new, autoraise # unused
+
+ try:
+ ctx = get_context()
+ except ContextNotInitializedError:
+ return False
+
+ if ctx.execution_context is None:
+ return False
+
+ # import antigravity is a real module in python. see:
+ # github.com/python/cpython/blob/main/Lib/antigravity.py
+ # which automatically triggers a webbrowser.open call to the relevant
+ # comic. We patch webbrowser.open due to an incompatible stub:
+ # https://pyodide.org/en/stable/usage/wasm-constraints.html
+ # so may as well hook in to customize the easter egg. Especially since
+ # iframe constraints actually block this from loading on marimo.app.
+ #
+ # For other python lore, try:
+ # import this
+ stack = inspect.stack()
+ if len(stack) > 3 and (
+ stack[2].filename.endswith("antigravity.py")
+ or (stack[1].filename.endswith("antigravity.py"))
+ ):
+ mo.output.append(
+ mo.image(
+ "https://marimo.app/images/antigravity.png",
+ alt=(
+ "The image shows 2 stick figures in XKCD style. The one "
+ 'on the left says:"You\'re Flying! How?", a floating '
+ 'stick figure on the right responds "marimo!"'
+ ),
+ caption=(
+ 'Original alt text: "I wrote 20 short programs in '
+ "Python yesterday."
+ " It was wonderful. "
+ "Perl, I'm leaving you.\""
+ "
The technologies may have changed, but the "
+ "sentiment remains. We agree Randall;
Edited "
+ "from "
+ " XKCD/353"
+ " under CC-2.5;"
+ ),
+ )
+ )
+ else:
+ mo.output.append(
+ mo.Html(
+ f"",
+ )
+ )
+ return True
+
+
+def build_browser_fallback() -> "type[webbrowser.BaseBrowser]":
+ """
+ Dynamically create the class since BaseBrowser does not exist in
+ pyodide.
+ """
+
+ # Construct like this to limit stack frames.
+ MarimoBrowser = type(
+ "MarimoBrowser",
+ (webbrowser.BaseBrowser,),
+ {"open": staticmethod(browser_open_fallback)},
+ )
+
+ return MarimoBrowser
diff --git a/marimo/_runtime/patches.py b/marimo/_runtime/patches.py
index e843d1ce707..877dd56d9e1 100644
--- a/marimo/_runtime/patches.py
+++ b/marimo/_runtime/patches.py
@@ -8,7 +8,7 @@
import types
from typing import Any, Callable, Iterator
-from marimo._runtime import marimo_pdb
+from marimo._runtime import marimo_browser, marimo_pdb
def patch_pdb(debugger: marimo_pdb.MarimoPdb) -> None:
@@ -19,6 +19,25 @@ def patch_pdb(debugger: marimo_pdb.MarimoPdb) -> None:
pdb.set_trace = functools.partial(marimo_pdb.set_trace, debugger=debugger)
+def patch_webbrowser() -> None:
+ import webbrowser
+
+ try:
+ _ = webbrowser.get()
+ # pyodide doesn't have a webbrowser.get() method
+ # (nor a webbrowser.Error, so careful)
+ except AttributeError:
+ webbrowser.open = marimo_browser.browser_open_fallback
+ except webbrowser.Error:
+ MarimoBrowser = marimo_browser.build_browser_fallback()
+ webbrowser.register(
+ "marimo-output",
+ None,
+ MarimoBrowser(),
+ preferred=True,
+ )
+
+
def patch_sys_module(module: types.ModuleType) -> None:
sys.modules[module.__name__] = module
diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py
index a741af34b59..0020239ae9b 100644
--- a/marimo/_runtime/runtime.py
+++ b/marimo/_runtime/runtime.py
@@ -537,8 +537,14 @@ def __init__(
# was invoked. New state updates evict older ones.
self.state_updates: dict[State[Any], CellId_t] = {}
+ # Webbrowser may not be set (e.g. docker container) or stubbed/broken
+ # (e.g. in pyodide). Set default to just inject an iframe of the
+ # expected page to output.
+ patches.patch_webbrowser()
+ # micropip only patched in non-pyodide environments.
if not is_pyodide():
patches.patch_micropip(self.globals)
+
exec("import marimo as __marimo__", self.globals)
def teardown(self) -> None:
diff --git a/tests/_runtime/test_patches.py b/tests/_runtime/test_patches.py
index c2ac7f53848..ca615474d48 100644
--- a/tests/_runtime/test_patches.py
+++ b/tests/_runtime/test_patches.py
@@ -118,3 +118,60 @@ async def test_micropip_set_index_urls(
]
)
TestMicropip._assert_micropip_warning_printed(buf.getvalue())
+
+
+async def test_webbrowser_injection(
+ mocked_kernel: Kernel, exec_req: ExecReqProvider
+):
+ await mocked_kernel.k.run(
+ [
+ exec_req.get("""
+ import webbrowser
+ MarimoBrowser = __marimo__._runtime.marimo_browser.build_browser_fallback()
+ webbrowser.register(
+ "marimo-output", None, MarimoBrowser(), preferred=True
+ )
+ """),
+ ]
+ )
+ await mocked_kernel.k.run(
+ [
+ cell := exec_req.get("webbrowser.open('https://marimo.io');"),
+ ]
+ )
+ assert "webbrowser" in mocked_kernel.k.globals
+ outputs: list[str] = []
+ for msg in mocked_kernel.stream.messages:
+ if msg[0] == "cell-op" and msg[1]["output"] is not None:
+ outputs.append(msg[1]["output"]["data"])
+
+ assert "