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

feat: marimo export ipynb #1367

Merged
merged 11 commits into from
May 15, 2024
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
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"] = [
akshayka marked this conversation as resolved.
Show resolved Hide resolved
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
Loading