Skip to content

Commit 50428cf

Browse files
authored
feat: export notebooks as PDFs via CLI (#7997)
1 parent fb39e42 commit 50428cf

File tree

4 files changed

+298
-3
lines changed

4 files changed

+298
-3
lines changed

‎marimo/_cli/export/commands.py‎

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from marimo._cli.print import echo, green
1313
from marimo._cli.utils import prompt_to_overwrite
1414
from marimo._dependencies.dependencies import DependencyManager
15+
from marimo._dependencies.errors import ManyModulesNotFoundError
1516
from marimo._server.api.utils import parse_title
1617
from marimo._server.export import (
1718
ExportResult,
@@ -21,6 +22,7 @@
2122
export_as_wasm,
2223
run_app_then_export_as_html,
2324
run_app_then_export_as_ipynb,
25+
run_app_then_export_as_pdf,
2426
)
2527
from marimo._server.export.exporter import Exporter
2628
from marimo._server.utils import asyncio_run
@@ -69,7 +71,7 @@ def write_data(data: str) -> None:
6971

7072
if output:
7173
output_path = Path(output)
72-
if not force:
74+
if not force and not watch:
7375
if not prompt_to_overwrite(output_path):
7476
return
7577

@@ -106,6 +108,92 @@ async def start() -> None:
106108
asyncio_run(start())
107109

108110

111+
def watch_and_export_bytes(
112+
marimo_path: MarimoPath,
113+
output: Path,
114+
watch: bool,
115+
export_callback: Callable[[MarimoPath], tuple[bytes | None, bool]],
116+
force: bool,
117+
) -> None:
118+
if watch and not output:
119+
raise click.UsageError(
120+
"Cannot use --watch without providing "
121+
+ "an output file with --output."
122+
)
123+
124+
output_path = Path(output)
125+
if not force and not watch:
126+
if not prompt_to_overwrite(output_path):
127+
return
128+
129+
def write_data(data: bytes) -> None:
130+
maybe_make_dirs(output_path)
131+
output_path.write_bytes(data)
132+
133+
# No watch, just run once
134+
if not watch:
135+
pdf_bytes, did_error = export_callback(marimo_path)
136+
if pdf_bytes is None:
137+
raise click.ClickException("Failed to export PDF.")
138+
write_data(pdf_bytes)
139+
if did_error:
140+
raise click.ClickException(
141+
"Export was successful, but some cells failed to execute."
142+
)
143+
return
144+
145+
# Watch mode: do an initial export before waiting for changes
146+
pdf_bytes, did_error = export_callback(marimo_path)
147+
if pdf_bytes is None:
148+
raise click.ClickException("Failed to export PDF.")
149+
write_data(pdf_bytes)
150+
if did_error:
151+
echo(
152+
"Warning: Export was successful, but some cells failed to execute.",
153+
err=True,
154+
)
155+
156+
async def on_file_changed(file_path: Path) -> None:
157+
echo(
158+
f"File {str(file_path)} changed. Re-exporting to {green(str(output_path))}"
159+
)
160+
try:
161+
# `export_callback` may call `asyncio_run()` internally. This callback
162+
# runs inside the file watcher's event loop, so we must execute the
163+
# export in a separate thread to avoid `asyncio.run()` nesting.
164+
pdf_bytes, did_error = await asyncio.to_thread(
165+
export_callback, MarimoPath(file_path)
166+
)
167+
except Exception as e:
168+
echo(f"Error: {e}", err=True)
169+
return
170+
171+
if pdf_bytes is None:
172+
echo("Error: Failed to export PDF.", err=True)
173+
return
174+
175+
write_data(pdf_bytes)
176+
if did_error:
177+
echo(
178+
"Warning: Export was successful, but some cells failed to execute.",
179+
err=True,
180+
)
181+
182+
async def start() -> None:
183+
# Watch the file for changes
184+
watcher = FileWatcher.create(marimo_path.path, on_file_changed)
185+
echo(f"Watching {green(marimo_path.relative_name)} for changes...")
186+
watcher.start()
187+
try:
188+
# Run forever
189+
while True: # noqa: ASYNC110
190+
await asyncio.sleep(1)
191+
except KeyboardInterrupt:
192+
watcher.stop()
193+
194+
asyncio_run(start())
195+
196+
109197
@click.command(
110198
help="""Run a notebook and export it as an HTML file.
111199
@@ -478,6 +566,155 @@ def export_callback(file_path: MarimoPath) -> ExportResult:
478566
)
479567

480568

569+
@click.command(
570+
help="""Export a marimo notebook as a PDF file.
571+
572+
Example:
573+
574+
marimo export pdf notebook.py -o notebook.pdf
575+
576+
Optionally pass CLI args to the notebook:
577+
578+
marimo export pdf notebook.py -o notebook.pdf -- -arg1 foo -arg2 bar
579+
580+
Requires nbformat and nbconvert to be installed.
581+
"""
582+
)
583+
@click.option(
584+
"--include-outputs/--no-include-outputs",
585+
default=True,
586+
show_default=True,
587+
type=bool,
588+
help="Run the notebook and include outputs in the exported PDF file.",
589+
)
590+
@click.option(
591+
"--webpdf/--no-webpdf",
592+
default=False,
593+
show_default=True,
594+
type=bool,
595+
help=(
596+
"Use nbconvert's WebPDF exporter (Chromium). If disabled, marimo will "
597+
"try standard PDF export (pandoc + TeX) first and fall back to WebPDF."
598+
),
599+
)
600+
@click.option(
601+
"--watch/--no-watch",
602+
default=False,
603+
show_default=True,
604+
type=bool,
605+
help=_watch_message,
606+
)
607+
@click.option(
608+
"-o",
609+
"--output",
610+
type=click.Path(path_type=Path),
611+
required=True,
612+
help="Output PDF file to save to.",
613+
)
614+
@click.option(
615+
"--sandbox/--no-sandbox",
616+
is_flag=True,
617+
default=None,
618+
show_default=False,
619+
type=bool,
620+
help=_sandbox_message,
621+
)
622+
@click.option(
623+
"-f",
624+
"--force",
625+
is_flag=True,
626+
default=False,
627+
help="Force overwrite of the output file if it already exists.",
628+
)
629+
@click.argument(
630+
"name",
631+
required=True,
632+
type=click.Path(exists=True, file_okay=True, dir_okay=False),
633+
)
634+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
635+
def pdf(
636+
name: str,
637+
output: Path,
638+
watch: bool,
639+
include_outputs: bool,
640+
webpdf: bool,
641+
sandbox: Optional[bool],
642+
force: bool,
643+
args: tuple[str],
644+
) -> None:
645+
"""Run a notebook and export it as a PDF file."""
646+
import sys
647+
648+
if include_outputs:
649+
# Set default, if not provided
650+
if sandbox is None:
651+
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox
652+
653+
sandbox = maybe_prompt_run_in_sandbox(name)
654+
655+
if sandbox:
656+
from marimo._cli.sandbox import run_in_sandbox
657+
658+
export_deps = ["nbformat"]
659+
# Adding webpdf extras to sandbox even if `webpdf` is False, since standard PDF export may fall back to it.
660+
export_deps.append("nbconvert[webpdf]")
661+
run_in_sandbox(
662+
sys.argv[1:],
663+
name=name,
664+
additional_deps=export_deps,
665+
)
666+
return
667+
668+
try:
669+
DependencyManager.require_many(
670+
"for PDF export",
671+
DependencyManager.nbformat,
672+
DependencyManager.nbconvert,
673+
)
674+
except ManyModulesNotFoundError as e:
675+
from marimo._cli.print import bold
676+
677+
pkgs = " ".join(e.package_names)
678+
raise click.ClickException(
679+
f"{e}\n\n"
680+
f" {green('Tip:')} Install with:\n\n"
681+
f" pip install {pkgs}\n\n"
682+
f" or rerun with {bold(f'marimo export pdf {name} --output {output} --sandbox')} (requires uv)"
683+
) from None
684+
685+
cli_args = parse_args(args) if include_outputs else {}
686+
687+
def export_callback(
688+
file_path: MarimoPath,
689+
) -> tuple[bytes | None, bool]:
690+
try:
691+
return asyncio_run(
692+
run_app_then_export_as_pdf(
693+
file_path,
694+
include_outputs=include_outputs,
695+
webpdf=webpdf,
696+
cli_args=cli_args,
697+
argv=list(args) if include_outputs else None,
698+
)
699+
)
700+
except ModuleNotFoundError as e:
701+
if getattr(e, "name", None) == "playwright":
702+
raise click.ClickException(
703+
"Playwright is required for WebPDF export.\n\n"
704+
f" {green('Tip:')} Install webpdf dependencies with:\n\n"
705+
" pip install 'nbconvert[webpdf]'\n\n"
706+
" and install Chromium with:\n\n"
707+
" python -m playwright install chromium"
708+
) from None
709+
raise
710+
except Exception as e:
711+
raise click.ClickException(f"Failed to export PDF: {e}") from None
712+
713+
return watch_and_export_bytes(
714+
MarimoPath(name), output, watch, export_callback, force
715+
)
716+
717+
481718
@click.command(
482719
help="""Export a notebook as a WASM-powered standalone HTML file.
483720
@@ -634,4 +871,5 @@ def export_callback(file_path: MarimoPath) -> ExportResult:
634871
export.add_command(script)
635872
export.add_command(md)
636873
export.add_command(ipynb)
874+
export.add_command(pdf)
637875
export.add_command(html_wasm)

‎marimo/_server/export/__init__.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,41 @@ async def run_app_then_export_as_ipynb(
190190
)
191191

192192

193+
async def run_app_then_export_as_pdf(
194+
filepath: MarimoPath,
195+
*,
196+
include_outputs: bool,
197+
webpdf: bool,
198+
cli_args: SerializedCLIArgs,
199+
argv: list[str] | None,
200+
) -> tuple[bytes | None, bool]:
201+
file_router = AppFileRouter.from_filename(filepath)
202+
file_key = file_router.get_unique_file_key()
203+
assert file_key is not None
204+
file_manager = file_router.get_file_manager(file_key)
205+
206+
session_view: SessionView | None = None
207+
did_error = False
208+
209+
if include_outputs:
210+
with patch_html_for_non_interactive_output():
211+
# Using quiet=True to suppress runtime stdout/stderr since outputs
212+
# are captured in the session_view and will be included in the PDF
213+
(session_view, did_error) = await run_app_until_completion(
214+
file_manager,
215+
cli_args,
216+
argv,
217+
quiet=True,
218+
)
219+
220+
pdf_data = Exporter().export_as_pdf(
221+
app=file_manager.app,
222+
session_view=session_view,
223+
webpdf=webpdf,
224+
)
225+
return pdf_data, did_error
226+
227+
193228
async def run_app_then_export_as_html(
194229
path: MarimoPath,
195230
include_code: bool,

‎marimo/_server/export/exporter.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,17 @@ def export_as_wasm(
307307
return html, download_filename
308308

309309
def export_as_pdf(
310-
self, *, app: InternalApp, session_view: SessionView, webpdf: bool
310+
self,
311+
*,
312+
app: InternalApp,
313+
session_view: SessionView | None,
314+
webpdf: bool,
311315
) -> bytes | None:
312316
"""Export notebook as a PDF.
313317
314318
Args:
315319
app: The app to export
316-
session_view: The session view to export
320+
session_view: The session view to export. If None, outputs are not included.
317321
webpdf: If False, tries standard PDF export (pandoc + TeX) first,
318322
falling back to webpdf if deps are not installed. If True, uses webpdf
319323
directly.

‎tests/_cli/test_cli_export.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,3 +842,21 @@ def test_cli_export_ipynb_sandbox_no_outputs(
842842
def test_cli_export_html_sandbox_no_prompt(temp_marimo_file: str) -> None:
843843
p = _run_export("html", temp_marimo_file, "--no-sandbox")
844844
_assert_success(p)
845+
846+
847+
class TestExportPDF:
848+
@pytest.mark.skipif(
849+
DependencyManager.nbformat.has() and DependencyManager.nbconvert.has(),
850+
reason="This test expects PDF export deps to be missing.",
851+
)
852+
def test_export_pdf_missing_dependencies(
853+
self, temp_marimo_file: str
854+
) -> None:
855+
output_file = temp_marimo_file.replace(".py", ".pdf")
856+
p = _run_export(
857+
"pdf", temp_marimo_file, "--output", output_file, "--no-sandbox"
858+
)
859+
_assert_failure(p)
860+
stderr = p.stderr.decode()
861+
assert "nbconvert" in stderr
862+
assert "pip install" in stderr

0 commit comments

Comments
 (0)