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

1"""Export module for building and deploying marimo notebooks. 

2 

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. 

7 

8Architecture: 

9 The export flow follows this path: 

10 cli() → app() (Typer) → main() → _main_impl() → _generate_index() 

11 

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 

17 

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 

22 

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. 

27 

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) 

35 

36Example:: 

37 

38 # CLI usage 

39 $ marimushka export --notebooks notebooks --apps apps --output _site 

40 

41 # Python API 

42 from marimushka.export import main 

43 main(notebooks="notebooks", apps="apps", output="_site") 

44 

45The exported files will be placed in the specified output directory (default: _site). 

46""" 

47 

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# /// 

57 

58import sys 

59from concurrent.futures import ThreadPoolExecutor, as_completed 

60from pathlib import Path 

61 

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 

67 

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 

78 

79# Maximum number of changed files to display in watch mode 

80_MAX_CHANGED_FILES_TO_DISPLAY = 5 

81 

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) 

90 

91app = typer.Typer(help=f"Marimushka - Export marimo notebooks in style. Version: {__version__}") 

92 

93 

94def _validate_template(template_path: Path) -> None: 

95 """Validate the template file exists and has correct extension. 

96 

97 Args: 

98 template_path: Path to the template file. 

99 

100 Raises: 

101 TemplateNotFoundError: If the template file does not exist. 

102 TemplateInvalidError: If the template path is not a file. 

103 

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") 

111 

112 

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. 

120 

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. 

126 

127 Returns: 

128 NotebookExportResult with success status and details. 

129 

130 """ 

131 return notebook.export(output_dir=output_dir, sandbox=sandbox, bin_path=bin_path) 

132 

133 

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. 

144 

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. 

153 

154 Returns: 

155 BatchExportResult containing individual results and summary statistics. 

156 

157 """ 

158 batch_result = BatchExportResult() 

159 

160 if not notebooks: 

161 return batch_result 

162 

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} 

165 

166 for future in as_completed(futures): 

167 result = future.result() 

168 batch_result.add(result) 

169 

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}") 

173 

174 if progress and task_id is not None: 

175 progress.advance(task_id) 

176 

177 return batch_result 

178 

179 

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. 

183 

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`). 

187 

188 Args: 

189 ctx: Typer context containing invocation information, including 

190 which subcommand (if any) was specified. 

191 

192 Raises: 

193 typer.Exit: Always raised when no subcommand is provided, with 

194 exit code 0 to indicate this is expected behavior. 

195 

196 """ 

197 if ctx.invoked_subcommand is None: 

198 print(ctx.get_help()) 

199 raise typer.Exit() 

200 

201 

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. 

214 

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. 

218 

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. 

229 

230 Returns: 

231 The rendered HTML content as a string. 

232 

233 Raises: 

234 TemplateRenderError: If the template fails to render. 

235 IndexWriteError: If the index file cannot be written. 

236 

237 """ 

238 # Initialize empty lists if None is provided 

239 notebooks = notebooks or [] 

240 apps = apps or [] 

241 notebooks_wasm = notebooks_wasm or [] 

242 

243 total_notebooks = len(notebooks) + len(apps) + len(notebooks_wasm) 

244 combined_batch_result = BatchExportResult() 

245 

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) 

258 

259 all_notebooks = [ 

260 (notebooks, output / "notebooks"), 

261 (apps, output / "apps"), 

262 (notebooks_wasm, output / "notebooks_wasm"), 

263 ] 

264 

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) 

272 

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) 

284 

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) 

289 

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) 

294 

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) 

299 

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 ) 

305 

306 # Create the full path for the index.html file 

307 index_path: Path = Path(output) / "index.html" 

308 

309 # Ensure the output directory exists 

310 Path(output).mkdir(parents=True, exist_ok=True) 

311 

312 # Set up Jinja2 environment and load template 

313 template_dir = template_file.parent 

314 template_name = template_file.name 

315 

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) 

322 

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 

331 

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 

339 

340 return rendered_html 

341 

342 

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. 

355 

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. 

360 

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. 

364 

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. 

385 

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. 

389 

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. 

395 

396 """ 

397 logger.info("Starting marimushka build process") 

398 logger.info(f"Version of Marimushka: {__version__}") 

399 output = output or "_site" 

400 

401 # Convert output_dir explicitly to Path 

402 output_dir: Path = Path(output) 

403 logger.info(f"Output directory: {output_dir}") 

404 

405 # Make sure the output directory exists 

406 output_dir.mkdir(parents=True, exist_ok=True) 

407 

408 # Convert template to Path and validate early 

409 template_file: Path = Path(template) 

410 _validate_template(template_file) 

411 

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}") 

419 

420 # Convert bin_path to Path if provided 

421 bin_path_obj: Path | None = Path(bin_path) if bin_path else None 

422 

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) 

426 

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)}") 

430 

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 "" 

435 

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 ) 

447 

448 

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. 

461 

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. 

472 

473 Returns: 

474 Rendered HTML content as string, empty if no notebooks found. 

475 

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. 

481 

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 ) 

495 

496 

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. 

517 

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. 

522 

523 The export process uses marimo's built-in export functionality via uvx, 

524 running each notebook in sandbox mode by default for security. 

525 

526 Example usage: 

527 # Basic export with defaults 

528 $ marimushka export 

529 

530 # Custom directories 

531 $ marimushka export -n my_notebooks -a my_apps -o dist 

532 

533 # Disable parallel processing 

534 $ marimushka export --no-parallel 

535 

536 # Use custom template 

537 $ marimushka export -t my_template.html.j2 

538 

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 ) 

551 

552 

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. 

573 

574 This command watches the notebook directories and template file for changes, 

575 automatically re-exporting when files are modified. 

576 

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 

585 

586 # Build list of paths to watch 

587 watch_paths: list[Path] = [] 

588 

589 template_path = Path(template) 

590 if template_path.exists(): 

591 watch_paths.append(template_path.parent) 

592 

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) 

597 

598 if not watch_paths: 

599 rich_print("[bold yellow]Warning:[/bold yellow] No directories to watch!") 

600 raise typer.Exit(1) 

601 

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") 

606 

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") 

621 

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]") 

631 

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]") 

647 

648 

649@app.command(name="version") # type: ignore[untyped-decorator] 

650def version() -> None: 

651 """Display the current version of Marimushka. 

652 

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. 

656 

657 Example: 

658 $ marimushka version 

659 Marimushka version: 0.1.0 

660 

661 """ 

662 rich_print(f"[bold green]Marimushka[/bold green] version: [bold blue]{__version__}[/bold blue]") 

663 

664 

665def cli() -> None: 

666 """Entry point for the marimushka command-line interface. 

667 

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. 

671 

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 

676 

677 Running without a subcommand displays help text. 

678 

679 Example: 

680 $ marimushka # Shows help 

681 $ marimushka export # Run export command 

682 $ marimushka --help # Shows help 

683 

684 """ 

685 app()