Coverage for src / marimushka / cli.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-28 17:41 +0000

1"""Command-line interface for marimushka. 

2 

3This module provides the CLI commands for exporting marimo notebooks, 

4watching for changes, and displaying version information. 

5""" 

6 

7import sys 

8from pathlib import Path 

9 

10import typer 

11from loguru import logger 

12from rich import print as rich_print 

13 

14from . import __version__ 

15 

16# Global state for debug mode 

17_debug_mode = False 

18 

19# Configure logger 

20logger.configure(extra={"subprocess": ""}) 

21logger.remove() 

22 

23 

24def configure_logging(debug: bool = False) -> None: 

25 """Configure logging based on debug mode. 

26 

27 Args: 

28 debug: Whether to enable debug mode. 

29 

30 """ 

31 global _debug_mode 

32 _debug_mode = debug 

33 

34 # Remove existing handlers 

35 logger.remove() 

36 

37 if debug: 

38 # Debug mode: show all logs including DEBUG level 

39 logger.add( 

40 sys.stderr, 

41 format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | " 

42 "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | " 

43 "<magenta>{extra[subprocess]}</magenta><level>{message}</level>", 

44 level="DEBUG", 

45 ) 

46 logger.debug("Debug mode enabled - showing all log messages") 

47 else: 

48 # Normal mode: show INFO and above 

49 logger.add( 

50 sys.stderr, 

51 format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | " 

52 "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | " 

53 "<magenta>{extra[subprocess]}</magenta><level>{message}</level>", 

54 level="INFO", 

55 ) 

56 

57 

58# Initial configuration with default settings 

59configure_logging(debug=False) 

60 

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

62_MAX_CHANGED_FILES_TO_DISPLAY = 5 

63 

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

65 

66 

67@app.callback(invoke_without_command=True) 

68def callback(ctx: typer.Context) -> None: 

69 """Handle the CLI invocation without a subcommand. 

70 

71 This callback runs before any subcommand and displays help text if the 

72 user invokes marimushka without specifying a subcommand (e.g., just 

73 `marimushka` instead of `marimushka export`). 

74 

75 Args: 

76 ctx: Typer context containing invocation information, including 

77 which subcommand (if any) was specified. 

78 

79 Raises: 

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

81 exit code 0 to indicate this is expected behavior. 

82 

83 """ 

84 if ctx.invoked_subcommand is None: 

85 print(ctx.get_help()) 

86 raise typer.Exit() 

87 

88 

89@app.command(name="export") 

90def export_command( 

91 output: str = typer.Option("_site", "--output", "-o", help="Directory where the exported files will be saved"), 

92 template: str = typer.Option( 

93 str(Path(__file__).parent / "templates" / "tailwind.html.j2"), 

94 "--template", 

95 "-t", 

96 help="Path to the template file", 

97 ), 

98 notebooks: str = typer.Option("notebooks", "--notebooks", "-n", help="Directory containing marimo notebooks"), 

99 apps: str = typer.Option("apps", "--apps", "-a", help="Directory containing marimo apps"), 

100 notebooks_wasm: str = typer.Option( 

101 "notebooks_wasm", "--notebooks-wasm", "-nw", help="Directory containing marimo notebooks" 

102 ), 

103 sandbox: bool = typer.Option(True, "--sandbox/--no-sandbox", help="Whether to run the notebook in a sandbox"), 

104 bin_path: str | None = typer.Option(None, "--bin-path", "-b", help="The directory where the executable is located"), 

105 parallel: bool = typer.Option(True, "--parallel/--no-parallel", help="Whether to export notebooks in parallel"), 

106 max_workers: int = typer.Option(4, "--max-workers", "-w", help="Maximum number of parallel workers (1-16)"), 

107 timeout: int = typer.Option(300, "--timeout", help="Timeout in seconds for each notebook export"), 

108 debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug mode with verbose logging"), 

109) -> None: 

110 """Export marimo notebooks and build an HTML index page linking to them. 

111 

112 This is the main CLI command for exporting notebooks. It scans the specified 

113 directories for marimo notebook files (.py), exports each one to HTML or 

114 WebAssembly format based on its category, and generates an index.html page 

115 that provides navigation links to all exported notebooks. 

116 

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

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

119 

120 Example usage: 

121 # Basic export with defaults 

122 $ marimushka export 

123 

124 # Custom directories 

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

126 

127 # Disable parallel processing 

128 $ marimushka export --no-parallel 

129 

130 # Use custom template 

131 $ marimushka export -t my_template.html.j2 

132 

133 # Enable debug mode for troubleshooting 

134 $ marimushka export --debug 

135 

136 """ 

137 # Configure logging based on debug flag 

138 configure_logging(debug=debug) 

139 

140 from .export import main 

141 

142 main( 

143 output=output, 

144 template=template, 

145 notebooks=notebooks, 

146 apps=apps, 

147 notebooks_wasm=notebooks_wasm, 

148 sandbox=sandbox, 

149 bin_path=bin_path, 

150 parallel=parallel, 

151 max_workers=max_workers, 

152 timeout=timeout, 

153 ) 

154 

155 

156@app.command(name="watch") 

