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

144 statements  

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

1"""Notebook module for handling marimo notebooks. 

2 

3This module provides the core abstractions for representing and exporting marimo 

4notebooks to various HTML and WebAssembly formats. It defines the Notebook class 

5which encapsulates a marimo notebook file and handles the export process via 

6subprocess calls to the marimo CLI. 

7 

8Key Components: 

9 Kind: Enumeration of notebook export types (static HTML, interactive WASM, app mode). 

10 Notebook: Dataclass representing a single marimo notebook with export capabilities. 

11 folder2notebooks: Utility function to discover notebooks in a directory. 

12 

13Export Modes: 

14 The module supports three export modes through the Kind enum: 

15 

16 - NB (notebook): Exports to static HTML using `marimo export html --sandbox`. 

17 Best for documentation and read-only sharing. 

18 

19 - NB_WASM (notebook_wasm): Exports to interactive WebAssembly using 

20 `marimo export html-wasm --mode edit --sandbox`. Users can edit and run 

21 code in the browser. 

22 

23 - APP (app): Exports to WebAssembly in run mode using 

24 `marimo export html-wasm --mode run --no-show-code --sandbox`. Presents 

25 a clean application interface with code hidden. 

26 

27Example:: 

28 

29 from marimushka.notebook import Notebook, Kind, folder2notebooks 

30 from pathlib import Path 

31 

32 # Create a notebook and export it 

33 nb = Notebook(Path("my_notebook.py"), kind=Kind.APP) 

34 result = nb.export(Path("_site/apps")) 

35 if result.success: 

36 print(f"Exported to {result.output_path}") 

37 

38 # Discover all notebooks in a directory 

39 notebooks = folder2notebooks(Path("notebooks"), kind=Kind.NB) 

40""" 

41 

42import dataclasses 

43import os 

44import shutil 

45import subprocess # nosec B404 

46from enum import Enum 

47from pathlib import Path 

48 

49from loguru import logger 

50 

51from .audit import AuditLogger, get_audit_logger 

52from .exceptions import ( 

53 ExportError, 

54 ExportExecutableNotFoundError, 

55 ExportSubprocessError, 

56 NotebookExportResult, 

57 NotebookInvalidError, 

58 NotebookNotFoundError, 

59) 

60from .security import sanitize_error_message, set_secure_file_permissions, validate_bin_path, validate_path_traversal 

61 

62 

63class Kind(Enum): 

64 """Enumeration of notebook export types. 

65 

66 This enum defines the three ways a marimo notebook can be exported, 

67 each with different capabilities and use cases. The choice of Kind 

68 affects both the marimo export command used and the output directory. 

69 

70 Attributes: 

71 NB: Static HTML export. Creates a read-only HTML representation 

72 of the notebook. Output goes to the 'notebooks/' subdirectory. 

73 Uses command: `marimo export html --sandbox` 

74 NB_WASM: Interactive WebAssembly export in edit mode. Creates a 

75 fully interactive notebook that runs in the browser with code 

76 editing capabilities. Output goes to the 'notebooks_wasm/' 

77 subdirectory. Uses command: `marimo export html-wasm --mode edit --sandbox` 

78 APP: WebAssembly export in run/app mode. Creates an application 

79 interface with code hidden from users. Ideal for dashboards 

80 and user-facing tools. Output goes to the 'apps/' subdirectory. 

81 Uses command: `marimo export html-wasm --mode run --no-show-code --sandbox` 

82 

83 """ 

84 

85 NB = "notebook" 

86 NB_WASM = "notebook_wasm" 

87 APP = "app" 

88 

89 @classmethod 

90 def from_str(cls, value: str) -> "Kind": 

