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
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-27 07:24 +0000
1"""Notebook module for handling marimo notebooks.
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.
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.
13Export Modes:
14 The module supports three export modes through the Kind enum:
16 - NB (notebook): Exports to static HTML using `marimo export html --sandbox`.
17 Best for documentation and read-only sharing.
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.
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.
27Example::
29 from marimushka.notebook import Notebook, Kind, folder2notebooks
30 from pathlib import Path
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}")
38 # Discover all notebooks in a directory
39 notebooks = folder2notebooks(Path("notebooks"), kind=Kind.NB)
40"""
42import dataclasses
43import os
44import shutil
45import subprocess # nosec B404
46from enum import Enum
47from pathlib import Path
49from loguru import logger
51from .exceptions import (
52 ExportError,
53 ExportExecutableNotFoundError,
54 ExportSubprocessError,
55 NotebookExportResult,
56 NotebookInvalidError,
57 NotebookNotFoundError,
58)
61class Kind(Enum):
62 """Enumeration of notebook export types.
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.
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`
81 """
83 NB = "notebook"
84 NB_WASM = "notebook_wasm"
85 APP = "app"
87 @classmethod
88 def from_str(cls, value: str) -> "Kind":
89 """Represent a factory method to parse a string into a Kind enumeration instance.
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.
95 Args:
96 value (str): A string representing the kind to parse into a Kind instance.
98 Returns:
99 Kind: An instance of the Kind enumeration corresponding to the input string.
101 Raises:
102 ValueError: If the input string does not match any valid Kind value.
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']
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
122 @property
123 def command(self) -> list[str]:
124 """Get the command list associated with a specific Kind instance.
126 The command property returns a list of command strings that correspond
127 to different kinds of operations based on the Kind instance.
129 Attributes:
130 command: A list of strings representing the command.
132 Returns:
133 list[str]: A list of command strings for the corresponding Kind instance.
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']
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]
150 @property
151 def html_path(self) -> Path:
152 """Provide a property to determine the HTML path for different kinds of objects.
154 This property computes the corresponding directory path based on the kind
155 of the object, such as notebooks, notebooks_wasm, or apps.
157 @return: A Path object representing the relevant directory path for the
158 current kind.
160 @rtype: Path
162 Examples:
163 >>> from marimushka.notebook import Kind
164 >>> str(Kind.NB.html_path)
165 'notebooks'
166 >>> str(Kind.APP.html_path)
167 'apps'
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]
178@dataclasses.dataclass(frozen=True)
179class Notebook:
180 """Represents a marimo notebook.
182 This class encapsulates a marimo notebook (.py file) and provides methods
183 for exporting it to HTML/WebAssembly format.
185 Attributes:
186 path (Path): Path to the marimo notebook (.py file)
187 kind (Kind): How the notebook ts treated
189 """
191 path: Path
192 kind: Kind = Kind.NB
194 def __post_init__(self) -> None:
195 """Validate the notebook path after initialization.
197 Raises:
198 NotebookNotFoundError: If the file does not exist.
199 NotebookInvalidError: If the path is not a file or not a Python file.
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")
209 def export(self, output_dir: Path, sandbox: bool = True, bin_path: Path | None = None) -> NotebookExportResult:
210 """Export the notebook to HTML/WebAssembly format.
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.
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.
222 Returns:
223 NotebookExportResult indicating success or failure with details.
225 """
226 executable = "uvx"
227 exe: str | None = None
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
245 cmd = [exe, *self.kind.command]
246 if sandbox:
247 cmd.append("--sandbox")
248 else:
249 cmd.append("--no-sandbox")
251 # Create the full output path and ensure the directory exists
252 output_file: Path = output_dir / f"{self.path.stem}.html"
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)
266 # Add the notebook path and output file to command
267 cmd.extend([str(self.path), "-o", str(output_file)])
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
274 nb_logger = logger.bind(subprocess=f"[{self.path.name}] ")
276 if result.stdout:
277 nb_logger.info(f"stdout:\n{result.stdout.strip()}")
279 if result.stderr:
280 nb_logger.warning(f"stderr:\n{result.stderr.strip()}")
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)
293 return NotebookExportResult.succeeded(self.path, output_file)
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)
310 @property
311 def display_name(self) -> str:
312 """Return the display name for the notebook.
314 The display name is derived from the notebook filename by replacing
315 underscores with spaces, making it more human-readable.
317 Returns:
318 str: Human-friendly display name with underscores replaced by spaces.
320 Examples:
321 >>> # Demonstrating the transformation logic
322 >>> filename = "my_cool_notebook"
323 >>> display_name = filename.replace("_", " ")
324 >>> display_name
325 'my cool notebook'
327 """
328 return self.path.stem.replace("_", " ")
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"
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.
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.
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.
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.
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.
362 Example::
364 from pathlib import Path
365 from marimushka.notebook import folder2notebooks, Kind
367 # Get all notebooks from a directory as static HTML exports
368 notebooks = folder2notebooks(Path("notebooks"), Kind.NB)
370 # Get all notebooks as interactive apps
371 apps = folder2notebooks("apps", Kind.APP)
373 # Handle empty or missing directories gracefully
374 empty = folder2notebooks(None) # Returns []
375 empty = folder2notebooks("") # Returns []
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 []
386 """
387 if folder is None or folder == "":
388 return []
390 notebooks = list(Path(folder).glob("*.py"))
391 notebooks.sort()
393 return [Notebook(path=nb, kind=kind) for nb in notebooks]