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

241 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-28 22:09 +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 

355class RobotsTxtAccessFailure( Omnierror, RuntimeError ): 

356 ''' Robots.txt file access failure (network issue, not policy). ''' 

357 

358 def __init__( self, domain: str, cause: BaseException ): 

359 message = ( 

360 f"Failed to access robots.txt at '{domain}' due to network issue: " 

361 f"{cause}" ) 

362 self.domain = domain 

363 self.cause = cause 

364 super( ).__init__( message ) 

365 

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

367 ''' Renders robots.txt access failure as JSON-compatible dict. ''' 

368 return __.immut.Dictionary[ 

369 str, __.typx.Any 

370 ]( 

371 type = 'robots_txt_access_failure', 

372 title = 'Robots.txt Access Failure', 

373 message = str( self ), 

374 domain = self.domain, 

375 cause = str( self.cause ), 

376 suggestion = ( 

377 'This is likely a temporary network issue or server ' 

378 'configuration problem. The site content may still be ' 

379 'accessible.' ), 

380 ) 

381 

382 def render_as_markdown( 

383 self, /, *, 

384 reveal_internals: bool = True, 

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

386 ''' Renders robots.txt access failure as Markdown lines. ''' 

387 lines = [ "## Error: Robots.txt Access Failure" ] 

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

389 lines.append( 

390 "**Suggestion:** This is likely a temporary network issue or " 

391 "server configuration problem. The site content may still be " 

392 "accessible." ) 

393 if reveal_internals: 

394 lines.append( f"**Domain:** {self.domain}" ) 

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

396 lines.append( "**Error Type:** robots_txt_access_failure" ) 

397 return tuple( lines ) 

398 

399 

400class StructureIncompatibility( Omnierror, ValueError ): 

401 ''' Documentation structure incompatible with processor. ''' 

402 

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

404 self.processor_name = processor_name 

405 self.source = source 

406 super( ).__init__( 

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

408 f"The documentation structure may be incompatible with " 

409 f"this processor." ) 

410 

411 

412class StructureProcessFailure( Omnierror, RuntimeError ): 

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

414 

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

416 self.processor_name = processor_name 

417 self.source = source 

418 super( ).__init__( 

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

420 f"Cause: {cause}" ) 

421 

422 

423class ContentExtractFailure( StructureProcessFailure ): 

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

425 

426 def __init__( 

427 self, 

428 processor_name: str, 

429 source: str, 

430 meaningful_results: int, 

431 requested_objects: int, 

432 ): 

433 self.processor_name = processor_name 

434 self.source = source 

435 self.meaningful_results = meaningful_results 

436 self.requested_objects = requested_objects 

437 cause = ( 

438 f"Got {meaningful_results} meaningful results from " 

439 f"{requested_objects} requested objects. " 

440 f"This may indicate incompatible theme or documentation " 

441 f"structure." ) 

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

443 

444 

445class ContentIdInvalidity( Omnierror, ValueError ): 

446 ''' Content ID has invalid format or encoding. ''' 

447 

448 def __init__( self, content_id: str, cause: str ): 

449 self.content_id = content_id 

450 self.cause = cause 

451 super( ).__init__( 

452 f"Content ID '{content_id}' is invalid. {cause}" ) 

453 

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

455 ''' Renders content ID invalidity as JSON-compatible dictionary. ''' 

456 return __.immut.Dictionary[ 

457 str, __.typx.Any 

458 ]( 

459 type = 'content_id_invalid', 

460 title = 'Invalid Content ID Format', 

461 message = str( self ), 

462 content_id = self.content_id, 

463 cause = self.cause, 

464 suggestion = ( 

465 'Verify the content ID was generated correctly and not ' 

466 'corrupted during transmission.' ), 

467 ) 

468 

469 def render_as_markdown( 

470 self, /, *, 

471 reveal_internals: bool = True, 

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

473 ''' Renders content ID invalidity as Markdown lines for display. ''' 

474 lines = [ "## Error: Invalid Content ID Format" ] 

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

476 lines.append( 

477 "**Suggestion:** Verify the content ID was generated correctly " 

478 "and not corrupted during transmission." ) 

479 if reveal_internals: 

480 lines.append( f"**Content ID:** {self.content_id}" ) 

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

482 lines.append( "**Error Type:** content_id_invalid" ) 

483 return tuple( lines ) 

484 

485 

486class ContentIdLocationMismatch( Omnierror, ValueError ): 

487 ''' Content ID location does not match term query location. ''' 

488 

489 def __init__( self, content_id_location: str, term_location: str ): 

490 self.content_id_location = content_id_location 

491 self.term_location = term_location 

492 super( ).__init__( 

493 f"Content ID location '{content_id_location}' does not match " 

494 f"term location '{term_location}'" ) 

495 

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

497 ''' Renders content ID location mismatch as JSON-compatible dict. ''' 

498 return __.immut.Dictionary[ 

499 str, __.typx.Any 

500 ]( 

501 type = 'content_id_location_mismatch', 

502 title = 'Content ID Location Mismatch', 

503 message = str( self ), 

504 content_id_location = self.content_id_location, 

505 term_location = self.term_location, 

506 suggestion = ( 

507 'Ensure the content ID was generated from the same location ' 

508 'being queried, or use a content ID from the correct ' 

509 'location.' ), 

510 ) 

511 

512 def render_as_markdown( 

513 self, /, *, 

514 reveal_internals: bool = True, 

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

516 ''' Renders content ID location mismatch as Markdown lines. ''' 

517 lines = [ "## Error: Content ID Location Mismatch" ] 

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

519 lines.append( 

520 "**Suggestion:** Ensure the content ID was generated from the " 

521 "same location being queried, or use a content ID from the " 

522 "correct location." ) 

523 if reveal_internals: 

524 lines.append( 

525 f"**Content ID Location:** {self.content_id_location}" ) 

526 lines.append( f"**Term Location:** {self.term_location}" ) 

527 lines.append( "**Error Type:** content_id_location_mismatch" ) 

528 return tuple( lines ) 

529 

530 

531class ContentIdObjectAbsence( Omnierror, ValueError ): 

532 ''' Object specified in content ID not found in location. ''' 

533 

534 def __init__( self, object_name: str, location: str ): 

535 self.object_name = object_name 

536 self.location = location 

537 super( ).__init__( 

538 f"Object '{object_name}' not found at location '{location}'" ) 

539 

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

541 ''' Renders content ID object absence as JSON-compatible dict. ''' 

542 return __.immut.Dictionary[ 

543 str, __.typx.Any 

544 ]( 

545 type = 'content_id_object_not_found', 

546 title = 'Content ID Object Not Found', 

547 message = str( self ), 

548 object_name = self.object_name, 

549 location = self.location, 

550 suggestion = ( 

551 'Verify the object name exists at the location, or check ' 

552 'if the object has been renamed or removed.' ), 

553 ) 

554 

555 def render_as_markdown( 

556 self, /, *, 

557 reveal_internals: bool = True, 

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

559 ''' Renders content ID object absence as Markdown lines. ''' 

560 lines = [ "## Error: Content ID Object Not Found" ] 

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

562 lines.append( 

563 "**Suggestion:** Verify the object name exists at the location, " 

564 "or check if the object has been renamed or removed." ) 

565 if reveal_internals: 

566 lines.append( f"**Object Name:** {self.object_name}" ) 

567 lines.append( f"**Location:** {self.location}" ) 

568 lines.append( "**Error Type:** content_id_object_not_found" ) 

569 return tuple( lines ) 

570 

571 

572class ThemeDetectFailure( StructureProcessFailure ): 

573 ''' Theme detection failed during processing. ''' 

574 

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

576 self.theme_error = theme_error 

577 super( ).__init__( 

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

579 

580 

581class UrlImpermissibility( Omnierror, PermissionError ): 

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

583 

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

585 message = ( 

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

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

588 self.url = url 

589 self.user_agent = user_agent 

590 super( ).__init__( message ) 

591 

592 

593class ContextInvalidity( Omnierror, TypeError ): 

594 ''' Invalid context type provided to operation. ''' 

595 

596