91 """Represent a factory method to parse a string into a Kind enumeration instance. 

92 

93 This method attempts to match the input string to an existing kind defined 

94 in the Kind enumeration. If the input string does not match any valid kind, 

95 an error is raised detailing the invalid value and listing acceptable kinds. 

96 

97 Args: 

98 value (str): A string representing the kind to parse into a Kind instance. 

99 

100 Returns: 

101 Kind: An instance of the Kind enumeration corresponding to the input string. 

102 

103 Raises: 

104 ValueError: If the input string does not match any valid Kind value. 

105 

106 Examples: 

107 >>> from marimushka.notebook import Kind 

108 >>> Kind.from_str("notebook") 

109 <Kind.NB: 'notebook'> 

110 >>> Kind.from_str("app") 

111 <Kind.APP: 'app'> 

112 >>> Kind.from_str("invalid") 

113 Traceback (most recent call last): 

114 ... 

115 ValueError: Invalid Kind: 'invalid'. Must be one of ['notebook', 'notebook_wasm', 'app'] 

116 

117 """ 

118 try: 

119 return Kind(value) 

120 except ValueError as e: 

121 msg = f"Invalid Kind: {value!r}. Must be one of {[k.value for k in Kind]}" 

122 raise ValueError(msg) from e 

123 

124 @property 

125 def command(self) -> list[str]: 

126 """Get the command list associated with a specific Kind instance. 

127 

128 The command property returns a list of command strings that correspond 

129 to different kinds of operations based on the Kind instance. 

130 

131 Attributes: 

132 command: A list of strings representing the command. 

133 

134 Returns: 

135 list[str]: A list of command strings for the corresponding Kind instance. 

136 

137 Examples: 

138 >>> from marimushka.notebook import Kind 

139 >>> Kind.NB.command 

140 ['marimo', 'export', 'html'] 

141 >>> Kind.APP.command 

142 ['marimo', 'export', 'html-wasm', '--mode', 'run', '--no-show-code'] 

143 

144 """ 

145 commands = { 

146 Kind.NB: ["marimo", "export", "html"], 

147 Kind.NB_WASM: ["marimo", "export", "html-wasm", "--mode", "edit"], 

148 Kind.APP: ["marimo", "export", "html-wasm", "--mode", "run", "--no-show-code"], 

149 } 

150 return commands[self] 

151 

152 @property 

153 def html_path(self) -> Path: 

154 """Provide a property to determine the HTML path for different kinds of objects. 

155 

156 This property computes the corresponding directory path based on the kind 

157 of the object, such as notebooks, notebooks_wasm, or apps. 

158 

159 @return: A Path object representing the relevant directory path for the 

160 current kind. 

161 

162 @rtype: Path 

163 

164 Examples: 

165 >>> from marimushka.notebook import Kind 

166 >>> str(Kind.NB.html_path) 

167 'notebooks' 

168 >>> str(Kind.APP.html_path) 

169 'apps' 

170 

171 """ 

172 paths = { 

173 Kind.NB: Path("notebooks"), 

174 Kind.NB_WASM: Path("notebooks_wasm"), 

175 Kind.APP: Path("apps"), 

176 } 

177 return paths[self] 

178 

179 

180@dataclasses.dataclass(frozen=True) 

181class Notebook: 

182 """Represents a marimo notebook. 

183 

184 This class encapsulates a marimo notebook (.py file) and provides methods 

185 for exporting it to HTML/WebAssembly format. 

186 

187 Attributes: 

188 path (Path): Path to the marimo notebook (.py file) 

189 kind (Kind): How the notebook ts treated 

190 

191 """ 

192 

193 path: Path 

194 kind: Kind = Kind.NB 

195 

196 def __post_init__(self) -> None: 

197 """Validate the notebook path after initialization. 

198 

199 Raises: 

200 NotebookNotFoundError: If the file does not exist. 

201 NotebookInvalidError: If the path is not a file or not a Python file. 

202 

203 """ 

204 if not self.path.exists(): 

205 raise NotebookNotFoundError(self.path) 

206 if not self.path.is_file(): 

207 raise NotebookInvalidError(self.path, reason="path is not a file") 

208 if not self.path.suffix == ".py": 

209 raise NotebookInvalidError(self.path, reason="not a Python file") 

210 

211 def export( 

212 self, 

213 output_dir: Path, 

214 sandbox: bool = True, 

215 bin_path: Path | None = None, 

216 timeout: int = 300, 

217 audit_logger: AuditLogger | None = None, 

218 ) -> NotebookExportResult: 

