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

1"""Custom exceptions and result types for marimushka. 

2 

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""" 

7 

8from collections.abc import Callable 

9from dataclasses import dataclass, field 

10from pathlib import Path 

11 

12# Progress callback type for API users 

13# Called with (completed: int, total: int, notebook_name: str) 

14ProgressCallback = Callable[[int, int, str], None] 

15 

16 

17class MarimushkaError(Exception): 

18 """Base exception for all marimushka errors. 

19 

20 All marimushka-specific exceptions inherit from this class, 

21 allowing callers to catch all marimushka errors with a single handler. 

22 

23 Attributes: 

24 message: Human-readable error description. 

25 

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' 

33 

34 """ 

35 

36 def __init__(self, message: str) -> None: 

37 """Initialize the exception. 

38 

39 Args: 

40 message: Human-readable error description. 

41 

42 """ 

43 self.message = message 

44 super().__init__(message) 

45 

46 

47class TemplateError(MarimushkaError): 

48 """Base exception for template-related errors.""" 

49 

50 

51class TemplateNotFoundError(TemplateError): 

52 """Raised when the specified template file does not exist. 

53 

54 Attributes: 

55 template_path: Path to the missing template file. 

56 

57 """ 

58 

59 def __init__(self, template_path: Path) -> None: 

60 """Initialize the exception. 

61 

62 Args: 

63 template_path: Path to the missing template file. 

64 

65 """ 

66 self.template_path = template_path 

67 super().__init__(f"Template file not found: {template_path}") 

68 

69 

70class TemplateInvalidError(TemplateError): 

71 """Raised when the template path is not a valid file. 

72 

73 Attributes: 

74 template_path: Path that is not a valid file. 

75 

76 """ 

77 

78 def __init__(self, template_path: Path, reason: str = "not a file") -> None: 

79 """Initialize the exception. 

80 

81 Args: 

82 template_path: Path that is not a valid file. 

83 reason: Explanation of why the path is invalid. 

84 

85 """ 

86 self.template_path = template_path 

87 self.reason = reason 

88 super().__init__(f"Invalid template path ({reason}): {template_path}") 

89 

90 

91class TemplateRenderError(TemplateError): 

92 """Raised when template rendering fails. 

93 

94 Attributes: 

95 template_path: Path to the template that failed to render. 

96 original_error: The underlying Jinja2 error. 

97 

98 """ 

99 

100 def __init__(self, template_path: Path, original_error: Exception) -> None: 

101 """Initialize the exception. 

102 

103 Args: 

104 template_path: Path to the template that failed to render. 

105 original_error: The underlying Jinja2 error. 

106 

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}") 

111 

112 

113class NotebookError(MarimushkaError): 

114 """Base exception for notebook-related errors.""" 

115 

116 

117class NotebookNotFoundError(NotebookError): 

118 """Raised when the specified notebook file does not exist. 

119 

120 Attributes: 

121 notebook_path: Path to the missing notebook file. 

122 

123 """ 

124 

125 def __init__(self, notebook_path: Path) -> None: 

126 """Initialize the exception. 

127 

128 Args: 

129 notebook_path: Path to the missing notebook file. 

130 

131 """ 

132 self.notebook_path = notebook_path 

133 super().__init__(f"Notebook file not found: {notebook_path}") 

134 

135 

136class NotebookInvalidError(NotebookError): 

137 """Raised when the notebook path is not a valid Python file. 

138 

139 Attributes: 

140 notebook_path: Path to the invalid notebook. 

141 reason: Explanation of why the notebook is invalid. 

142 

143 """ 

144 

145 def __init__(self, notebook_path: Path, reason: str) -> None: 

146 """Initialize the exception. 

147 

148 Args: 

149 notebook_path: Path to the invalid notebook. 

150 reason: Explanation of why the notebook is invalid. 

151 

152 """ 

153 self.notebook_path = notebook_path 

154 self.reason = reason 

155 super().__init__(f"Invalid notebook ({reason}): {notebook_path}") 

156 

157 

158class ExportError(MarimushkaError): 

159 """Base exception for export-related errors.""" 

160 

161 

162class ExportExecutableNotFoundError(ExportError): 

163 """Raised when the export executable (uvx/marimo) cannot be found. 

164 

165 Attributes: 

166 executable: Name of the missing executable. 

167 search_path: Path where the executable was searched for. 

168 

169 """ 

170 

171 def __init__(self, executable: str, search_path: Path | None = None) -> None: 

172 """Initialize the exception. 

173 

174 Args: 

175 executable: Name of the missing executable. 

176 search_path: Path where the executable was searched for. 

177 

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) 

186 

187 

188class ExportSubprocessError(ExportError): 

189 """Raised when the export subprocess fails. 

190 

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. 

197 

198 """ 

199 

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. 

209 

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. 

216 

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) 

227 

228 

229class OutputError(MarimushkaError): 

230 """Base exception for output-related errors.""" 

231 

232 

233class IndexWriteError(OutputError): 

234 """Raised when the index.html file cannot be written. 

235 

236 Attributes: 

237 index_path: Path where the index file was to be written. 

238 original_error: The underlying OS error. 

239 

240 """ 

241 

242 def __init__(self, index_path: Path, original_error: Exception) -> None: 

243 """Initialize the exception. 

244 

245 Args: 

246 index_path: Path where the index file was to be written. 

247 original_error: The underlying OS error. 

248 

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}") 

253 

254 

255# Result types for operations that can partially succeed 

256 

257 

258@dataclass(frozen=True) 

259class NotebookExportResult: 

260 """Result of exporting a single notebook. 

261 

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). 

267 

268 """ 

269 

270 notebook_path: Path 

271 success: bool 

272 output_path: Path | None = None 

273 error: ExportError | None = None 

274 

275 @classmethod 

276 def succeeded(cls, notebook_path: Path, output_path: Path) -> "NotebookExportResult": 

277 """Create a successful result. 

278 

279 Args: 

280 notebook_path: Path to the notebook that was exported. 

281 output_path: Path to the exported HTML file. 

282 

283 Returns: 

284 A NotebookExportResult indicating success. 

285 

286 """ 

287 return cls(notebook_path=notebook_path, success=True, output_path=output_path) 

288 

289 @classmethod 

290 def failed(cls, notebook_path: Path, error: ExportError) -> "NotebookExportResult": 

291 """Create a failed result. 

292 

293 Args: 

294 notebook_path: Path to the notebook that failed to export. 

295 error: The error that occurred. 

296 

297 Returns: 

298 A NotebookExportResult indicating failure. 

299 

300 """ 

301 return cls(notebook_path=notebook_path, success=False, error=error) 

302 

303 

304@dataclass 

305class BatchExportResult: 

306 """Result of exporting multiple notebooks. 

307 

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. 

313 

314 """ 

315 

316 results: list[NotebookExportResult] = field(default_factory=list) 

317 

318 @property 

319 def total(self) -> int: 

320 """Return total number of notebooks attempted.""" 

321 return len(self.results) 

322 

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) 

327 

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) 

332 

333 @property 

334 def all_succeeded(self) -> bool: 

335 """Return True if all exports succeeded.""" 

336 return self.failed == 0 

337 

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] 

342 

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] 

347 

348 def add(self, result: NotebookExportResult) -> None: 

349 """Add a result to the batch. 

350 

351 Args: 

352 result: The export result to add. 

353 

354 """ 

355 self.results.append(result)