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

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 dataclasses import dataclass, field 

9from pathlib import Path 

10 

11 

12class MarimushkaError(Exception): 

13 """Base exception for all marimushka errors. 

14 

15 All marimushka-specific exceptions inherit from this class, 

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

17 

18 Attributes: 

19 message: Human-readable error description. 

20 

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' 

28 

29 """ 

30 

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

32 """Initialize the exception. 

33 

34 Args: 

35 message: Human-readable error description. 

36 

37 """ 

38 self.message = message 

39 super().__init__(message) 

40 

41 

42class TemplateError(MarimushkaError): 

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

44 

45 

46class TemplateNotFoundError(TemplateError): 

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

48 

49 Attributes: 

50 template_path: Path to the missing template file. 

51 

52 """ 

53 

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

55 """Initialize the exception. 

56 

57 Args: 

58 template_path: Path to the missing template file. 

59 

60 """ 

61 self.template_path = template_path 

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

63 

64 

65class TemplateInvalidError(TemplateError): 

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

67 

68 Attributes: 

69 template_path: Path that is not a valid file. 

70 

71 """ 

72 

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

74 """Initialize the exception. 

75 

76 Args: 

77 template_path: Path that is not a valid file. 

78 reason: Explanation of why the path is invalid. 

79 

80 """ 

81 self.template_path = template_path 

82 self.reason = reason 

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

84 

85 

86class TemplateRenderError(TemplateError): 

87 """Raised when template rendering fails. 

88 

89 Attributes: 

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

91 original_error: The underlying Jinja2 error. 

92 

93 """ 

94 

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

96 """Initialize the exception. 

97 

98 Args: 

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

100 original_error: The underlying Jinja2 error. 

101 

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

106 

107 

108class NotebookError(MarimushkaError): 

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

110 

111 

112class NotebookNotFoundError(NotebookError): 

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

114 

115 Attributes: 

116 notebook_path: Path to the missing notebook file. 

117 

118 """ 

119 

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

121 """Initialize the exception. 

122 

123 Args: 

124 notebook_path: Path to the missing notebook file. 

125 

126 """ 

127 self.notebook_path = notebook_path 

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

129 

130 

131class NotebookInvalidError(NotebookError): 

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

133 

134 Attributes: 

135 notebook_path: Path to the invalid notebook. 

136 reason: Explanation of why the notebook is invalid. 

137 

138 """ 

139 

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

141 """Initialize the exception. 

142 

143 Args: 

144 notebook_path: Path to the invalid notebook. 

145 reason: Explanation of why the notebook is invalid. 

146 

147 """ 

148 self.notebook_path = notebook_path 

149 self.reason = reason 

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

151 

152 

153class ExportError(MarimushkaError): 

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

155 

156 

157class ExportExecutableNotFoundError(ExportError): 

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

159 

160 Attributes: 

161 executable: Name of the missing executable. 

162 search_path: Path where the executable was searched for. 

163 

164 """ 

165 

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

167 """Initialize the exception. 

168 

169 Args: 

170 executable: Name of the missing executable. 

171 search_path: Path where the executable was searched for. 

172 

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) 

181 

182 

183class ExportSubprocessError(ExportError): 

184 """Raised when the export subprocess fails. 

185 

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. 

192 

193 """ 

194 

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. 

204 

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. 

211 

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) 

222 

223 

224class OutputError(MarimushkaError): 

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

226 

227 

228class IndexWriteError(OutputError): 

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

230 

231 Attributes: 

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

233 original_error: The underlying OS error. 

234 

235 """ 

236 

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

238 """Initialize the exception. 

239 

240 Args: 

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

242 original_error: The underlying OS error. 

243 

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

248 

249 

250# Result types for operations that can partially succeed 

251 

252 

253@dataclass(frozen=True) 

254class NotebookExportResult: 

255 """Result of exporting a single notebook. 

256 

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

262 

263 """ 

264 

265 notebook_path: Path 

266 success: bool 

267 output_path: Path | None = None 

268 error: ExportError | None = None 

269 

270 @classmethod 

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

272 """Create a successful result. 

273 

274 Args: 

275 notebook_path: Path to the notebook that was exported. 

276 output_path: Path to the exported HTML file. 

277 

278 Returns: 

279 A NotebookExportResult indicating success. 

280 

281 """ 

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

283 

284 @classmethod 

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

286 """Create a failed result. 

287 

288 Args: 

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

290 error: The error that occurred. 

291 

292 Returns: 

293 A NotebookExportResult indicating failure. 

294 

295 """ 

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

297 

298 

299@dataclass 

300class BatchExportResult: 

301 """Result of exporting multiple notebooks. 

302 

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. 

308 

309 """ 

310 

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

312 

313 @property 

314 def total(self) -> int: 

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

316 return len(self.results) 

317 

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) 

322 

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) 

327 

328 @property 

329 def all_succeeded(self) -> bool: 

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

331 return self.failed == 0 

332 

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] 

337 

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] 

342 

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

344 """Add a result to the batch. 

345 

346 Args: 

347 result: The export result to add. 

348 

349 """ 

350 self.results.append(result)