219 """Export the notebook to HTML/WebAssembly format. 

220 

221 This method exports the marimo notebook to HTML/WebAssembly format. 

222 If is_app is True, the notebook is exported in "run" mode with code hidden, 

223 suitable for applications. Otherwise, it's exported in "edit" mode, 

224 suitable for interactive notebooks. 

225 

226 Args: 

227 output_dir: Directory where the exported HTML file will be saved. 

228 sandbox: Whether to run the notebook in a sandbox. Defaults to True. 

229 bin_path: The directory where the executable is located. Defaults to None. 

230 timeout: Maximum time in seconds for the export process. Defaults to 300. 

231 audit_logger: Logger for audit events. If None, uses default logger. 

232 

233 Returns: 

234 NotebookExportResult indicating success or failure with details. 

235 

236 """ 

237 if audit_logger is None: 

238 audit_logger = get_audit_logger() 

239 

240 # Resolve executable 

241 exe = self._resolve_executable(bin_path, audit_logger) 

242 if isinstance(exe, NotebookExportResult): 

243 return exe 

244 

245 # Prepare output path 

246 output_file_or_error = self._prepare_output_path(output_dir, audit_logger) 

247 if isinstance(output_file_or_error, NotebookExportResult): 

248 return output_file_or_error 

249 output_file = output_file_or_error 

250 

251 # Build and run command 

252 cmd = self._build_command(exe, sandbox, output_file) 

253 return self._run_export_subprocess(cmd, output_file, timeout, audit_logger) 

254 

255 def _resolve_executable(self, bin_path: Path | None, audit_logger: AuditLogger) -> str | NotebookExportResult: 

256 """Resolve the executable path. 

257 

258 Args: 

259 bin_path: Optional directory where the executable is located. 

260 audit_logger: Audit logger for security logging. 

261 

262 Returns: 

263 Executable string on success, or NotebookExportResult on error. 

264 

265 """ 

266 executable = "uvx" 

267 

268 if not bin_path: 

269 return executable 

270 

271 # Validate bin_path for security 

272 try: 

273 validated_bin_path = validate_bin_path(bin_path) 

274 audit_logger.log_path_validation(bin_path, "bin_path", True) 

275 except ValueError as e: 

276 err: ExportError = ExportExecutableNotFoundError(executable, bin_path) 

277 sanitized_error = sanitize_error_message(str(e)) 

278 logger.error(f"Invalid bin_path: {sanitized_error}") 

279 audit_logger.log_path_validation(bin_path, "bin_path", False, sanitized_error) 

280 return NotebookExportResult.failed(self.path, err) 

281 

282 # Construct the full executable path 

283 # Use shutil.which to find it with platform-specific extensions (like .exe on Windows) 

284 exe = shutil.which(executable, path=str(validated_bin_path)) 

285 if not exe: 

286 # Fallback: try constructing the path directly 

287 exe_path = validated_bin_path / executable 

288 if exe_path.is_file() and os.access(exe_path, os.X_OK): 

289 return str(exe_path) 

290 

291 err = ExportExecutableNotFoundError(executable, validated_bin_path) 

292 logger.error(str(err)) 

293 audit_logger.log_export(self.path, None, False, str(err)) 

294 return NotebookExportResult.failed(self.path, err) 

295 

296 return exe 

297 

298 def _prepare_output_path(self, output_dir: Path, audit_logger: AuditLogger) -> Path | NotebookExportResult: 

299 """Validate and prepare the output path. 

300 

301 Args: 

302 output_dir: Directory where the exported HTML file will be saved. 

303 audit_logger: Audit logger for security logging. 

304 

305 Returns: 

306 Output file Path on success, or NotebookExportResult on error. 

307 

308 """ 

309 output_file = output_dir / f"{self.path.stem}.html" 

310 

311 # Validate output path to prevent path traversal 

312 try: 

313 validate_path_traversal(output_file) 

314 audit_logger.log_path_validation(output_file, "output_path", True) 

315 except ValueError as e: 

316 sanitized_error = sanitize_error_message(str(e)) 

