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

92 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-01-27 07:24 +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 .exceptions import ( 

52 ExportError, 

53 ExportExecutableNotFoundError, 

54 ExportSubprocessError, 

55 NotebookExportResult, 

56 NotebookInvalidError, 

57 NotebookNotFoundError, 

58) 

59 

60 

61class Kind(Enum): 

62 """Enumeration of notebook export types. 

63 

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

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

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

67 

68 Attributes: 

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

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

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

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

73 fully interactive notebook that runs in the browser with code 

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

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

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

77 interface with code hidden from users. Ideal for dashboards 

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

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

80 

81 """ 

82 

83 NB = "notebook" 

84 NB_WASM = "notebook_wasm" 

85 APP = "app" 

86 

87 @classmethod 

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

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

90 

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

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

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

94 

95 Args: 

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

97 

98 Returns: 

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

100 

101 Raises: 

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

103 

104 Examples: 

105 >>> from marimushka.notebook import Kind 

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

107 <Kind.NB: 'notebook'> 

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

109 <Kind.APP: 'app'> 

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

111 Traceback (most recent call last): 

112 ... 

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

114 

115 """ 

116 try: 

117 return Kind(value) 

118 except ValueError as e: 

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

120 raise ValueError(msg) from e 

121 

122 @property 

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

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

125 

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

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

128 

129 Attributes: 

130 command: A list of strings representing the command. 

131 

132 Returns: 

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

134 

135 Examples: 

136 >>> from marimushka.notebook import Kind 

137 >>> Kind.NB.command 

138 ['marimo', 'export', 'html'] 

139 >>> Kind.APP.command 

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

141 

142 """ 

143 commands = { 

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

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

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

147 } 

148 return commands[self] 

149 

150 @property 

151 def html_path(self) -> Path: 

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

153 

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

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

156 

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

158 current kind. 

159 

160 @rtype: Path 

161 

162 Examples: 

163 >>> from marimushka.notebook import Kind 

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

165 'notebooks' 

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

167 'apps' 

168 

169 """ 

170 paths = { 

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

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

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

174 } 

175 return paths[self] 

176 

177 

178@dataclasses.dataclass(frozen=True) 

179class Notebook: 

180 """Represents a marimo notebook. 

181 

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

183 for exporting it to HTML/WebAssembly format. 

184 

185 Attributes: 

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

187 kind (Kind): How the notebook ts treated 

188 

189 """ 

190 

191 path: Path 

192 kind: Kind = Kind.NB 

193 

194 def __post_init__(self) -> None: 

195 """Validate the notebook path after initialization. 

196 

197 Raises: 

198 NotebookNotFoundError: If the file does not exist. 

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

200 

201 """ 

202 if not self.path.exists(): 

203 raise NotebookNotFoundError(self.path) 

204 if not self.path.is_file(): 

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

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

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

208 

209 def export(self, output_dir: Path, sandbox: bool = True, bin_path: Path | None = None) -> NotebookExportResult: 

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

211 

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

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

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

215 suitable for interactive notebooks. 

216 

217 Args: 

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

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

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

221 

222 Returns: 

223 NotebookExportResult indicating success or failure with details. 

224 

225 """ 

226 executable = "uvx" 

227 exe: str | None = None 

228 

229 if bin_path: 

230 # Construct the full executable path 

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

232 exe = shutil.which(executable, path=str(bin_path)) 

233 if not exe: 

234 # Fallback: try constructing the path directly 

235 exe_path = bin_path / executable 

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

237 exe = str(exe_path) 

238 else: 

239 err: ExportError = ExportExecutableNotFoundError(executable, bin_path) 

240 logger.error(str(err)) 

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

242 else: 

243 exe = executable 

244 

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

246 if sandbox: 

247 cmd.append("--sandbox") 

248 else: 

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

250 

251 # Create the full output path and ensure the directory exists 

252 output_file: Path = output_dir / f"{self.path.stem}.html" 

253 

254 try: 

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

256 except OSError as e: # pragma: no cover 

257 err = ExportSubprocessError( 

258 notebook_path=self.path, 

259 command=cmd, 

260 return_code=-1, 

261 stderr=f"Failed to create output directory: {e}", 

262 ) 

263 logger.error(str(err)) 

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

265 

266 # Add the notebook path and output file to command 

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

268 

269 try: 

270 # Run marimo export command 

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

272 result = subprocess.run(cmd, capture_output=True, text=True, check=False) # nosec B603 

273 

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

275 

276 if result.stdout: 

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

278 

279 if result.stderr: 

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

281 

282 if result.returncode != 0: 

283 err = ExportSubprocessError( 

284 notebook_path=self.path, 

285 command=cmd, 

286 return_code=result.returncode, 

287 stdout=result.stdout, 

288 stderr=result.stderr, 

289 ) 

290 nb_logger.error(str(err)) 

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

292 

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

294 

295 except FileNotFoundError as e: 

296 # Executable not found in PATH 

297 err = ExportExecutableNotFoundError(executable) 

298 logger.error(f"{err}: {e}") 

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

300 except subprocess.SubprocessError as e: 

301 err = ExportSubprocessError( 

302 notebook_path=self.path, 

303 command=cmd, 

304 return_code=-1, 

305 stderr=str(e), 

306 ) 

307 logger.error(str(err)) 

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

309 

310 @property 

311 def display_name(self) -> str: 

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

313 

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

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

316 

317 Returns: 

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

319 

320 Examples: 

321 >>> # Demonstrating the transformation logic 

322 >>> filename = "my_cool_notebook" 

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

324 >>> display_name 

325 'my cool notebook' 

326 

327 """ 

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

329 

330 @property 

331 def html_path(self) -> Path: 

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

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

334 

335 

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

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

338 

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

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

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

342 filename to ensure consistent ordering across runs. 

343 

344 Args: 

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

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

347 an empty list. 

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

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

350 this kind. 

351 

352 Returns: 

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

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

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

356 

357 Raises: 

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

359 in normal usage but possible in race conditions). 

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

361 

362 Example:: 

363 

364 from pathlib import Path 

365 from marimushka.notebook import folder2notebooks, Kind 

366 

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

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

369 

370 # Get all notebooks as interactive apps 

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

372 

373 # Handle empty or missing directories gracefully 

374 empty = folder2notebooks(None) # Returns [] 

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

376 

377 Examples: 

378 >>> from marimushka.notebook import folder2notebooks 

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

380 >>> folder2notebooks(None) 

381 [] 

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

383 >>> folder2notebooks("") 

384 [] 

385 

386 """ 

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

388 return [] 

389 

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

391 notebooks.sort() 

392 

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