Skip to content

Commit da7a499

Browse files
authored
improvement: marimo export ipynb with outputs (#3048)
## 📝 Summary add `--include-outputs` for marimo export. The goal for this is to export to ipynb so they can be committed to git and viewable in GitHub. GitHub doesn't allow scripts/styles that are remote (they get sanitized), so web-components or widgets (including anywidgets) do not get shown. Instead, we will output markdown as that will be best rendered in GitHub's notebook renderer. e.g. ```bash # with outputs marimo export ipynb notebook.py -o notebook.ipynb --include-outputs # without outputs (default) marimo export ipynb notebook.py -o notebook.ipynb --no-include-outputs ```
1 parent 8edac6e commit da7a499

18 files changed

Lines changed: 663 additions & 64 deletions

File tree

.github/workflows/test_be.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ jobs:
8989
- name: Test with minimal dependencies
9090
if: ${{ matrix.dependencies == 'core' }}
9191
run: |
92-
hatch run +py=${{ matrix.python-version }} test:test -v tests/ -k "not test_cli"
92+
hatch run +py=${{ matrix.python-version }} test:test -v tests/ -k "not test_cli" --durations=10
9393
9494
# Test with optional dependencies
9595
- name: Test with optional dependencies
9696
if: ${{ matrix.dependencies == 'core,optional' }}
9797
run: |
98-
hatch run +py=${{ matrix.python-version }} test-optional:test -v tests/ -k "not test_cli"
98+
hatch run +py=${{ matrix.python-version }} test-optional:test -v tests/ -k "not test_cli" --durations=10

marimo/_cli/export/commands.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
export_as_md,
1515
export_as_script,
1616
run_app_then_export_as_html,
17+
run_app_then_export_as_ipynb,
1718
)
1819
from marimo._server.utils import asyncio_run
1920
from marimo._utils.file_watcher import FileWatcher
@@ -295,18 +296,32 @@ def export_callback(file_path: MarimoPath) -> str:
295296
will be printed to stdout.
296297
""",
297298
)
299+
@click.option(
300+
"--include-outputs/--no-include-outputs",
301+
default=False,
302+
show_default=True,
303+
type=bool,
304+
help="Run the notebook and include outputs in the exported ipynb file.",
305+
)
298306
@click.argument("name", required=True)
299307
def ipynb(
300308
name: str,
301309
output: str,
302310
watch: bool,
303311
sort: Literal["top-down", "topological"],
312+
include_outputs: bool,
304313
) -> None:
305314
"""
306315
Export a marimo notebook as a Jupyter notebook in topological order.
307316
"""
308317

309318
def export_callback(file_path: MarimoPath) -> str:
319+
if include_outputs:
320+
return asyncio_run(
321+
run_app_then_export_as_ipynb(
322+
file_path, sort_mode=sort, cli_args={}
323+
)
324+
)[0]
310325
return export_as_ipynb(file_path, sort_mode=sort)[0]
311326

312327
DependencyManager.nbformat.require(

marimo/_config/manager.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
class UserConfigManager:
2626
def __init__(self, config_path: Optional[str] = None) -> None:
2727
self._config_path = config_path
28-
self.config = load_config()
28+
self._config = load_config()
2929

3030
def save_config(
3131
self, config: MarimoConfig | PartialMarimoConfig
@@ -37,13 +37,13 @@ def save_config(
3737
# Remove the secret placeholders from the incoming config
3838
config = remove_secret_placeholders(config)
3939
# Merge the current config with the new config
40-
merged = merge_config(self.config, config)
40+
merged = merge_config(self._config, config)
4141

4242
with open(config_path, "w", encoding="utf-8") as f:
4343
tomlkit.dump(merged, f)
4444

45-
self.config = merge_default_config(merged)
46-
return self.config
45+
self._config = merge_default_config(merged)
46+
return self._config
4747

4848
def save_config_if_missing(self) -> None:
4949
try:
@@ -55,8 +55,21 @@ def save_config_if_missing(self) -> None:
5555

5656
def get_config(self, hide_secrets: bool = True) -> MarimoConfig:
5757
if hide_secrets:
58-
return mask_secrets(self.config)
59-
return self.config
58+
return mask_secrets(self._config)
59+
return self._config
6060

6161
def get_config_path(self) -> str:
6262
return get_or_create_config_path()
63+
64+
65+
class UserConfigManagerWithOverride(UserConfigManager):
66+
def __init__(
67+
self, delegate: UserConfigManager, override_config: PartialMarimoConfig
68+
) -> None:
69+
self.delegate = delegate
70+
self.override_config = override_config
71+
72+
def get_config(self, hide_secrets: bool = True) -> MarimoConfig:
73+
return merge_config(
74+
self.delegate.get_config(hide_secrets), self.override_config
75+
)

marimo/_output/hypertext.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Copyright 2024 Marimo. All rights reserved.
22
from __future__ import annotations
33

4+
import os
45
import weakref
5-
from typing import TYPE_CHECKING, Any, Literal, Optional, final
6+
from contextlib import contextmanager
7+
from typing import TYPE_CHECKING, Any, Iterator, Literal, Optional, cast, final
68

79
from marimo._messaging.mimetypes import KnownMimeType
810
from marimo._output.mime import MIME
@@ -68,8 +70,6 @@ class Html(MIME):
6870
- `right`: right-justify this element in the output area
6971
"""
7072