157def watch_command( 

158 output: str = typer.Option("_site", "--output", "-o", help="Directory where the exported files will be saved"), 

159 template: str = typer.Option( 

160 str(Path(__file__).parent / "templates" / "tailwind.html.j2"), 

161 "--template", 

162 "-t", 

163 help="Path to the template file", 

164 ), 

165 notebooks: str = typer.Option("notebooks", "--notebooks", "-n", help="Directory containing marimo notebooks"), 

166 apps: str = typer.Option("apps", "--apps", "-a", help="Directory containing marimo apps"), 

167 notebooks_wasm: str = typer.Option( 

168 "notebooks_wasm", "--notebooks-wasm", "-nw", help="Directory containing marimo notebooks" 

169 ), 

170 sandbox: bool = typer.Option(True, "--sandbox/--no-sandbox", help="Whether to run the notebook in a sandbox"), 

171 bin_path: str | None = typer.Option(None, "--bin-path", "-b", help="The directory where the executable is located"), 

172 parallel: bool = typer.Option(True, "--parallel/--no-parallel", help="Whether to export notebooks in parallel"), 

173 max_workers: int = typer.Option(4, "--max-workers", "-w", help="Maximum number of parallel workers (1-16)"), 

174 timeout: int = typer.Option(300, "--timeout", help="Timeout in seconds for each notebook export"), 

175 debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug mode with verbose logging"), 

176) -> None: 

177 """Watch for changes and automatically re-export notebooks. 

178 

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

180 automatically re-exporting when files are modified. 

181 

182 Requires the 'watchfiles' package: uv add watchfiles 

183 

184 Example usage: 

185 # Basic watch mode 

186 $ marimushka watch 

187 

188 # Watch with custom directories 

189 $ marimushka watch -n my_notebooks -a my_apps 

190 

191 # Watch with debug logging 

192 $ marimushka watch --debug 

193 """ 

194 # Configure logging based on debug flag 

195 configure_logging(debug=debug) 

196 

197 try: 

198 from watchfiles import watch as watchfiles_watch 

199 except ImportError: # pragma: no cover 

200 rich_print("[bold red]Error:[/bold red] watchfiles package is required for watch mode.") 

201 rich_print("Install it with: [cyan]uv add watchfiles[/cyan]") 

202 raise typer.Exit(1) from None 

203 

204 from .export import main 

205 

206 # Build list of paths to watch 

207 watch_paths: list[Path] = [] 

208 

209 template_path = Path(template) 

210 if template_path.exists(): 

211 watch_paths.append(template_path.parent) 

212 

213 for folder in [notebooks, apps, notebooks_wasm]: 

214 folder_path = Path(folder) 

215 if folder_path.exists() and folder_path.is_dir(): 

216 watch_paths.append(folder_path) 

217 

218 if not watch_paths: 

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

220 raise typer.Exit(1) 

221 

222 rich_print("[bold green]Watching for changes in:[/bold green]") 

223 for p in watch_paths: 

224 rich_print(f" [cyan]{p}[/cyan]") 

225 rich_print("\n[dim]Press Ctrl+C to stop[/dim]\n") 

226 

227 # Initial export 

228 rich_print("[bold blue]Running initial export...[/bold blue]") 

229 main( 

230 output=output, 

231 template=template, 

232 notebooks=notebooks, 

233 apps=apps, 

234 notebooks_wasm=notebooks_wasm, 

235 sandbox=sandbox, 

236 bin_path=bin_path, 

237 parallel=parallel, 

238 max_workers=max_workers, 

239 timeout=timeout, 

240 ) 

241 rich_print("[bold green]Initial export complete![/bold green]\n") 

242 

243 # Watch for changes (interactive loop - excluded from coverage) 

244 try: 

245 for changes in watchfiles_watch(*watch_paths): # pragma: no cover 

246 changed_files = [str(change[1]) for change in changes] 

247 rich_print("\n[bold yellow]Detected changes:[/bold yellow]") 

248 for f in changed_files[:_MAX_CHANGED_FILES_TO_DISPLAY]: 

249 rich_print(f" [dim]{f}[/dim]") 

250 if len(changed_files) > _MAX_CHANGED_FILES_TO_DISPLAY: 

251 rich_print(f" [dim]... and {len(changed_files) - _MAX_CHANGED_FILES_TO_DISPLAY} more[/dim]") 

252 

253 rich_print("[bold blue]Re-exporting...[/bold blue]") 

254 main( 

255 output=output, 

256 template=template, 

257 notebooks=notebooks, 

258 apps=apps, 

259 notebooks_wasm=notebooks_wasm, 

260 sandbox=sandbox, 

261 bin_path=bin_path, 

262 parallel=parallel, 

263 max_workers=max_workers, 

264 timeout=timeout, 

265 ) 

266 rich_print("[bold green]Export complete![/bold green]") 

267 except KeyboardInterrupt: 

268 rich_print("\n[bold green]Watch mode stopped.[/bold green]") 

269 

270 

271@app.command(name="version") 

272def version_command() -> None: 

273 """Display the current version of Marimushka. 

274 

275 Prints the package version with colored formatting using Rich. The version 

276 is read from the package metadata at runtime, ensuring it always reflects 

277 the installed version. 

278 

279 Example: 

280 $ marimushka version 

281 Marimushka version: 0.1.0 

282 

283 """ 

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

285 

286 

287def cli() -> None: 

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

289 

290 This function is registered as the console script entry point in 

291 pyproject.toml. It invokes the Typer application which handles 

292 argument parsing and command dispatch. 

293 

294 The CLI supports the following subcommands: 

295 - export: Export notebooks and generate index page 

296 - watch: Monitor for changes and auto-export 

297 - version: Display the installed version 

298 

299 Running without a subcommand displays help text. 

300 

301 Example: 

302 $ marimushka # Shows help 

303 $ marimushka export # Run export command 

304 $ marimushka --help # Shows help 

305 

306 """ 

307 app()