Coverage for sources/librovore/exceptions.py: 46%

175 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-02 00:02 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Family of exceptions for package API. ''' 

22 

23 

24import urllib.parse as _urlparse 

25 

26from . import __ 

27 

28 

29class Omniexception( __.immut.Object, BaseException ): 

30 ''' Base for all exceptions raised by package API. ''' 

31 

32 _attribute_visibility_includes_: __.cabc.Collection[ str ] = ( 

33 frozenset( ( '__cause__', '__context__', ) ) ) 

34 

35 

36class Omnierror( Omniexception, Exception ): 

37 ''' Base for error exceptions with self-rendering capability. ''' 

38 

39 @__.abc.abstractmethod 

40 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]: 

41 ''' Renders exception as JSON-compatible dictionary. ''' 

42 raise NotImplementedError 

43 

44 @__.abc.abstractmethod 

45 def render_as_markdown( 

46 self, /, *, 

47 reveal_internals: __.typx.Annotated[ 

48 bool, 

49 __.ddoc.Doc( ''' 

50 Controls whether implementation-specific details (internal  

51 field names, version numbers, priority scores) are included.  

52 When False, only user-facing information is shown. 

53 ''' ), 

54 ] = True, 

55 ) -> tuple[ str, ... ]: 

56 ''' Renders exception as Markdown lines for display. ''' 

57 raise NotImplementedError 

58 

59 

60class DetectionConfidenceInvalidity( Omnierror, ValueError ): 

61 ''' Detection confidence value is out of valid range. ''' 

62 

63 def __init__( self, confidence: float ): 

64 self.confidence = confidence 

65 super( ).__init__( f"Confidence {confidence} not in range [0.0, 1.0]" ) 

66 

67 

68class DocumentationContentAbsence( Omnierror, ValueError ): 

69 ''' Documentation main content container not found. ''' 

70 

71 def __init__( self, url: str ): 

72 message = f"No main content found in documentation at '{url}'." 

73 self.url = url 

74 super( ).__init__( message ) 

75 

76 

77class DocumentationInaccessibility( Omnierror, RuntimeError ): 

78 ''' Documentation file or resource absent or inaccessible. ''' 

79 

80 def __init__( self, url: str, cause: str | Exception ): 

81 message = f"Documentation at '{url}' is inaccessible. Cause: {cause}" 

82 self.url = url 

83 super( ).__init__( message ) 

84 

85 

86class DocumentationObjectAbsence( Omnierror, ValueError ): 

87 ''' Requested object not found in documentation page. ''' 

88 

89 def __init__( self, object_id: str, url: str ): 

90 message = ( 

91 f"Object '{object_id}' not found in documentation page " 

92 f"at '{url}'" ) 

93 self.object_id = object_id 

94 self.url = url 

95 super( ).__init__( message ) 

96 

97 

98class DocumentationParseFailure( Omnierror, ValueError ): 

99 ''' Documentation HTML parsing failed or content malformed. ''' 

100 

101 def __init__( self, url: str, cause: str | Exception ): 

102 message = f"Cannot parse documentation at '{url}'. Cause: {cause}" 

103 self.url = url 

104 super( ).__init__( message ) 

105 

106 

107class ExtensionCacheFailure( Omnierror, RuntimeError ): 

108 ''' Extension cache operation failed. ''' 

109 

110 def __init__( self, cache_path: __.Path, message: str ): 

111 self.cache_path = cache_path 

112 super( ).__init__( f"Cache error at '{cache_path}': {message}" ) 

113 

114 

115class ExtensionConfigurationInvalidity( Omnierror, ValueError ): 

116 ''' Extension configuration is invalid. ''' 

117 

118 def __init__( self, extension_name: str, message: str ): 

119 self.extension_name = extension_name 

120 super( ).__init__( f"Extension '{extension_name}': {message}" ) 

121 

122 

123class ExtensionInstallFailure( Omnierror, RuntimeError ): 

124 ''' Extension package installation failed. ''' 

125 

126 def __init__( self, package_spec: str, message: str ): 

127 self.package_spec = package_spec 

128 super( ).__init__( f"Failed to install '{package_spec}': {message}" ) 

129 

130 

131class ExtensionRegisterFailure( Omnierror, TypeError ): 

132 ''' Invalid plugin could not be registered. ''' 

133 

134 def __init__( self, message: str ): 

135 # TODO: Canned message with extension name as argument. 

136 super( ).__init__( message ) 

137 

138 

139class ExtensionVersionConflict( Omnierror, ImportError ): 

140 ''' Extension has incompatible version requirements. ''' 

141 

142 def __init__( self, package_name: str, required: str, available: str ): 

143 self.package_name = package_name 

144 self.required = required 

145 self.available = available 

146 super( ).__init__( 

147 f"Version conflict for '{package_name}': " 

148 f"required {required}, available {available}" ) 

149 

150 

151class HttpContentTypeInvalidity( Omnierror, ValueError ): 

152 ''' HTTP content type is not suitable for requested operation. ''' 

153 

154 def __init__( self, url: str, content_type: str, operation: str ): 

155 self.url = url 

156 self.content_type = content_type 

157 self.operation = operation 

158 super( ).__init__( 

159 f"Content type '{content_type}' not suitable for {operation} " 

160 f"operation on URL: {url}" ) 

161 

162 

163class InventoryFilterInvalidity( Omnierror, ValueError ): 

164 ''' Inventory filter is invalid. ''' 

165 

166 def __init__( self, message: str ): 

167 super( ).__init__( message ) 

168 

169 

170class InventoryInaccessibility( Omnierror, RuntimeError ): 

171 ''' Inventory file or resource absent or inaccessible. ''' 

172 

173 def __init__( 

174 self, 

175 source: str, 

176 cause: __.typx.Optional[ BaseException ] = None, 

177 ): 

178 self.source = source 

179 self.cause = cause 

180 message = f"Inventory at '{source}' is inaccessible." 

181 if cause is not None: 181 ↛ 183line 181 didn't jump to line 183 because the condition on line 181 was always true

182 message += f" Cause: {cause}" 

183 super( ).__init__( message ) 

184 

185 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]: 

186 ''' Renders inventory inaccessibility as JSON-compatible dict. ''' 

187 return __.immut.Dictionary[ 

188 str, __.typx.Any 

189 ]( 

190 type = 'inventory_inaccessible', 

191 title = 'Inventory Location Inaccessible', 

192 message = str( self ), 

193 source = self.source, 

194 cause = str( self.cause ) if self.cause is not None else None, 

195 suggestion = ( 

196 'Check that the URL is correct and the documentation site is ' 

197 'accessible.' ), 

198 ) 

199 

200 def render_as_markdown( 

201 self, /, *, 

202 reveal_internals: bool = True, 

203 ) -> tuple[ str, ... ]: 

204 ''' Renders inventory inaccessibility as Markdown lines. ''' 

205 lines = [ "## Error: Inventory Location Inaccessible" ] 

206 lines.append( f"**Message:** {self}" ) 

207 lines.append( 

208 "**Suggestion:** Check that the URL is correct and the " 

209 "documentation site is accessible." ) 

210 if reveal_internals: 

211 lines.append( f"**Source:** {self.source}" ) 

212 if self.cause is not None: 

213 lines.append( f"**Cause:** {self.cause}" ) 

214 lines.append( "**Error Type:** inventory_inaccessible" ) 

215 return tuple( lines ) 

216 

217 

218class InventoryInvalidity( Omnierror, ValueError ): 

219 ''' Inventory has invalid format or cannot be parsed. ''' 

220 

221 def __init__( 

222 self, 

223 source: str, 

224 cause: str | Exception, 

225 ): 

226 self.source = source 

227 self.cause = cause 

228 message = f"Inventory at '{source}' is invalid. Cause: {cause}" 

229 super( ).__init__( message ) 

230 

231 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]: 

232 ''' Renders inventory invalidity as JSON-compatible dict. ''' 

233 return __.immut.Dictionary[ 

234 str, __.typx.Any 

235 ]( 

236 type = 'inventory_invalid', 

237 title = 'Invalid Inventory Format', 

238 message = str( self ), 

239 source = self.source, 

240 cause = str( self.cause ), 

241 suggestion = ( 

242 'Verify that the inventory format is supported and the file ' 

243 'is not corrupted.' ), 

244 ) 

245 

246 def render_as_markdown( 

247 self, /, *, 

248 reveal_internals: bool = True, 

249 ) -> tuple[ str, ... ]: 

250 ''' Renders inventory invalidity as Markdown lines. ''' 

251 lines = [ "## Error: Invalid Inventory Format" ] 

252 lines.append( f"**Message:** {self}" ) 

253 lines.append( 

254 "**Suggestion:** Verify that the inventory format is supported " 

255 "and the file is not corrupted." ) 

256 if reveal_internals: 

257 lines.append( f"**Source:** {self.source}" ) 

258 lines.append( f"**Cause:** {self.cause}" ) 

259 lines.append( "**Error Type:** inventory_invalid" ) 

260 return tuple( lines ) 

261 

262 

263class InventoryUrlInvalidity( Omnierror, ValueError ): 

264 ''' Inventory URL is malformed or invalid. ''' 

265 

266 def __init__( self, source: str ): 

267 message = f"Invalid URL format: {source}" 

268 self.source = source 

269 super( ).__init__( message ) 

270 

271 

272class InventoryUrlNoSupport( Omnierror, NotImplementedError ): 

273 ''' Inventory URL has unsupported component. ''' 

274 

275 def __init__( 

276 self, url: _urlparse.ParseResult, component: str, 

277 value: __.Absential[ str ] = __.absent, 

278 ): 

279 url_s = _urlparse.urlunparse( url ) 

280 message_c = f"Component '{component}' " 

281 message_i = f"not supported in inventory URL '{url_s}'." 

282 message = ( 

283 f"{message_c} {message_i}" if __.is_absent( value ) 

284 else f"{message_c} with value '{value}' {message_i}" ) 

285 self.url = url 

286 super( ).__init__( message ) 

287 

288 

289class ProcessorGenusInvalidity( Omnierror, ValueError ): 

290 ''' Invalid processor genus provided. ''' 

291 

292 def __init__( self, genus: __.typx.Any ): 

293 message = f"Invalid ProcessorGenera: {genus}" 

294 self.genus = genus 

295 super( ).__init__( message ) 

296 

297 

298class ProcessorInavailability( Omnierror, RuntimeError ): 

299 ''' No processor found to handle source. ''' 

300 

301 def __init__( 

302 self, 

303 source: str, 

304 genus: __.Absential[ str ] = __.absent, 

305 ): 

306 self.source = source 

307 self.genus = genus 

308 message = f"No processor found to handle source: {source}" 

309 if not __.is_absent( genus ): 

310 message += f" (genus: {genus})" 

311 super( ).__init__( message ) 

312 

313 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]: 

314 ''' Renders processor unavailability as JSON-compatible dictionary. ''' 

315 return __.immut.Dictionary[ 

316 str, __.typx.Any 

317 ]( 

318 type = 'processor_unavailable', 

319 title = 'No Compatible Processor Found', 

320 message = str( self ), 

321 source = self.source, 

322 genus = self.genus if not __.is_absent( self.genus ) else None, 

323 suggestion = ( 

324 'Verify the URL points to a supported documentation format.' ), 

325 ) 

326 

327 def render_as_markdown( 

328 self, /, *, 

329 reveal_internals: bool = True, 

330 ) -> tuple[ str, ... ]: 

331 ''' Renders processor unavailability as Markdown lines for display. ''' 

332 lines = [ "## Error: No Compatible Processor Found" ] 

333 lines.append( f"**Message:** {self}" ) 

334 lines.append( 

335 "**Suggestion:** Verify the URL points to a supported " 

336 "documentation format." ) 

337 if reveal_internals: 

338 lines.append( f"**Source:** {self.source}" ) 

339 if not __.is_absent( self.genus ): 

340 lines.append( f"**Genus:** {self.genus}" ) 

341 lines.append( "**Error Type:** processor_unavailable" ) 

342 return tuple( lines ) 

343 

344 

345class ProcessorInvalidity( Omnierror, TypeError ): 

346 ''' Processor has wrong type. ''' 

347 

348 def __init__( self, expected: str, actual: type ): 

349 message = f"Expected {expected}, got {actual}." 

350 self.expected_type = expected 

351 self.actual_type = actual 

352 super( ).__init__( message ) 

353 

354 

355 

356 

357class StructureIncompatibility( Omnierror, ValueError ): 

358 ''' Documentation structure incompatible with processor. ''' 

359 

360 def __init__( self, processor_name: str, source: str ): 

361 self.processor_name = processor_name 

362 self.source = source 

363 super( ).__init__( 

364 f"No content extracted by {processor_name} from {source}. " 

365 f"The documentation structure may be incompatible with " 

366 f"this processor." ) 

367 

368 

369class StructureProcessFailure( Omnierror, RuntimeError ): 

370 ''' Structure processor failed to complete processing. ''' 

371 

372 def __init__( self, processor_name: str, source: str, cause: str ): 

373 self.processor_name = processor_name 

374 self.source = source 

375 super( ).__init__( 

376 f"Processor {processor_name} failed processing {source}. " 

377 f"Cause: {cause}" ) 

378 

379 

380class ContentExtractFailure( StructureProcessFailure ): 

381 ''' Failed to extract meaningful content from documentation. ''' 

382 

383 def __init__( 

384 self, 

385 processor_name: str, 

386 source: str, 

387 meaningful_results: int, 

388 requested_objects: int, 

389 ): 

390 self.processor_name = processor_name 

391 self.source = source 

392 self.meaningful_results = meaningful_results 

393 self.requested_objects = requested_objects 

394 cause = ( 

395 f"Got {meaningful_results} meaningful results from " 

396 f"{requested_objects} requested objects. " 

397 f"This may indicate incompatible theme or documentation " 

398 f"structure." ) 

399 super( ).__init__( processor_name, source, cause ) 

400 

401 

402class ThemeDetectFailure( StructureProcessFailure ): 

403 ''' Theme detection failed during processing. ''' 

404 

405 def __init__( self, processor_name: str, source: str, theme_error: str ): 

406 self.theme_error = theme_error 

407 super( ).__init__( 

408 processor_name, source, f"Theme detection failed: {theme_error}" ) 

409 

410 

411class UrlImpermissibility( Omnierror, PermissionError ): 

412 ''' URL access blocked by robots.txt directive. ''' 

413 

414 def __init__( self, url: str, user_agent: str ): 

415 message = ( 

416 f"URL '{url}' blocked by robots.txt for " 

417 f"user agent '{user_agent}'" ) 

418 self.url = url 

419 self.user_agent = user_agent 

420 super( ).__init__( message )