Skip to content

Commit

Permalink
feat: marimo export ipynb (#1367)
Browse files Browse the repository at this point in the history
* feat: marimo export ipynb

Export marimo notebooks to Jupyter (ipynb) notebooks.

This is a pretty-bare bones implementation that doesn't convert
markdown or create/save outputs. Still, I had a need for this today --
more convenient than copy-pasting, and didn't want to go through VSCode.

* fix comments

* fix test

* support markdown cells

* type ignore ...

* update snapshots

* update snapshots

* dont check snapshot equality for ipynb

* fix nondeterminism in snapshot ...

* lint

* provide ids to exported ipynb
  • Loading branch information
akshayka committed May 15, 2024
1 parent ffe5b97 commit 4cb3069
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 8 deletions.
28 changes: 22 additions & 6 deletions docs/guides/exporting.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Exporting

Export marimo notebooks to other file formats at the command line using

```
marimo export
```

## Export to static HTML

Export the current view your notebook to static HTML via the notebook
Expand All @@ -26,8 +32,8 @@ marimo export html notebook.py -o notebook.html --watch

## Export to a Python script

Export the notebook to a flat Python script at the command-line.
This exports the notebook in topological order, so the cells adhere to their dependency graph.
Export to a flat Python script in topological order, so the cells adhere to
their dependency graph.

```bash
marimo export script notebook.py -o notebook.script.py
Expand All @@ -36,14 +42,15 @@ marimo export script notebook.py -o notebook.script.py
```{admonition} Top-level await not supported
:class: warning
Exporting to a flat Python script does not support top-level await.
If you have top-level await in your notebook, you can still execute the notebook as a script with `python notebook.py`.
Exporting to a flat Python script does not support top-level await. If you have
top-level await in your notebook, you can still execute the notebook as a
script with `python notebook.py`.
```

## Export to markdown

Export the notebook to markdown at the command-line.
This exports the notebook in top to bottom order, so the cells are in the order as they appear in the notebook.
Export to markdown notebook in top to bottom order, so the cells are in the
order as they appear in the notebook.

```bash
marimo export md notebook.py -o notebook.md
Expand All @@ -56,3 +63,12 @@ You can also convert the markdown back to a marimo notebook:
```bash
marimo convert notebook.md > notebook.py
```

## Export to Jupyter notebook

Export to Jupyter notebook in topological order, so the cells adhere to
their dependency graph.

```bash
marimo export ipynb notebook.py -o notebook.ipynb
```
61 changes: 61 additions & 0 deletions marimo/_cli/export/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
from __future__ import annotations

import asyncio
import importlib.util
from typing import TYPE_CHECKING, Callable

import click

from marimo._cli.parse_args import parse_args
from marimo._cli.print import green
from marimo._server.export import (
export_as_ipynb,
export_as_md,
export_as_script,
run_app_then_export_as_html,
Expand Down Expand Up @@ -252,6 +254,65 @@ def export_callback(file_path: MarimoPath) -> str:
return watch_and_export(MarimoPath(name), output, watch, export_callback)


@click.command(
help="""
Export a marimo notebook as a Jupyter notebook in topological order.
Example:
\b
* marimo export ipynb notebook.py -o notebook.ipynb
Watch for changes and regenerate the script on modification:
\b
* marimo export ipynb notebook.py -o notebook.ipynb --watch
Requires nbformat to be installed.
"""
)
@click.option(
"--watch/--no-watch",
default=False,
show_default=True,
type=bool,
help="""
Watch notebook for changes and regenerate the ipynb on modification.
If watchdog is installed, it will be used to watch the file.
Otherwise, file watcher will poll the file every 1s.
""",
)
@click.option(
"-o",
"--output",
type=str,
default=None,
help="""
Output file to save the ipynb file to. If not provided, the ipynb contents
will be printed to stdout.
""",
)
@click.argument("name", required=True)
def ipynb(
name: str,
output: str,
watch: bool,
) -> None:
"""
Export a marimo notebook as a Jupyter notebook in topological order.
"""

def export_callback(file_path: MarimoPath) -> str:
return export_as_ipynb(file_path)[0]

if importlib.util.find_spec("nbformat") is None:
raise ModuleNotFoundError(
"Install `nbformat` from PyPI to use marimo export ipynb"
)
return watch_and_export(MarimoPath(name), output, watch, export_callback)


export.add_command(html)
export.add_command(script)
export.add_command(md)
export.add_command(ipynb)
7 changes: 6 additions & 1 deletion marimo/_dependencies/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def has_plotly() -> bool:

@staticmethod
def has_matplotlib() -> bool:
"""Return True if plotly is installed."""
"""Return True if matplotlib is installed."""
return importlib.util.find_spec("matplotlib") is not None

@staticmethod
Expand All @@ -161,3 +161,8 @@ def has_watchdog() -> bool:
def has_ipython() -> bool:
"""Return True if IPython is installed."""
return importlib.util.find_spec("IPython") is not None

@staticmethod
def has_nbformat() -> bool:
"""Return True if nbformat is installed."""
return importlib.util.find_spec("nbformat") is not None
1 change: 1 addition & 0 deletions marimo/_server/api/endpoints/login.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Copyright 2024 Marimo. All rights reserved.
from __future__ import annotations

from typing import TYPE_CHECKING
Expand Down
11 changes: 11 additions & 0 deletions marimo/_server/export/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ def export_as_md(
return Exporter().export_as_md(file_manager)


def export_as_ipynb(
path: MarimoPath,
) -> tuple[str, str]:
file_router = AppFileRouter.from_filename(path)
file_key = file_router.get_unique_file_key()
assert file_key is not None
file_manager = file_router.get_file_manager(file_key)

return Exporter().export_as_ipynb(file_manager)


async def run_app_then_export_as_html(
path: MarimoPath,
include_code: bool,
Expand Down
32 changes: 32 additions & 0 deletions marimo/_server/export/exporter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Copyright 2024 Marimo. All rights reserved.
from __future__ import annotations

import io
import mimetypes
import os
from typing import cast

from marimo import __version__
from marimo._ast.cell import Cell, CellImpl
from marimo._config.config import (
DEFAULT_CONFIG,
DisplayConfig,
Expand Down Expand Up @@ -119,6 +121,36 @@ def export_as_script(
download_filename = get_download_filename(file_manager, ".script.py")
return code, download_filename

def export_as_ipynb(
self,
file_manager: AppFileManager,
) -> tuple[str, str]:
import nbformat # type: ignore

def create_notebook_cell(cell: CellImpl) -> nbformat.NotebookNode:
markdown_string = get_markdown_from_cell(
Cell(_name="__", _cell=cell), cell.code
)
if markdown_string is not None:
return nbformat.v4.new_markdown_cell( # type: ignore
markdown_string, id=cell.cell_id
)
else:
return nbformat.v4.new_code_cell(cell.code, id=cell.cell_id) # type: ignore

notebook = nbformat.v4.new_notebook() # type: ignore
graph = file_manager.app.graph
notebook["cells"] = [
create_notebook_cell(graph.cells[cid]) # type: ignore
for cid in dataflow.topological_sort(graph, graph.cells.keys())
]

stream = io.StringIO()
nbformat.write(notebook, stream) # type: ignore
stream.seek(0)
download_filename = get_download_filename(file_manager, ".ipynb")
return stream.read(), download_filename

def export_as_md(self, file_manager: AppFileManager) -> tuple[str, str]:
import yaml

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ testoptional = [
"anywidget~=0.9.3",
"ipython~=8.12.3",
"openai~=1.12.0",
# exporting as ipynb
"nbformat >= 5.0.0",
]

[project.urls]
Expand Down
36 changes: 36 additions & 0 deletions tests/_cli/snapshots/ipynb.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "Hbol",
"metadata": {},
"outputs": [],
"source": [
"import marimo as mo"
]
},
{
"cell_type": "markdown",
"id": "MJUe",
"metadata": {},
"source": [
"markdown"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "vblA",
"metadata": {},
"outputs": [],
"source": [
"mo.md(f\"parametrized markdown {123}\")"
]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}

16 changes: 16 additions & 0 deletions tests/_cli/test_cli_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,19 @@ def test_export_watch_script_no_out_dir(
in line
)
break


class TestExportIpynb:
@pytest.mark.skipif(
not DependencyManager.has_nbformat(),
reason="This test requires nbformat.",
)
def test_export_ipynb(self, temp_marimo_file_with_md: str) -> None:
p = subprocess.run(
["marimo", "export", "ipynb", temp_marimo_file_with_md],
capture_output=True,
)
assert p.returncode == 0, p.stderr.decode()
output = p.stdout.decode()
# ipynb has non-deterministic ids
snapshot("ipynb.txt", output)
38 changes: 38 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,44 @@ def __():
tmp_dir.cleanup()


@pytest.fixture
def temp_marimo_file_with_md() -> Generator[str, None, None]:
tmp_dir = TemporaryDirectory()
tmp_file = tmp_dir.name + "/notebook.py"
content = inspect.cleandoc(
"""
import marimo
app = marimo.App()
@app.cell
def __():
import marimo as mo
return mo,
@app.cell
def __(mo):
mo.md("markdown")
return
@app.cell
def __(mo):
mo.md(f"parametrized markdown {123}")
return
if __name__ == "__main__":
app.run()
"""
)

try:
with open(tmp_file, "w") as f:
f.write(content)
yield tmp_file
finally:
tmp_dir.cleanup()


# Factory to create ExecutionRequests and abstract away cell ID
class ExecReqProvider:
def __init__(self) -> None:
Expand Down
1 change: 0 additions & 1 deletion tests/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ def normalize(string: str) -> str:
)
)
)

assert result == expected, f"Snapshot differs:\n{text_diff}"

return snapshot

0 comments on commit 4cb3069

Please sign in to comment.