Coverage for src / marimushka / exceptions.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"""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 dataclasses import dataclass, field
9from pathlib import Path
12class MarimushkaError(Exception):
13 """Base exception for all marimushka errors.
15 All marimushka-specific exceptions inherit from this class,
16 allowing callers to catch all marimushka errors with a single handler.
18 Attributes:
19 message: Human-readable error description.
21 Examples:
22 >>> from marimushka.exceptions import MarimushkaError
23 >>> err = MarimushkaError("Something went wrong")
24 >>> err.message
25 'Something went wrong'
26 >>> str(err)
27 'Something went wrong'
29 """
31 def __init__(self, message: str) -> None:
32 """Initialize the exception.
34 Args:
35 message: Human-readable error description.
37 """
38 self.message = message
39 super().__init__(message)
42class TemplateError(MarimushkaError):
43 """Base exception for template-related errors."""
46class TemplateNotFoundError(TemplateError):
47 """Raised when the specified template file does not exist.
49 Attributes:
50 template_path: Path to the missing template file.
52 """
54 def __init__(self, template_path: Path) -> None:
55 """Initialize the exception.
57 Args:
58 template_path: Path to the missing template file.
60 """
61 self.template_path = template_path
62 super().__init__(f"Template file not found: {template_path}")
65class TemplateInvalidError(TemplateError):
66 """Raised when the template path is not a valid file.
68 Attributes:
69 template_path: Path that is not a valid file.
71 """
73 def __init__(self, template_path: Path, reason: str = "not a file") -> None:
74 """Initialize the exception.
76 Args:
77 template_path: Path that is not a valid file.
78 reason: Explanation of why the path is invalid.
80 """
81 self.template_path = template_path
82 self.reason = reason
83 super().__init__(f"Invalid template path ({reason}): {template_path}")
86class TemplateRenderError(TemplateError):
87 """Raised when template rendering fails.
89 Attributes:
90 template_path: Path to the template that failed to render.
91 original_error: The underlying Jinja2 error.
93 """
95 def __init__(self, template_path: Path, original_error: Exception) -> None:
96 """Initialize the exception.
98 Args:
99 template_path: Path to the template that failed to render.
100 original_error: The underlying Jinja2 error.
102 """
103 self.template_path = template_path
104 self.original_error = original_error
105 super().__init__(f"Failed to render template {template_path}: {original_error}")
108class NotebookError(MarimushkaError):
109 """Base exception for notebook-related errors."""
112class NotebookNotFoundError(NotebookError):
113 """Raised when the specified notebook file does not exist.
115 Attributes:
116 notebook_path: Path to the missing notebook file.
118 """
120 def __init__(self, notebook_path: Path) -> None:
121 """Initialize the exception.
123 Args:
124 notebook_path: Path to the missing notebook file.
126 """
127 self.notebook_path = notebook_path
128 super().__init__(f"Notebook file not found: {notebook_path}")
131class NotebookInvalidError(NotebookError):
132 """Raised when the notebook path is not a valid Python file.
134 Attributes:
135 notebook_path: Path to the invalid notebook.
136 reason: Explanation of why the notebook is invalid.
138 """
140 def __init__(self, notebook_path: Path, reason: str) -> None:
141 """Initialize the exception.
143 Args:
144 notebook_path: Path to the invalid notebook.
145 reason: Explanation of why the notebook is invalid.
147 """
148 self.notebook_path = notebook_path
149 self.reason = reason
150 super().__init__(f"Invalid notebook ({reason}): {notebook_path}")
153class ExportError(MarimushkaError):
154 """Base exception for export-related errors."""
157class ExportExecutableNotFoundError(ExportError):
158 """Raised when the export executable (uvx/marimo) cannot be found.
160 Attributes:
161 executable: Name of the missing executable.
162 search_path: Path where the executable was searched for.
164 """
166 def __init__(self, executable: str, search_path: Path | None = None) -> None:
167 """Initialize the exception.
169 Args:
170 executable: Name of the missing executable.
171 search_path: Path where the executable was searched for.
173 """
174 self.executable = executable
175 self.search_path = search_path
176 if search_path:
177 message = f"Executable '{executable}' not found in {search_path}"
178 else:
179 message = f"Executable '{executable}' not found in PATH"
180 super().__init__(message)
183class ExportSubprocessError(ExportError):
184 """Raised when the export subprocess fails.
186 Attributes:
187 notebook_path: Path to the notebook being exported.
188 command: The command that was executed.
189 return_code: Exit code from the subprocess.
190 stdout: Standard output from the subprocess.
191 stderr: Standard error from the subprocess.
193 """
195 def __init__(
196 self,
197 notebook_path: Path,
198 command: list[str],
199 return_code: int,
200 stdout: str = "",
201 stderr: str = "",
202 ) -> None:
203 """Initialize the exception.
205 Args:
206 notebook_path: Path to the notebook being exported.
207 command: The command that was executed.
208 return_code: Exit code from the subprocess.
209 stdout: Standard output from the subprocess.
210 stderr: Standard error from the subprocess.
212 """
213 self.notebook_path = notebook_path
214 self.command = command
215 self.return_code = return_code
216 self.stdout = stdout
217 self.stderr = stderr
218 message = f"Export failed for {notebook_path.name} (exit code {return_code})"
219 if stderr:
220 message += f": {stderr[:200]}"
221 super().__init__(message)
224class OutputError(MarimushkaError):
225 """Base exception for output-related errors."""
228class IndexWriteError(OutputError):
229 """Raised when the index.html file cannot be written.
231 Attributes:
232 index_path: Path where the index file was to be written.
233 original_error: The underlying OS error.
235 """
237 def __init__(self, index_path: Path, original_error: Exception) -> None:
238 """Initialize the exception.
240 Args:
241 index_path: Path where the index file was to be written.
242 original_error: The underlying OS error.
244 """
245 self.index_path = index_path
246 self.original_error = original_error
247 super().__init__(f"Failed to write index file to {index_path}: {original_error}")
250# Result types for operations that can partially succeed
253@dataclass(frozen=True)
254class NotebookExportResult:
255 """Result of exporting a single notebook.
257 Attributes:
258 notebook_path: Path to the notebook that was exported.
259 success: Whether the export succeeded.
260 output_path: Path to the exported HTML file (if successful).
261 error: The error that occurred (if failed).
263 """
265 notebook_path: Path
266 success: bool
267 output_path: Path | None = None
268 error: ExportError | None = None
270 @classmethod
271 def succeeded(cls, notebook_path: Path, output_path: Path) -> "NotebookExportResult":
272 """Create a successful result.
274 Args:
275 notebook_path: Path to the notebook that was exported.
276 output_path: Path to the exported HTML file.
278 Returns:
279 A NotebookExportResult indicating success.
281 """
282 return cls(notebook_path=notebook_path, success=True, output_path=output_path)
284 @classmethod
285 def failed(cls, notebook_path: Path, error: ExportError) -> "NotebookExportResult":
286 """Create a failed result.
288 Args:
289 notebook_path: Path to the notebook that failed to export.
290 error: The error that occurred.
292 Returns:
293 A NotebookExportResult indicating failure.
295 """
296 return cls(notebook_path=notebook_path, success=False, error=error)
299@dataclass
300class BatchExportResult:
301 """Result of exporting multiple notebooks.
303 Attributes:
304 results: List of individual notebook export results.
305 total: Total number of notebooks attempted.
306 succeeded: Number of successful exports.
307 failed: Number of failed exports.
309 """
311 results: list[NotebookExportResult] = field(default_factory=list)
313 @property
314 def total(self) -> int:
315 """Return total number of notebooks attempted."""
316 return len(self.results)
318 @property
319 def succeeded(self) -> int:
320 """Return number of successful exports."""
321 return sum(1 for r in self.results if r.success)
323 @property
324 def failed(self) -> int:
325 """Return number of failed exports."""
326 return sum(1 for r in self.results if not r.success)
328 @property
329 def all_succeeded(self) -> bool:
330 """Return True if all exports succeeded."""
331 return self.failed == 0
333 @property
334 def failures(self) -> list[NotebookExportResult]:
335 """Return list of failed results."""
336 return [r for r in self.results if not r.success]
338 @property
339 def successes(self) -> list[NotebookExportResult]:
340 """Return list of successful results."""
341 return [r for r in self.results if r.success]
343 def add(self, result: NotebookExportResult) -> None:
344 """Add a result to the batch.
346 Args:
347 result: The export result to add.
349 """
350 self.results.append(result)