Coverage for src / marimushka / export.py: 100%
153 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-27 07:24 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-27 07:24 +0000
1"""Export module for building and deploying marimo notebooks.
3This module provides the CLI interface and core export functionality for
4converting marimo notebooks into deployable HTML/WebAssembly format. It
5orchestrates the export process, handles template rendering, and generates
6an index page that serves as a navigation hub for all exported notebooks.
8Architecture:
9 The export flow follows this path:
10 cli() → app() (Typer) → main() → _main_impl() → _generate_index()
12 - cli(): Entry point that invokes the Typer application
13 - app: Typer application with subcommands (export, watch, version)
14 - main(): Public Python API with sensible defaults
15 - _main_impl(): Implementation with logging and orchestration
16 - _generate_index(): Core export logic with template rendering
18CLI Commands:
19 - export: Export notebooks to HTML/WASM and generate index page
20 - watch: Monitor directories and auto-export on file changes
21 - version: Display the current marimushka version
23Parallel Export:
24 The module supports parallel notebook export using ThreadPoolExecutor.
25 By default, it uses 4 worker threads, configurable via --max-workers.
26 Progress is displayed using Rich progress bars.
28Template System:
29 Index pages are generated using Jinja2 templates. Templates receive
30 three lists (notebooks, apps, notebooks_wasm) where each item has:
31 - display_name: Human-readable name (underscores converted to spaces)
32 - html_path: Relative path to the exported HTML file
33 - path: Original .py file path
34 - kind: The notebook type (Kind enum)
36Example::
38 # CLI usage
39 $ marimushka export --notebooks notebooks --apps apps --output _site
41 # Python API
42 from marimushka.export import main
43 main(notebooks="notebooks", apps="apps", output="_site")
45The exported files will be placed in the specified output directory (default: _site).
46"""
48# /// script
49# requires-python = ">=3.11"
50# dependencies = [
51# "jinja2>=3.1.6",
52# "typer>=0.16.0",
53# "loguru>=0.7.3",
54# "rich>=14.0.0",
55# ]
56# ///
58import sys
59from concurrent.futures import ThreadPoolExecutor, as_completed
60from pathlib import Path
62import jinja2
63import typer
64from loguru import logger
65from rich import print as rich_print
66from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TaskProgressColumn, TextColumn
68from . import __version__
69from .exceptions import (
70 BatchExportResult,
71 IndexWriteError,
72 NotebookExportResult,
73 TemplateInvalidError,
74 TemplateNotFoundError,
75 TemplateRenderError,
76)
77from .notebook import Kind, Notebook, folder2notebooks
79# Maximum number of changed files to display in watch mode
80_MAX_CHANGED_FILES_TO_DISPLAY = 5
82# Configure logger
83logger.configure(extra={"subprocess": ""})
84logger.remove()
85logger.add(
86 sys.stderr,
87 format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:"
88 "<cyan>{function}</cyan>:<cyan>{line}</cyan> | <magenta>{extra[subprocess]}</magenta><level>{message}</level>",
89)
91app = typer.Typer(help=f"Marimushka - Export marimo notebooks in style. Version: {__version__}")
94def _validate_template(template_path: Path) -> None:
95 """Validate the template file exists and has correct extension.
97 Args:
98 template_path: Path to the template file.
100 Raises:
101 TemplateNotFoundError: If the template file does not exist.
102 TemplateInvalidError: If the template path is not a file.
104 """
105 if not template_path.exists():
106 raise TemplateNotFoundError(template_path)
107 if not template_path.is_file():
108 raise TemplateInvalidError(template_path, reason="path is not a file")
109 if template_path.suffix not in (".j2", ".jinja2"):
110 logger.warning(f"Template file '{template_path}' does not have .j2 or .jinja2 extension")
113def _export_notebook(
114 notebook: Notebook,
115 output_dir: Path,
116 sandbox: bool,
117 bin_path: Path | None,
118) -> NotebookExportResult:
119 """Export a single notebook and return the result.
121 Args:
122 notebook: The notebook to export.
123 output_dir: Output directory for the exported HTML.
124 sandbox: Whether to use sandbox mode.
125 bin_path: Custom path to uvx executable.
127 Returns:
128 NotebookExportResult with success status and details.
130 """
131 return notebook.export(output_dir=output_dir, sandbox=sandbox, bin_path=bin_path)
134def _export_notebooks_parallel(
135 notebooks: list[Notebook],
136 output_dir: Path,
137 sandbox: bool,
138 bin_path: Path | None,
139 max_workers: int = 4,
140 progress: Progress | None = None,
141 task_id: TaskID | None = None,
142) -> BatchExportResult:
143 """Export notebooks in parallel using a thread pool.
145 Args:
146 notebooks: List of notebooks to export.
147 output_dir: Output directory for exported HTML files.
148 sandbox: Whether to use sandbox mode.
149 bin_path: Custom path to uvx executable.
150 max_workers: Maximum number of parallel workers. Defaults to 4.
151 progress: Optional Rich Progress instance for progress tracking.
152 task_id: Optional task ID for progress updates.
154 Returns:
155 BatchExportResult containing individual results and summary statistics.
157 """
158 batch_result = BatchExportResult()
160 if not notebooks:
161 return batch_result
163 with ThreadPoolExecutor(max_workers=max_workers) as executor:
164 futures = {executor.submit(_export_notebook, nb, output_dir, sandbox, bin_path): nb for nb in notebooks}
166 for future in as_completed(futures):
167 result = future.result()
168 batch_result.add(result)
170 if not result.success:
171 error_msg = str(result.error) if result.error else "Unknown error"
172 logger.error(f"Failed to export {result.notebook_path.name}: {error_msg}")
174 if progress and task_id is not None:
175 progress.advance(task_id)
177 return batch_result
180@app.callback(invoke_without_command=True) # type: ignore[untyped-decorator]
181def callback(ctx: typer.Context) -> None:
182 """Handle the CLI invocation without a subcommand.
184 This callback runs before any subcommand and displays help text if the
185 user invokes marimushka without specifying a subcommand (e.g., just
186 `marimushka` instead of `marimushka export`).
188 Args:
189 ctx: Typer context containing invocation information, including
190 which subcommand (if any) was specified.
192 Raises:
193 typer.Exit: Always raised when no subcommand is provided, with
194 exit code 0 to indicate this is expected behavior.
196 """
197 if ctx.invoked_subcommand is None:
198 print(ctx.get_help())
199 raise typer.Exit()
202def _generate_index(
203 output: Path,
204 template_file: Path,
205 notebooks: list[Notebook] | None = None,
206 apps: list[Notebook] | None = None,
207 notebooks_wasm: list[Notebook] | None = None,
208 sandbox: bool = True,
209 bin_path: Path | None = None,
210 parallel: bool = True,
211 max_workers: int = 4,
212) -> str:
213 """Generate an index.html file that lists all the notebooks.
215 This function creates an HTML index page that displays links to all the exported
216 notebooks. The index page includes the marimo logo and displays each notebook
217 with a formatted title and a link to open it.
219 Args:
220 output: Directory where the index.html file will be saved.
221 template_file: Path to the Jinja2 template file.
222 notebooks: List of notebooks for static HTML export.
223 apps: List of notebooks for app export.
224 notebooks_wasm: List of notebooks for interactive WebAssembly export.
225 sandbox: Whether to run the notebook in a sandbox. Defaults to True.
226 bin_path: The directory where the executable is located. Defaults to None.
227 parallel: Whether to export notebooks in parallel. Defaults to True.
228 max_workers: Maximum number of parallel workers. Defaults to 4.
230 Returns:
231 The rendered HTML content as a string.
233 Raises:
234 TemplateRenderError: If the template fails to render.
235 IndexWriteError: If the index file cannot be written.
237 """
238 # Initialize empty lists if None is provided
239 notebooks = notebooks or []
240 apps = apps or []
241 notebooks_wasm = notebooks_wasm or []
243 total_notebooks = len(notebooks) + len(apps) + len(notebooks_wasm)
244 combined_batch_result = BatchExportResult()
246 if total_notebooks > 0:
247 with Progress(
248 SpinnerColumn(),
249 TextColumn("[progress.description]{task.description}"),
250 BarColumn(),
251 TaskProgressColumn(),
252 TextColumn("[cyan]{task.completed}/{task.total}"),
253 ) as progress:
254 # Create progress tasks for each category
255 if parallel:
256 # Parallel export with combined progress
257 task = progress.add_task("[green]Exporting notebooks...", total=total_notebooks)
259 all_notebooks = [
260 (notebooks, output / "notebooks"),
261 (apps, output / "apps"),
262 (notebooks_wasm, output / "notebooks_wasm"),
263 ]
265 for nb_list, out_dir in all_notebooks:
266 if nb_list:
267 batch_result = _export_notebooks_parallel(
268 nb_list, out_dir, sandbox, bin_path, max_workers, progress, task
269 )
270 for result in batch_result.results:
271 combined_batch_result.add(result)
273 if combined_batch_result.failed > 0: # pragma: no cover
274 logger.warning(
275 f"Export completed: {combined_batch_result.succeeded} succeeded, "
276 f"{combined_batch_result.failed} failed"
277 )
278 for failure in combined_batch_result.failures:
279 error_detail = str(failure.error) if failure.error else "Unknown error"
280 logger.debug(f" - {failure.notebook_path.name}: {error_detail}")
281 else:
282 # Sequential export with progress bar
283 task = progress.add_task("[green]Exporting notebooks...", total=total_notebooks)
285 for nb in notebooks:
286 result = nb.export(output_dir=output / "notebooks", sandbox=sandbox, bin_path=bin_path)
287 combined_batch_result.add(result)
288 progress.advance(task)
290 for nb in apps:
291 result = nb.export(output_dir=output / "apps", sandbox=sandbox, bin_path=bin_path)
292 combined_batch_result.add(result)
293 progress.advance(task)
295 for nb in notebooks_wasm:
296 result = nb.export(output_dir=output / "notebooks_wasm", sandbox=sandbox, bin_path=bin_path)
297 combined_batch_result.add(result)
298 progress.advance(task)
300 if combined_batch_result.failed > 0: # pragma: no cover
301 logger.warning(
302 f"Export completed: {combined_batch_result.succeeded} succeeded, "
303 f"{combined_batch_result.failed} failed"
304 )
306 # Create the full path for the index.html file
307 index_path: Path = Path(output) / "index.html"
309 # Ensure the output directory exists
310 Path(output).mkdir(parents=True, exist_ok=True)
312 # Set up Jinja2 environment and load template
313 template_dir = template_file.parent
314 template_name = template_file.name
316 try:
317 # Create Jinja2 environment and load template
318 env = jinja2.Environment(
319 loader=jinja2.FileSystemLoader(template_dir), autoescape=jinja2.select_autoescape(["html", "xml"])
320 )
321 template = env.get_template(template_name)
323 # Render the template with notebook and app data
324 rendered_html: str = template.render(
325 notebooks=notebooks,
326 apps=apps,
327 notebooks_wasm=notebooks_wasm,
328 )
329 except jinja2.exceptions.TemplateError as e:
330 raise TemplateRenderError(template_file, e) from e
332 # Write the rendered HTML to the index.html file
333 try:
334 with Path.open(index_path, "w") as f:
335 f.write(rendered_html)
336 logger.info(f"Successfully generated index file at {index_path}")
337 except OSError as e:
338 raise IndexWriteError(index_path, e) from e
340 return rendered_html
343def _main_impl(
344 output: str | Path,
345 template: str | Path,
346 notebooks: str | Path,
347 apps: str | Path,
348 notebooks_wasm: str | Path,
349 sandbox: bool = True,
350 bin_path: str | Path | None = None,
351 parallel: bool = True,
352 max_workers: int = 4,
353) -> str:
354 """Execute the main export workflow with logging and validation.
356 This is the internal implementation function that contains the actual
357 export logic. It validates inputs, discovers notebooks in each directory,
358 exports them according to their type, and generates the index page.
359 All operations are logged for debugging and monitoring.
361 This function is called by main() (Python API) and _main_typer() (CLI).
362 It separates the implementation from the interface, allowing both the
363 Python API and CLI to share the same core logic.
365 Args:
366 output: Directory where exported files will be saved. Created if
367 it doesn't exist. Subdirectories (notebooks/, apps/, notebooks_wasm/)
368 are created automatically based on content.
369 template: Path to the Jinja2 template file for the index page.
370 Must have .j2 or .jinja2 extension (warning logged otherwise).
371 notebooks: Directory containing marimo notebooks for static HTML export.
372 All .py files in this directory are treated as notebooks.
373 apps: Directory containing marimo notebooks for app-mode export.
374 All .py files are exported with code hidden in run mode.
375 notebooks_wasm: Directory containing marimo notebooks for interactive
376 WebAssembly export with edit mode enabled.
377 sandbox: Whether to run notebooks in sandbox mode during export.
378 Defaults to True for security. Set False only for trusted code.
379 bin_path: Custom directory containing the uvx executable. If None,
380 uvx is located via the system PATH.
381 parallel: Whether to export notebooks in parallel using threads.
382 Defaults to True for performance.
383 max_workers: Maximum number of parallel worker threads. Only used
384 when parallel=True. Defaults to 4.
386 Returns:
387 The rendered HTML content of the index page as a string. Returns
388 an empty string if no notebooks or apps were found.
390 Raises:
391 TemplateNotFoundError: If the template file does not exist.
392 TemplateInvalidError: If the template path is not a valid file.
393 TemplateRenderError: If the template fails to render due to Jinja2 errors.
394 IndexWriteError: If the index.html file cannot be written to disk.
396 """
397 logger.info("Starting marimushka build process")
398 logger.info(f"Version of Marimushka: {__version__}")
399 output = output or "_site"
401 # Convert output_dir explicitly to Path
402 output_dir: Path = Path(output)
403 logger.info(f"Output directory: {output_dir}")
405 # Make sure the output directory exists
406 output_dir.mkdir(parents=True, exist_ok=True)
408 # Convert template to Path and validate early
409 template_file: Path = Path(template)
410 _validate_template(template_file)
412 logger.info(f"Using template file: {template_file}")
413 logger.info(f"Notebooks: {notebooks}")
414 logger.info(f"Apps: {apps}")
415 logger.info(f"Notebooks-wasm: {notebooks_wasm}")
416 logger.info(f"Sandbox: {sandbox}")
417 logger.info(f"Parallel: {parallel} (max_workers={max_workers})")
418 logger.info(f"Bin path: {bin_path}")
420 # Convert bin_path to Path if provided
421 bin_path_obj: Path | None = Path(bin_path) if bin_path else None
423 notebooks_data = folder2notebooks(folder=notebooks, kind=Kind.NB)
424 apps_data = folder2notebooks(folder=apps, kind=Kind.APP)
425 notebooks_wasm_data = folder2notebooks(folder=notebooks_wasm, kind=Kind.NB_WASM)
427 logger.info(f"# notebooks_data: {len(notebooks_data)}")
428 logger.info(f"# apps_data: {len(apps_data)}")
429 logger.info(f"# notebooks_wasm_data: {len(notebooks_wasm_data)}")
431 # Exit if no notebooks or apps were found
432 if not notebooks_data and not apps_data and not notebooks_wasm_data:
433 logger.warning("No notebooks or apps found!")
434 return ""
436 return _generate_index(
437 output=output_dir,
438 template_file=template_file,
439 notebooks=notebooks_data,
440 apps=apps_data,
441 notebooks_wasm=notebooks_wasm_data,
442 sandbox=sandbox,
443 bin_path=bin_path_obj,
444 parallel=parallel,
445 max_workers=max_workers,
446 )
449def main(
450 output: str | Path = "_site",
451 template: str | Path = Path(__file__).parent / "templates" / "tailwind.html.j2",
452 notebooks: str | Path = "notebooks",
453 apps: str | Path = "apps",
454 notebooks_wasm: str | Path = "notebooks",
455 sandbox: bool = True,
456 bin_path: str | Path | None = None,
457 parallel: bool = True,
458 max_workers: int = 4,
459) -> str:
460 """Export marimo notebooks and generate an index page.
462 Args:
463 output: Output directory for generated files. Defaults to "_site".
464 template: Path to Jinja2 template file. Defaults to built-in Tailwind template.
465 notebooks: Directory containing static notebooks. Defaults to "notebooks".
466 apps: Directory containing app notebooks. Defaults to "apps".
467 notebooks_wasm: Directory containing interactive notebooks. Defaults to "notebooks".
468 sandbox: Whether to run exports in isolated sandbox. Defaults to True.
469 bin_path: Custom path to uvx executable. Defaults to None.
470 parallel: Whether to export notebooks in parallel. Defaults to True.
471 max_workers: Maximum number of parallel workers. Defaults to 4.
473 Returns:
474 Rendered HTML content as string, empty if no notebooks found.
476 Raises:
477 TemplateNotFoundError: If the template file does not exist.
478 TemplateInvalidError: If the template path is not a file.
479 TemplateRenderError: If the template fails to render.
480 IndexWriteError: If the index file cannot be written.
482 """
483 # Call the implementation function with the provided parameters and return its result
484 return _main_impl(
485 output=output,
486 template=template,
487 notebooks=notebooks,
488 apps=apps,
489 notebooks_wasm=notebooks_wasm,
490 sandbox=sandbox,
491 bin_path=bin_path,
492 parallel=parallel,
493 max_workers=max_workers,
494 )
497@app.command(name="export") # type: ignore[untyped-decorator]
498def _main_typer(
499 output: str = typer.Option("_site", "--output", "-o", help="Directory where the exported files will be saved"),
500 template: str = typer.Option(
501 str(Path(__file__).parent / "templates" / "tailwind.html.j2"),
502 "--template",
503 "-t",
504 help="Path to the template file",
505 ),
506 notebooks: str = typer.Option("notebooks", "--notebooks", "-n", help="Directory containing marimo notebooks"),
507 apps: str = typer.Option("apps", "--apps", "-a", help="Directory containing marimo apps"),
508 notebooks_wasm: str = typer.Option(
509 "notebooks_wasm", "--notebooks-wasm", "-nw", help="Directory containing marimo notebooks"
510 ),
511 sandbox: bool = typer.Option(True, "--sandbox/--no-sandbox", help="Whether to run the notebook in a sandbox"),
512 bin_path: str | None = typer.Option(None, "--bin-path", "-b", help="The directory where the executable is located"),
513 parallel: bool = typer.Option(True, "--parallel/--no-parallel", help="Whether to export notebooks in parallel"),
514 max_workers: int = typer.Option(4, "--max-workers", "-w", help="Maximum number of parallel workers"),
515) -> None:
516 """Export marimo notebooks and build an HTML index page linking to them.
518 This is the main CLI command for exporting notebooks. It scans the specified
519 directories for marimo notebook files (.py), exports each one to HTML or
520 WebAssembly format based on its category, and generates an index.html page
521 that provides navigation links to all exported notebooks.
523 The export process uses marimo's built-in export functionality via uvx,
524 running each notebook in sandbox mode by default for security.
526 Example usage:
527 # Basic export with defaults
528 $ marimushka export
530 # Custom directories
531 $ marimushka export -n my_notebooks -a my_apps -o dist
533 # Disable parallel processing
534 $ marimushka export --no-parallel
536 # Use custom template
537 $ marimushka export -t my_template.html.j2
539 """
540 main(
541 output=output,
542 template=template,
543 notebooks=notebooks,
544 apps=apps,
545 notebooks_wasm=notebooks_wasm,
546 sandbox=sandbox,
547 bin_path=bin_path,
548 parallel=parallel,
549 max_workers=max_workers,
550 )
553@app.command(name="watch") # type: ignore[untyped-decorator]
554def watch(
555 output: str = typer.Option("_site", "--output", "-o", help="Directory where the exported files will be saved"),
556 template: str = typer.Option(
557 str(Path(__file__).parent / "templates" / "tailwind.html.j2"),
558 "--template",
559 "-t",
560 help="Path to the template file",
561 ),
562 notebooks: str = typer.Option("notebooks", "--notebooks", "-n", help="Directory containing marimo notebooks"),
563 apps: str = typer.Option("apps", "--apps", "-a", help="Directory containing marimo apps"),
564 notebooks_wasm: str = typer.Option(
565 "notebooks_wasm", "--notebooks-wasm", "-nw", help="Directory containing marimo notebooks"
566 ),
567 sandbox: bool = typer.Option(True, "--sandbox/--no-sandbox", help="Whether to run the notebook in a sandbox"),
568 bin_path: str | None = typer.Option(None, "--bin-path", "-b", help="The directory where the executable is located"),
569 parallel: bool = typer.Option(True, "--parallel/--no-parallel", help="Whether to export notebooks in parallel"),
570 max_workers: int = typer.Option(4, "--max-workers", "-w", help="Maximum number of parallel workers"),
571) -> None:
572 """Watch for changes and automatically re-export notebooks.
574 This command watches the notebook directories and template file for changes,
575 automatically re-exporting when files are modified.
577 Requires the 'watchfiles' package: uv add watchfiles
578 """
579 try:
580 from watchfiles import watch as watchfiles_watch
581 except ImportError: # pragma: no cover
582 rich_print("[bold red]Error:[/bold red] watchfiles package is required for watch mode.")
583 rich_print("Install it with: [cyan]uv add watchfiles[/cyan]")
584 raise typer.Exit(1) from None
586 # Build list of paths to watch
587 watch_paths: list[Path] = []
589 template_path = Path(template)
590 if template_path.exists():
591 watch_paths.append(template_path.parent)
593 for folder in [notebooks, apps, notebooks_wasm]:
594 folder_path = Path(folder)
595 if folder_path.exists() and folder_path.is_dir():
596 watch_paths.append(folder_path)
598 if not watch_paths:
599 rich_print("[bold yellow]Warning:[/bold yellow] No directories to watch!")
600 raise typer.Exit(1)
602 rich_print("[bold green]Watching for changes in:[/bold green]")
603 for p in watch_paths:
604 rich_print(f" [cyan]{p}[/cyan]")
605 rich_print("\n[dim]Press Ctrl+C to stop[/dim]\n")
607 # Initial export
608 rich_print("[bold blue]Running initial export...[/bold blue]")
609 main(
610 output=output,
611 template=template,
612 notebooks=notebooks,
613 apps=apps,
614 notebooks_wasm=notebooks_wasm,
615 sandbox=sandbox,
616 bin_path=bin_path,
617 parallel=parallel,
618 max_workers=max_workers,
619 )
620 rich_print("[bold green]Initial export complete![/bold green]\n")
622 # Watch for changes (interactive loop - excluded from coverage)
623 try:
624 for changes in watchfiles_watch(*watch_paths): # pragma: no cover
625 changed_files = [str(change[1]) for change in changes]
626 rich_print("\n[bold yellow]Detected changes:[/bold yellow]")
627 for f in changed_files[:_MAX_CHANGED_FILES_TO_DISPLAY]:
628 rich_print(f" [dim]{f}[/dim]")
629 if len(changed_files) > _MAX_CHANGED_FILES_TO_DISPLAY:
630 rich_print(f" [dim]... and {len(changed_files) - _MAX_CHANGED_FILES_TO_DISPLAY} more[/dim]")
632 rich_print("[bold blue]Re-exporting...[/bold blue]")
633 main(
634 output=output,
635 template=template,
636 notebooks=notebooks,
637 apps=apps,
638 notebooks_wasm=notebooks_wasm,
639 sandbox=sandbox,
640 bin_path=bin_path,
641 parallel=parallel,
642 max_workers=max_workers,
643 )
644 rich_print("[bold green]Export complete![/bold green]")
645 except KeyboardInterrupt:
646 rich_print("\n[bold green]Watch mode stopped.[/bold green]")
649@app.command(name="version") # type: ignore[untyped-decorator]
650def version() -> None:
651 """Display the current version of Marimushka.
653 Prints the package version with colored formatting using Rich. The version
654 is read from the package metadata at runtime, ensuring it always reflects
655 the installed version.
657 Example:
658 $ marimushka version
659 Marimushka version: 0.1.0
661 """
662 rich_print(f"[bold green]Marimushka[/bold green] version: [bold blue]{__version__}[/bold blue]")
665def cli() -> None:
666 """Entry point for the marimushka command-line interface.
668 This function is registered as the console script entry point in
669 pyproject.toml. It invokes the Typer application which handles
670 argument parsing and command dispatch.
672 The CLI supports the following subcommands:
673 - export: Export notebooks and generate index page
674 - watch: Monitor for changes and auto-export
675 - version: Display the installed version
677 Running without a subcommand displays help text.
679 Example:
680 $ marimushka # Shows help
681 $ marimushka export # Run export command
682 $ marimushka --help # Shows help
684 """
685 app()