71-
_text: str
72-
7373
def __init__(self, text: str) -> None:
7474
"""Initialize the HTML element.
7575
@@ -116,6 +116,21 @@ def text(self) -> str:
116116

117117
@final
118118
def _mime_(self) -> tuple[KnownMimeType, str]:
119+
no_js = os.getenv("MARIMO_NO_JS", "false").lower() == "true"
120+
if no_js and hasattr(self, "_repr_png_"):
121+
return (
122+
"image/png",
123+
cast(
124+
str, cast(Any, self)._repr_png_().decode()
125+
), # ignore[no-untyped-call]
126+
)
127+
if no_js and hasattr(self, "_repr_markdown_"):
128+
return (
129+
"text/markdown",
130+
cast(
131+
str, cast(Any, self)._repr_markdown_()
132+
), # ignore[no-untyped-call]
133+
)
119134
return ("text/html", self.text)
120135

121136
def __format__(self, spec: str) -> str:
@@ -264,3 +279,21 @@ def _repr_html_(self) -> str:
264279
def _js(text: str) -> Html:
265280
# TODO: interpolation of Python values to javascript
266281
return Html("<script>" + text + "</script>")
282+
283+
284+
@contextmanager
285+
def patch_html_for_non_interactive_output() -> Iterator[None]:
286+
"""
287+
Patch Html to return text/markdown for simpler non-interactive outputs,
288+
that can be rendered without JS/CSS (just as in the GitHub viewer).
289+
"""
290+
# HACK: we must set MARIMO_NO_JS since the rendering may happen in another
291+
# thread
292+
# This won't work when we are running a marimo server and are auto-exporting
293+
# with this enabled.
294+
old_no_js = os.getenv("MARIMO_NO_JS", "false")
295+
try:
296+
os.environ["MARIMO_NO_JS"] = "true"
297+
yield
298+
finally:
299+
os.environ["MARIMO_NO_JS"] = old_no_js

marimo/_plugins/ui/_core/ui_element.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import uuid
1111
import weakref
1212
from dataclasses import dataclass, fields
13+
from html import escape
1314
from typing import (
1415
TYPE_CHECKING,
1516
Any,
@@ -528,3 +529,8 @@ def __bool__(self) -> bool:
528529
"probably want to call `.value` instead."
529530
)
530531
return True
532+
533+
def _repr_markdown_(self) -> str:
534+
# When rendering to markdown, remove the marimo-ui-element tag
535+
# and render the inner-text escaped.
536+
return escape(self._inner_text)

marimo/_plugins/ui/_impl/table.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,5 +590,15 @@ def clamp_rows_and_columns(manager: TableManager[Any]) -> JSONType:
590590
total_rows=result.get_num_rows(force=True) or 0,
591591
)
592592

593+
def _repr_markdown_(self) -> str:
594+
"""
595+
Return a markdown representation of the table.
596+
Useful for rendering in the GitHub viewer.
597+
"""
598+
df = self.data
599+
if hasattr(df, "_repr_html_"):
600+
return df._repr_html_() # type: ignore[attr-defined,no-any-return]
601+
return str(df)
602+
593603
def __hash__(self) -> int:
594604
return id(self)

marimo/_server/export/__init__.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
import asyncio
55
from typing import Callable, Literal
66

7-
from marimo._config.manager import UserConfigManager
7+
from marimo._config.manager import (
8+
UserConfigManager,
9+
UserConfigManagerWithOverride,
10+
)
811
from marimo._messaging.ops import MessageOperation
912
from marimo._messaging.types import KernelMessage
13+
from marimo._output.hypertext import patch_html_for_non_interactive_output
1014
from marimo._runtime.requests import AppMetadata, SerializedCLIArgs
1115
from marimo._server.export.exporter import Exporter
1216
from marimo._server.file_manager import AppFileManager
@@ -52,6 +56,24 @@ def export_as_ipynb(
5256
return Exporter().export_as_ipynb(file_manager, sort_mode=sort_mode)
5357

5458

59+
async def run_app_then_export_as_ipynb(
60+
path: MarimoPath,
61+
sort_mode: Literal["top-down", "topological"],
62+
cli_args: SerializedCLIArgs,
63+
) -> tuple[str, str]:
64+
file_router = AppFileRouter.from_filename(path)
65+
file_key = file_router.get_unique_file_key()
66+
assert file_key is not None
67+
file_manager = file_router.get_file_manager(file_key)
68+
69+
with patch_html_for_non_interactive_output():
70+
session_view = await run_app_until_completion(file_manager, cli_args)
71+
72+
return Exporter().export_as_ipynb(
73+
file_manager, sort_mode=sort_mode, session_view=session_view
74+
)
75+
76+
5577
async def run_app_then_export_as_html(
5678
path: MarimoPath,
5779
include_code: bool,
@@ -125,7 +147,16 @@ def write_operation(self, op: MessageOperation) -> None:
125147
def connection_state(self) -> ConnectionState:
126148
return ConnectionState.OPEN
127149

128-
config = UserConfigManager()
150+
config_manager = UserConfigManagerWithOverride(
151+
UserConfigManager(),
152+
{
153+
"runtime": {
154+
"on_cell_change": "autorun",
155+
"auto_instantiate": True,
156+
"auto_reload": "off",
157+
}
158+
},
159+
)
129160

130161
# Create a session
131162
session = Session.create(
@@ -140,7 +171,7 @@ def connection_state(self) -> ConnectionState:
140171
cli_args=cli_args,
141172
),
142173
app_file_manager=file_manager,
143-
user_config_manager=config,
174+
user_config_manager=config_manager,
144175
virtual_files_supported=False,
145176
redirect_console_to_browser=False,
146177
)

0 commit comments

Comments
 (0)