Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

compat: Register webbrowser.open hook for pyodide #3864

Merged
merged 2 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions marimo/_runtime/marimo_browser.py
Original file line number Diff line number Diff line change
@@ -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>I wrote 20 short programs in '
"Python yesterday."
"<b> It was wonderful. </b>"
"Perl, <u>I'm leaving you.</u></i>\""
"<br/> The technologies may have changed, but the "
"sentiment remains. We agree Randall;<br /> Edited "
"from <u><a "
" href='https://xkcd.com/353'>"
" XKCD/353"
" </a></u> under CC-2.5;"
),
)
)
else:
mo.output.append(
mo.Html(
f"<iframe src='{url}' style='width:100%;height:500px'></iframe>",
)
)
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
21 changes: 20 additions & 1 deletion marimo/_runtime/patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions marimo/_runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions tests/_runtime/test_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<iframe" in outputs[-1]


async def test_webbrowser_easter_egg(
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("import antigravity;"),
]
)
assert "antigravity" 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 "<iframe" not in outputs[-1]
assert "<img" in outputs[-1]
Loading