317 err = ExportSubprocessError( 

318 notebook_path=self.path, 

319 command=[], # Command not yet built 

320 return_code=-1, 

321 stderr=f"Invalid output path: {sanitized_error}", 

322 ) 

323 logger.error(str(err)) 

324 audit_logger.log_path_validation(output_file, "output_path", False, sanitized_error) 

325 return NotebookExportResult.failed(self.path, err) 

326 

327 # Create output directory 

328 try: 

329 output_file.parent.mkdir(parents=True, exist_ok=True) 

330 except OSError as e: # pragma: no cover 

331 sanitized_error = sanitize_error_message(str(e)) 

332 err = ExportSubprocessError( 

333 notebook_path=self.path, 

334 command=[], # Command not yet built 

335 return_code=-1, 

336 stderr=f"Failed to create output directory: {sanitized_error}", 

337 ) 

338 logger.error(str(err)) 

339 audit_logger.log_export(self.path, None, False, sanitized_error) 

340 return NotebookExportResult.failed(self.path, err) 

341 

342 return output_file 

343 

344 def _build_command(self, exe: str, sandbox: bool, output_file: Path) -> list[str]: 

345 """Build the export command. 

346 

347 Args: 

348 exe: Executable to use (e.g., 'uvx' or full path). 

349 sandbox: Whether to run the notebook in a sandbox. 

350 output_file: Path where the exported HTML file will be saved. 

351 

352 Returns: 

353 Command list ready for subprocess execution. 

354 

355 """ 

356 cmd = [exe, *self.kind.command] 

357 if sandbox: 

358 cmd.append("--sandbox") 

359 else: 

360 cmd.append("--no-sandbox") 

361 cmd.extend([str(self.path), "-o", str(output_file)]) 

362 return cmd 

363 

364 def _run_export_subprocess( 

365 self, cmd: list[str], output_file: Path, timeout: int, audit_logger: AuditLogger 

366 ) -> NotebookExportResult: 

367 """Run the export subprocess and handle results. 

368 

369 Args: 

370 cmd: Command list to execute. 

371 output_file: Path where the exported HTML file will be saved. 

372 timeout: Maximum time in seconds for the export process. 

373 audit_logger: Audit logger for security logging. 

374 

375 Returns: 

376 NotebookExportResult indicating success or failure. 

377 

378 """ 

379 try: 

380 # Run marimo export command with timeout 

381 logger.debug(f"Running command: {cmd}") 

382 result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout) # nosec B603 # noqa: S603 

383 

384 nb_logger = logger.bind(subprocess=f"[{self.path.name}] ") 

385 

386 if result.stdout: 

387 nb_logger.info(f"stdout:\n{result.stdout.strip()}") 

388 

389 if result.stderr: 

390 nb_logger.warning(f"stderr:\n{result.stderr.strip()}") 

391 

392 if result.returncode != 0: 

393 sanitized_stderr = sanitize_error_message(result.stderr) 

394 err = ExportSubprocessError( 

395 notebook_path=self.path, 

396 command=cmd, 

397 return_code=result.returncode, 

398 stdout=result.stdout, 

399 stderr=sanitized_stderr, 

400 ) 

401 nb_logger.error(str(err)) 

402 audit_logger.log_export(self.path, None, False, sanitized_stderr) 

403 return NotebookExportResult.failed(self.path, err) 

404 

405 # Set secure permissions on output file 

406 try: 

407 set_secure_file_permissions(output_file, mode=0o644) 

408 except ValueError as e: # pragma: no cover 

409 logger.warning(f"Could not set secure permissions on {output_file}: {e}") 

410 

411 audit_logger.log_export(self.path, output_file, True) 

412 return NotebookExportResult.succeeded(self.path, output_file) 

413 

414 except subprocess.TimeoutExpired: 

415 err = ExportSubprocessError( 

416 notebook_path=self.path, 

417 command=cmd, 

418 return_code=-1, 

419 stderr=f"Export timed out after {timeout} seconds", 

420 ) 

421 logger.error(str(err)) 

422 audit_logger.log_export(self.path, None, False, f"timeout after {timeout}s") 

