Coverage for src / marimushka / exceptions.py: 100%
94 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-28 17:41 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-28 17:41 +0000
1"""Custom exceptions and result types for marimushka.
3This module defines a hierarchy of exceptions for specific error scenarios,
4enabling callers to handle different failure modes appropriately. It also
5defines result types for operations that can partially succeed.
6"""
8from collections.abc import Callable
9from dataclasses import dataclass, field
10from pathlib import Path
12# Progress callback type for API users
13# Called with (completed: int, total: int, notebook_name: str)
14ProgressCallback = Callable[[int, int, str], None]
17class MarimushkaError(Exception):
18 """Base exception for all marimushka errors.
20 All marimushka-specific exceptions inherit from this class,
21 allowing callers to catch all marimushka errors with a single handler.
23 Attributes:
24 message: Human-readable error description.
26 Examples:
27 >>> from marimushka.exceptions import MarimushkaError
28 >>> err = MarimushkaError("Something went wrong")
29 >>> err.message
30 'Something went wrong'
31 >>> str(err)
32 'Something went wrong'
34 """
36 def __init__(self, message: str) -> None:
37 """Initialize the exception.
39 Args:
40 message: Human-readable error description.
42 """
43 self.message = message
44 super().__init__(message)
47class TemplateError(MarimushkaError):
48 """Base exception for template-related errors."""
51class TemplateNotFoundError(TemplateError):
52 """Raised when the specified template file does not exist.
54 Attributes:
55 template_path: Path to the missing template file.
57 """
59 def __init__(self, template_path: Path) -> None:
60 """Initialize the exception.
62 Args:
63 template_path: Path to the missing template file.
65 """
66 self.template_path = template_path
67 super().__init__(f"Template file not found: {template_path}")
70class TemplateInvalidError(TemplateError):
71 """Raised when the template path is not a valid file.
73 Attributes:
74 template_path: Path that is not a valid file.
76 """
78 def __init__(self, template_path: Path, reason: str = "not a file") -> None:
79 """Initialize the exception.
81 Args:
82 template_path: Path that is not a valid file.
83 reason: Explanation of why the path is invalid.
85 """
86 self.template_path = template_path
87 self.reason = reason
88 super().__init__(f"Invalid template path ({reason}): {template_path}")
91class TemplateRenderError(TemplateError):
92 """Raised when template rendering fails.
94 Attributes:
95 template_path: Path to the template that failed to render.
96 original_error: The underlying Jinja2 error.
98 """
100 def __init__(self, template_path: Path, original_error: Exception) -> None:
101 """Initialize the exception.
103 Args:
104 template_path: Path to the template that failed to render.
105 original_error: The underlying Jinja2 error.
107 """
108 self.template_path = template_path
109 self.original_error = original_error
110 super().__init__(f"Failed to render template {template_path}: {original_error}")
113class NotebookError(MarimushkaError):
114 """Base exception for notebook-related errors."""
117class NotebookNotFoundError(NotebookError):
118 """Raised when the specified notebook file does not exist.
120 Attributes:
121 notebook_path: Path to the missing notebook file.
123 """
125 def __init__(self, notebook_path: Path) -> None:
126 """Initialize the exception.
128 Args:
129 notebook_path: Path to the missing notebook file.
131 """
132 self.notebook_path = notebook_path
133 super().__init__(f"Notebook file not found: {notebook_path}")
136class NotebookInvalidError(NotebookError):
137 """Raised when the notebook path is not a valid Python file.
139 Attributes:
140 notebook_path: Path to the invalid notebook.
141 reason: Explanation of why the notebook is invalid.
143 """
145 def __init__(self, notebook_path: Path, reason: str) -> None:
146 """Initialize the exception.
148 Args:
149 notebook_path: Path to the invalid notebook.
150 reason: Explanation of why the notebook is invalid.
152 """
153 self.notebook_path = notebook_path
154 self.reason = reason
155 super().__init__(f"Invalid notebook ({reason}): {notebook_path}")
158class ExportError(MarimushkaError):
159 """Base exception for export-related errors."""
162class ExportExecutableNotFoundError(ExportError):
163 """Raised when the export executable (uvx/marimo) cannot be found.
165 Attributes:
166 executable: Name of the missing executable.
167 search_path: Path where the executable was searched for.
169 """
171 def __init__(self, executable: str, search_path: Path | None = None) -> None:
172 """Initialize the exception.
174 Args:
175 executable: Name of the missing executable.
176 search_path: Path where the executable was searched for.
178 """
179 self.executable = executable
180 self.search_path = search_path
181 if search_path:
182 message = f"Executable '{executable}' not found in {search_path}"
183 else:
184 message = f"Executable '{executable}' not found in PATH"
185 super().__init__(message)
188class ExportSubprocessError(ExportError):
189 """Raised when the export subprocess fails.
191 Attributes:
192 notebook_path: Path to the notebook being exported.
193 command: The command that was executed.
194 return_code: Exit code from the subprocess.
195 stdout: Standard output from the subprocess.
196 stderr: Standard error from the subprocess.
198 """
200 def __init__(
201 self,
202 notebook_path: Path,
203 command: list[str],
204 return_code: int,
205 stdout: str = "",
206 stderr: str = "",
207 ) -> None:
208 """Initialize the exception.
210 Args:
211 notebook_path: Path to the notebook being exported.
212 command: The command that was executed.
213 return_code: Exit code from the subprocess.
214 stdout: Standard output from the subprocess.
215 stderr: Standard error from the subprocess.
217 """
218 self.notebook_path = notebook_path
219 self.command = command
220 self.return_code = return_code
221 self.stdout = stdout
222 self.stderr = stderr
223 message = f"Export failed for {notebook_path.name} (exit code {return_code})"
224 if stderr:
225 message += f": {stderr[:200]}"
226 super().__init__(message)
229class OutputError(MarimushkaError):
230 """Base exception for output-related errors."""
233class IndexWriteError(OutputError):
234 """Raised when the index.html file cannot be written.
236 Attributes:
237 index_path: Path where the index file was to be written.
238 original_error: The underlying OS error.
240 """
242 def __init__(self, index_path: Path, original_error: Exception) -> None:
243 """Initialize the exception.
245 Args:
246 index_path: Path where the index file was to be written.
247 original_error: The underlying OS error.
249 """
250 self.index_path = index_path
251 self.original_error = original_error
252 super().__init__(f"Failed to write index file to {index_path}: {original_error}")
255# Result types for operations that can partially succeed
258@dataclass(frozen=True)
259class NotebookExportResult:
260 """Result of exporting a single notebook.
262 Attributes:
263 notebook_path: Path to the notebook that was exported.
264 success: Whether the export succeeded.
265 output_path: Path to the exported HTML file (if successful).
266 error: The error that occurred (if failed).
268 """
270 notebook_path: Path
271 success: bool
272 output_path: Path | None = None
273 error: ExportError | None = None
275 @classmethod
276 def succeeded(cls, notebook_path: Path, output_path: Path) -> "NotebookExportResult":
277 """Create a successful result.
279 Args:
280 notebook_path: Path to the notebook that was exported.
281 output_path: Path to the exported HTML file.
283 Returns:
284 A NotebookExportResult indicating success.
286 """
287 return cls(notebook_path=notebook_path, success=True, output_path=output_path)
289 @classmethod
290 def failed(cls, notebook_path: Path, error: ExportError) -> "NotebookExportResult":
291 """Create a failed result.
293 Args:
294 notebook_path: Path to the notebook that failed to export.
295 error: The error that occurred.
297 Returns:
298 A NotebookExportResult indicating failure.
300 """
301 return cls(notebook_path=notebook_path, success=False, error=error)
304@dataclass
305class BatchExportResult:
306 """Result of exporting multiple notebooks.
308 Attributes:
309 results: List of individual notebook export results.
310 total: Total number of notebooks attempted.
311 succeeded: Number of successful exports.
312 failed: Number of failed exports.
314 """
316 results: list[NotebookExportResult] = field(default_factory=list)
318 @property
319 def total(self) -> int:
320 """Return total number of notebooks attempted."""
321 return len(self.results)
323 @property
324 def succeeded(self) -> int:
325 """Return number of successful exports."""
326 return sum(1 for r in self.results if r.success)
328 @property
329 def failed(self) -> int:
330 """Return number of failed exports."""
331 return sum(1 for r in self.results if not r.success)
333 @property
334 def all_succeeded(self) -> bool:
335 """Return True if all exports succeeded."""
336 return self.failed == 0
338 @property
339 def failures(self) -> list[NotebookExportResult]:
340 """Return list of failed results."""
341 return [r for r in self.results if not r.success]
343 @property
344 def successes(self) -> list[NotebookExportResult]:
345 """Return list of successful results."""
346 return [r for r in self.results if r.success]
348 def add(self, result: NotebookExportResult) -> None:
349 """Add a result to the batch.
351 Args:
352 result: The export result to add.
354 """
355 self.results.append(result)