Skip to content

Commit 1a56d93

Browse files
peter-gymscolnick
andauthored
feat: generate islands from notebook IR (#9988)
Adds `MarimoIslandGenerator.from_ir(...)` so callers can construct island exports from `NotebookSerialization` directly. This way, downstream exporters like quarto-marimo, jupyter-book-marimo and mdx-marimo mostly only need to worry about parsing their source format and mapping it to marimo IR. Disabled and transitively disabled cells are marked nonreactive. --------- Co-authored-by: Myles Scolnick <myles@marimo.io>
1 parent 5c3d9a9 commit 1a56d93

2 files changed

Lines changed: 334 additions & 1 deletion

File tree

marimo/_islands/_island_generator.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,24 @@
1313
from marimo._ast.app_config import _AppConfig
1414
from marimo._ast.cell import Cell, CellConfig
1515
from marimo._ast.compiler import compile_cell
16+
from marimo._ast.load import load_notebook_ir
1617
from marimo._messaging.cell_output import CellOutput
1718
from marimo._output.utils import uri_encode_component
19+
from marimo._schemas.serialization import NotebookSerialization
1820
from marimo._session.notebook import AppFileManager, load_notebook
1921
from marimo._types.ids import CellId_t
22+
from marimo._utils.marimo_path import MarimoPath
2023
from marimo._version import __version__
2124

2225
if sys.platform == "win32": # handling for windows
2326
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
2427

2528
if TYPE_CHECKING:
29+
from marimo._ast.models import CellData
2630
from marimo._session.state.session_view import SessionView
2731

2832
LOGGER = _loggers.marimo_logger()
33+
IN_MEMORY_FILENAME = "<marimo>.py"
2934

3035

3136
class MarimoIslandStub:
@@ -255,6 +260,11 @@ def __init__(self, app_id: str = "main"):
255260
# resolve to the notebook rather than to the host process.
256261
self._source_filename: str | None = None
257262

263+
@property
264+
def stubs(self) -> tuple[MarimoIslandStub, ...]:
265+
"""Return a snapshot of the generated per-cell island renderers."""
266+
return tuple(self._stubs)
267+
258268
@staticmethod
259269
def from_file(
260270
filename: str,
@@ -290,6 +300,49 @@ def from_file(
290300

291301
return generator
292302

303+
@staticmethod
304+
def _from_ir(
305+
notebook: NotebookSerialization,
306+
*,
307+
app_id: str = "main",
308+
filepath: str | None = None,
309+
display_code: bool = False,
310+
) -> MarimoIslandGenerator:
311+
"""Create a generator from serialized marimo notebook data.
312+
313+
Args:
314+
notebook: Serialized notebook IR.
315+
app_id: App identifier written to rendered island DOM.
316+
filepath: Source path used for `__file__` and `mo.notebook_dir()`.
317+
display_code: Whether generated stubs render code by default.
318+
"""
319+
ir_filepath = filepath if filepath is not None else notebook.filename
320+
source_filename = _source_filename(ir_filepath)
321+
app = load_notebook_ir(
322+
notebook,
323+
filepath=_load_filepath(source_filename or ir_filepath),
324+
)
325+
326+
generator = MarimoIslandGenerator(app_id=app_id)
327+
generator._source_filename = source_filename
328+
internal_app = InternalApp(app)
329+
generator._app = internal_app
330+
generator._config = internal_app.config
331+
cells = list(generator._app.cell_manager.cell_data())
332+
disabled_cell_ids = _disabled_cell_ids(cells)
333+
generator._stubs = [
334+
MarimoIslandStub(
335+
cell_id=data.cell_id,
336+
app_id=generator._app_id,
337+
code=data.code,
338+
display_code=display_code,
339+
is_reactive=data.cell_id not in disabled_cell_ids,
340+
)
341+
for data in cells
342+
]
343+
344+
return generator
345+
293346
def add_code(
294347
self,
295348
code: str,
@@ -597,3 +650,36 @@ def render_html(
597650

598651
def remove_empty_lines(text: str) -> str:
599652
return "\n".join([line for line in text.split("\n") if line.strip() != ""])
653+
654+
655+
def _source_filename(filename: str | None) -> str | None:
656+
if filename is None or filename == IN_MEMORY_FILENAME:
657+
return None
658+
return os.path.abspath(filename)
659+
660+
661+
def _load_filepath(filename: str | None) -> str:
662+
if filename and MarimoPath.is_valid_path(filename):
663+
return filename
664+
return IN_MEMORY_FILENAME
665+
666+
667+
def _disabled_cell_ids(cell_data: list[CellData]) -> set[CellId_t]:
668+
from marimo._runtime.dataflow.graph import DirectedGraph
669+
670+
disabled_cell_ids = {
671+
data.cell_id for data in cell_data if data.cell is None
672+
}
673+
graph = DirectedGraph()
674+
675+
for data in cell_data:
676+
cell = data.cell
677+
if cell is not None:
678+
graph.register_cell(data.cell_id, cell._cell)
679+
680+
for data in cell_data:
681+
cell = data.cell
682+
if cell is not None and graph.is_disabled(data.cell_id):
683+
disabled_cell_ids.add(data.cell_id)
684+
685+
return disabled_cell_ids

tests/_islands/test_island_generator.py

Lines changed: 248 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from typing import TYPE_CHECKING, get_type_hints
44

55
import pytest
66

@@ -9,6 +9,11 @@
99
from marimo._islands._island_generator import (
1010
MarimoIslandGenerator,
1111
)
12+
from marimo._schemas.serialization import (
13+
AppInstantiation,
14+
CellDef,
15+
NotebookSerialization,
16+
)
1217
from tests.mocks import snapshotter
1318

1419
if TYPE_CHECKING:
@@ -17,6 +22,19 @@
1722
snapshot = snapshotter(__file__)
1823

1924

25+
def _notebook(
26+
cells: list[CellDef],
27+
*,
28+
options: dict[str, object] | None = None,
29+
filename: str | None = None,
30+
) -> NotebookSerialization:
31+
return NotebookSerialization(
32+
app=AppInstantiation(options=options or {}),
33+
cells=cells,
34+
filename=filename,
35+
)
36+
37+
2038
def test_add_code():
2139
generator = MarimoIslandGenerator()
2240
generator.add_code("print('Hello, World!')")
@@ -243,6 +261,235 @@ def __(mo):
243261
assert str(other_dir / "nb.py") not in data
244262

245263

264+
async def test_from_ir_builds_notebook_serialization() -> None:
265+
notebook = _notebook(
266+
[
267+
CellDef(code="import marimo as mo"),
268+
CellDef(code='mo.md("# Hello, IR!")'),
269+
],
270+
)
271+
272+
generator = MarimoIslandGenerator._from_ir(notebook, app_id="page-a")
273+
stubs = generator.stubs
274+
275+
assert len(stubs) == 2
276+
assert stubs[0].code == "import marimo as mo"
277+
assert stubs[1].code == 'mo.md("# Hello, IR!")'
278+
body_html = generator.render_body(include_init_island=False)
279+
assert 'data-app-id="page-a"' in body_html
280+
for cell_id in generator._app.cell_manager.cell_ids():
281+
assert f'data-cell-id="{cell_id}"' in body_html
282+
283+
await generator.build()
284+
285+
stub = generator.stubs[1]
286+
assert stub.output is not None
287+
assert "Hello, IR!" in stub.output.data
288+
289+
290+
def test_stubs_returns_read_only_snapshot() -> None:
291+
generator = MarimoIslandGenerator()
292+
generator.add_code("x = 1")
293+
294+
stubs = generator.stubs
295+
assert len(stubs) == 1
296+
assert stubs[0].code == "x = 1"
297+
298+
generator.add_code("y = 2")
299+
assert len(stubs) == 1
300+
assert len(generator.stubs) == 2
301+
302+
303+
def test_from_ir_type_hints_resolve() -> None:
304+
hints = get_type_hints(MarimoIslandGenerator._from_ir)
305+
306+
assert hints["notebook"] is NotebookSerialization
307+
assert hints["return"] is MarimoIslandGenerator
308+
309+
310+
def test_from_ir_applies_display_code_to_stubs() -> None:
311+
notebook = _notebook([CellDef(code="x = 1")])
312+
313+
generator = MarimoIslandGenerator._from_ir(
314+
notebook,
315+
display_code=True,
316+
)
317+
318+
html = generator.stubs[0].render()
319+
assert "<marimo-cell-code hidden>" not in html
320+
assert "x = 1" in html
321+
322+
323+
def test_from_ir_preserves_app_config() -> None:
324+
notebook = _notebook(
325+
[],
326+
options={"width": "medium", "app_title": "IR App"},
327+
)
328+
329+
generator = MarimoIslandGenerator._from_ir(notebook)
330+
331+
body_html = generator.render_body()
332+
assert 'style="margin: auto; max-width: 1110px;"' in body_html
333+
334+
html = generator.render_html()
335+
assert "<title> IR App </title>" in html
336+
337+
338+
def test_from_ir_sanitizes_markdown_app_config() -> None:
339+
notebook = _notebook(
340+
[],
341+
options={"width": "medium", "author": "Marimo"},
342+
filename="notebook.md",
343+
)
344+
345+
generator = MarimoIslandGenerator._from_ir(notebook)
346+
347+
body_html = generator.render_body()
348+
assert 'style="margin: auto; max-width: 1110px;"' in body_html
349+
350+
351+
async def test_from_ir_propagates_ir_filename_to_cells(tmp_path: Path) -> None:
352+
notebook_file = tmp_path / "nb.py"
353+
notebook_file.write_text("")
354+
notebook = _notebook(
355+
[
356+
CellDef(code="import marimo as mo"),
357+
CellDef(
358+
code=('mo.md(f"FILE={__file__} | DIR={mo.notebook_dir()}")')
359+
),
360+
],
361+
filename=str(notebook_file),
362+
)
363+
364+
generator = MarimoIslandGenerator._from_ir(notebook)
365+
await generator.build()
366+
367+
captured = generator.stubs[1].output
368+
assert captured is not None
369+
data = captured.data
370+
assert str(notebook_file) in data, (
371+
f"expected __file__ to resolve to {notebook_file}, got: {data}"
372+
)
373+
assert str(notebook_file.parent) in data, (
374+
f"expected notebook_dir() to resolve to {notebook_file.parent}, "
375+
f"got: {data}"
376+
)
377+
378+
379+
async def test_from_ir_resolves_relative_path_at_capture_time(
380+
tmp_path: Path,
381+
) -> None:
382+
import os
383+
384+
notebook_dir = tmp_path / "notebooks"
385+
notebook_dir.mkdir()
386+
notebook_file = notebook_dir / "nb.py"
387+
notebook_file.write_text("")
388+
notebook = _notebook(
389+
[
390+
CellDef(code="import marimo as mo"),
391+
CellDef(code='mo.md(f"FILE={__file__}")'),
392+
],
393+
)
394+
other_dir = tmp_path / "elsewhere"
395+
other_dir.mkdir()
396+
397+
original_cwd = os.getcwd()
398+
try:
399+
os.chdir(notebook_dir)
400+
generator = MarimoIslandGenerator._from_ir(notebook, filepath="nb.py")
401+
os.chdir(other_dir)
402+
await generator.build()
403+
finally:
404+
os.chdir(original_cwd)
405+
406+
captured = generator.stubs[1].output
407+
assert captured is not None
408+
data = captured.data
409+
assert str(notebook_file) in data, (
410+
f"expected __file__ to resolve to {notebook_file}, got: {data}"
411+
)
412+
assert str(other_dir / "nb.py") not in data
413+
414+
415+
async def test_from_ir_filepath_overrides_ir_filename(
416+
tmp_path: Path,
417+
) -> None:
418+
ir_file = tmp_path / "ir.py"
419+
source_file = tmp_path / "source.ipynb"
420+
ir_file.write_text("")
421+
source_file.write_text("")
422+
notebook = _notebook(
423+
[
424+
CellDef(code="import marimo as mo"),
425+
CellDef(
426+
code=('mo.md(f"FILE={__file__} | DIR={mo.notebook_dir()}")')
427+
),
428+
],
429+
filename=str(ir_file),
430+
)
431+
432+
generator = MarimoIslandGenerator._from_ir(
433+
notebook, filepath=str(source_file)
434+
)
435+
await generator.build()
436+
437+
captured = generator.stubs[1].output
438+
assert captured is not None
439+
data = captured.data
440+
assert str(source_file) in data, (
441+
f"expected __file__ to resolve to {source_file}, got: {data}"
442+
)
443+
assert str(source_file.parent) in data, (
444+
f"expected notebook_dir() to resolve to {source_file.parent}, "
445+
f"got: {data}"
446+
)
447+
assert str(ir_file) not in data
448+
449+
450+
async def test_from_ir_preserves_disabled_cell_config() -> None:
451+
notebook = _notebook(
452+
[
453+
CellDef(code="'disabled output'", options={"disabled": True}),
454+
CellDef(code="'active output'"),
455+
],
456+
)
457+
458+
generator = MarimoIslandGenerator._from_ir(notebook)
459+
460+
body_html = generator.render_body(include_init_island=False)
461+
assert 'data-reactive="false"' in body_html
462+
assert 'data-reactive="true"' in body_html
463+
464+
await generator.build()
465+
466+
disabled_stub = generator.stubs[0]
467+
active_stub = generator.stubs[1]
468+
assert disabled_stub.output is None
469+
assert active_stub.output is not None
470+
assert "active output" in active_stub.output.data
471+
472+
473+
async def test_from_ir_marks_transitively_disabled_cells_static() -> None:
474+
notebook = _notebook(
475+
[
476+
CellDef(code="x = 1", options={"disabled": True}),
477+
CellDef(code="x + 1"),
478+
],
479+
)
480+
481+
generator = MarimoIslandGenerator._from_ir(notebook)
482+
483+
rendered = [stub.render() for stub in generator.stubs]
484+
assert all('data-reactive="false"' in html for html in rendered)
485+
assert "x%20%2B%201" not in rendered[1]
486+
487+
await generator.build()
488+
489+
assert generator.stubs[0].output is None
490+
assert generator.stubs[1].output is None
491+
492+
246493
def test_render_head():
247494
generator = MarimoIslandGenerator()
248495
head_html = generator.render_head()

0 commit comments

Comments
 (0)