423 return NotebookExportResult.failed(self.path, err) 

424 except FileNotFoundError as e: 

425 # Executable not found in PATH 

426 exec_err = ExportExecutableNotFoundError(cmd[0]) 

427 sanitized_error = sanitize_error_message(str(e)) 

428 logger.error(f"{exec_err}: {sanitized_error}") 

429 audit_logger.log_export(self.path, None, False, sanitized_error) 

430 return NotebookExportResult.failed(self.path, exec_err) 

431 except subprocess.SubprocessError as e: 

432 sanitized_error = sanitize_error_message(str(e)) 

433 err = ExportSubprocessError( 

434 notebook_path=self.path, 

435 command=cmd, 

436 return_code=-1, 

437 stderr=sanitized_error, 

438 ) 

439 logger.error(str(err)) 

440 audit_logger.log_export(self.path, None, False, sanitized_error) 

441 return NotebookExportResult.failed(self.path, err) 

442 

443 @property 

444 def display_name(self) -> str: 

445 """Return the display name for the notebook. 

446 

447 The display name is derived from the notebook filename by replacing 

448 underscores with spaces, making it more human-readable. 

449 

450 Returns: 

451 str: Human-friendly display name with underscores replaced by spaces. 

452 

453 Examples: 

454 >>> # Demonstrating the transformation logic 

455 >>> filename = "my_cool_notebook" 

456 >>> display_name = filename.replace("_", " ") 

457 >>> display_name 

458 'my cool notebook' 

459 

460 """ 

461 return self.path.stem.replace("_", " ") 

462 

463 @property 

464 def html_path(self) -> Path: 

465 """Return the path to the exported HTML file.""" 

466 return self.kind.html_path / f"{self.path.stem}.html" 

467 

468 

469def folder2notebooks(folder: Path | str | None, kind: Kind = Kind.NB) -> list[Notebook]: 

470 """Discover and create Notebook instances for all Python files in a directory. 

471 

472 This function scans a directory for Python files (*.py) and creates Notebook 

473 instances for each one. It assumes all Python files in the directory are 

474 valid marimo notebooks. The resulting list is sorted alphabetically by 

475 filename to ensure consistent ordering across runs. 

476 

477 Args: 

478 folder: Path to the directory to scan for notebooks. Can be a Path 

479 object, a string path, or None. If None or empty string, returns 

480 an empty list. 

481 kind: The export type for all discovered notebooks. Defaults to Kind.NB 

482 (static HTML export). All notebooks in the folder will be assigned 

483 this kind. 

484 

485 Returns: 

486 A list of Notebook instances, one for each Python file found in the 

487 directory, sorted alphabetically by filename. Returns an empty list 

488 if the folder is None, empty, or contains no Python files. 

489 

490 Raises: 

491 NotebookNotFoundError: If a discovered file does not exist (unlikely 

492 in normal usage but possible in race conditions). 

493 NotebookInvalidError: If a discovered path is not a valid file. 

494 

495 Example:: 

496 

497 from pathlib import Path 

498 from marimushka.notebook import folder2notebooks, Kind 

499 

500 # Get all notebooks from a directory as static HTML exports 

501 notebooks = folder2notebooks(Path("notebooks"), Kind.NB) 

502 

503 # Get all notebooks as interactive apps 

504 apps = folder2notebooks("apps", Kind.APP) 

505 

506 # Handle empty or missing directories gracefully 

507 empty = folder2notebooks(None) # Returns [] 

508 empty = folder2notebooks("") # Returns [] 

509 

510 Examples: 

511 >>> from marimushka.notebook import folder2notebooks 

512 >>> # When folder is None, returns empty list 

513 >>> folder2notebooks(None) 

514 [] 

515 >>> # When folder is empty string, returns empty list 

516 >>> folder2notebooks("") 

517 [] 

518 

519 """ 

520 if folder is None or folder == "": 

521 return [] 

522 

523 notebooks = list(Path(folder).glob("*.py")) 

524 notebooks.sort() 

525 

526 return [Notebook(path=nb, kind=kind) for nb in notebooks]