|
12 | 12 | from marimo._cli.print import echo, green |
13 | 13 | from marimo._cli.utils import prompt_to_overwrite |
14 | 14 | from marimo._dependencies.dependencies import DependencyManager |
| 15 | +from marimo._dependencies.errors import ManyModulesNotFoundError |
15 | 16 | from marimo._server.api.utils import parse_title |
16 | 17 | from marimo._server.export import ( |
17 | 18 | ExportResult, |
|
21 | 22 | export_as_wasm, |
22 | 23 | run_app_then_export_as_html, |
23 | 24 | run_app_then_export_as_ipynb, |
| 25 | + run_app_then_export_as_pdf, |
24 | 26 | ) |
25 | 27 | from marimo._server.export.exporter import Exporter |
26 | 28 | from marimo._server.utils import asyncio_run |
@@ -69,7 +71,7 @@ def write_data(data: str) -> None: |
69 | 71 |
|
70 | 72 | if output: |
71 | 73 | output_path = Path(output) |
72 | | - if not force: |
| 74 | + if not force and not watch: |
73 | 75 | if not prompt_to_overwrite(output_path): |
74 | 76 | return |
75 | 77 |
|
@@ -106,6 +108,92 @@ async def start() -> None: |
106 | 108 | asyncio_run(start()) |
107 | 109 |
|
108 | 110 |
|
| 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 | + |
109 | 197 | @click.command( |
110 | 198 | help="""Run a notebook and export it as an HTML file. |
111 | 199 |
|
@@ -478,6 +566,155 @@ def export_callback(file_path: MarimoPath) -> ExportResult: |
478 | 566 | ) |
479 | 567 |
|
480 | 568 |
|
| 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 | + |
481 | 718 | @click.command( |
482 | 719 | help="""Export a notebook as a WASM-powered standalone HTML file. |
483 | 720 |
|
@@ -634,4 +871,5 @@ def export_callback(file_path: MarimoPath) -> ExportResult: |
634 | 871 | export.add_command(script) |
635 | 872 | export.add_command(md) |
636 | 873 | export.add_command(ipynb) |
| 874 | +export.add_command(pdf) |
637 | 875 | export.add_command(html_wasm) |
0 commit comments