Coverage for sources/librovore/exceptions.py: 41%
241 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-20 18:40 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-20 18:40 +0000
1# vim: set filetype=python fileencoding=utf-8:
2# -*- coding: utf-8 -*-
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#============================================================================#
21''' Family of exceptions for package API. '''
24import urllib.parse as _urlparse
26from . import __
29class Omniexception( __.immut.Object, BaseException ):
30 ''' Base for all exceptions raised by package API. '''
32 _attribute_visibility_includes_: __.cabc.Collection[ str ] = (
33 frozenset( ( '__cause__', '__context__', ) ) )
36class Omnierror( Omniexception, Exception ):
37 ''' Base for error exceptions with self-rendering capability. '''
39 @__.abc.abstractmethod
40 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]:
41 ''' Renders exception as JSON-compatible dictionary. '''
42 raise NotImplementedError
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
60class DetectionConfidenceInvalidity( Omnierror, ValueError ):
61 ''' Detection confidence value is out of valid range. '''
63 def __init__( self, confidence: float ):
64 self.confidence = confidence
65 super( ).__init__( f"Confidence {confidence} not in range [0.0, 1.0]" )
68class DocumentationContentAbsence( Omnierror, ValueError ):
69 ''' Documentation main content container not found. '''
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 )
77class DocumentationInaccessibility( Omnierror, RuntimeError ):
78 ''' Documentation file or resource absent or inaccessible. '''
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 )
86class DocumentationObjectAbsence( Omnierror, ValueError ):
87 ''' Requested object not found in documentation page. '''
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 )
98class DocumentationParseFailure( Omnierror, ValueError ):
99 ''' Documentation HTML parsing failed or content malformed. '''
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 )
107class ExtensionCacheFailure( Omnierror, RuntimeError ):
108 ''' Extension cache operation failed. '''
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}" )
115class ExtensionConfigurationInvalidity( Omnierror, ValueError ):
116 ''' Extension configuration is invalid. '''
118 def __init__( self, extension_name: str, message: str ):
119 self.extension_name = extension_name
120 super( ).__init__( f"Extension '{extension_name}': {message}" )
123class ExtensionInstallFailure( Omnierror, RuntimeError ):
124 ''' Extension package installation failed. '''
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}" )
131class ExtensionRegisterFailure( Omnierror, TypeError ):
132 ''' Invalid plugin could not be registered. '''
134 def __init__( self, message: str ):
135 # TODO: Canned message with extension name as argument.
136 super( ).__init__( message )
139class ExtensionVersionConflict( Omnierror, ImportError ):
140 ''' Extension has incompatible version requirements. '''
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}" )
151class HttpContentTypeInvalidity( Omnierror, ValueError ):
152 ''' HTTP content type is not suitable for requested operation. '''
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}" )
163class InventoryFilterInvalidity( Omnierror, ValueError ):
164 ''' Inventory filter is invalid. '''
166 def __init__( self, message: str ):
167 super( ).__init__( message )
170class InventoryInaccessibility( Omnierror, RuntimeError ):
171 ''' Inventory file or resource absent or inaccessible. '''
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 )
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 )
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 )
218class InventoryInvalidity( Omnierror, ValueError ):
219 ''' Inventory has invalid format or cannot be parsed. '''
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 )
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 )
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 )
263class InventoryUrlInvalidity( Omnierror, ValueError ):
264 ''' Inventory URL is malformed or invalid. '''
266 def __init__( self, source: str ):
267 message = f"Invalid URL format: {source}"
268 self.source = source
269 super( ).__init__( message )
272class InventoryUrlNoSupport( Omnierror, NotImplementedError ):
273 ''' Inventory URL has unsupported component. '''
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 )
289class ProcessorGenusInvalidity( Omnierror, ValueError ):
290 ''' Invalid processor genus provided. '''
292 def __init__( self, genus: __.typx.Any ):
293 message = f"Invalid ProcessorGenera: {genus}"
294 self.genus = genus
295 super( ).__init__( message )
298class ProcessorInavailability( Omnierror, RuntimeError ):
299 ''' No processor found to handle source. '''
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 )
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 )
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 )
345class ProcessorInvalidity( Omnierror, TypeError ):
346 ''' Processor has wrong type. '''
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 )
355class RobotsTxtAccessFailure( Omnierror, RuntimeError ):
356 ''' Robots.txt file access failure (network issue, not policy). '''
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 )
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 )
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 )
400class StructureIncompatibility( Omnierror, ValueError ):
401 ''' Documentation structure incompatible with processor. '''
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." )
412class StructureProcessFailure( Omnierror, RuntimeError ):
413 ''' Structure processor failed to complete processing. '''
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}" )
423class ContentExtractFailure( StructureProcessFailure ):
424 ''' Failed to extract meaningful content from documentation. '''
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 )
445class ContentIdInvalidity( Omnierror, ValueError ):
446 ''' Content ID has invalid format or encoding. '''
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}" )
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 )
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 )
486class ContentIdLocationMismatch( Omnierror, ValueError ):
487 ''' Content ID location does not match term query location. '''
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}'" )
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 )
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 )
531class ContentIdObjectAbsence( Omnierror, ValueError ):
532 ''' Object specified in content ID not found in location. '''
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}'" )
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 )
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 )
572class ThemeDetectFailure( StructureProcessFailure ):
573 ''' Theme detection failed during processing. '''
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}" )
581class UrlImpermissibility( Omnierror, PermissionError ):
582 ''' URL access blocked by robots.txt directive. '''
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 )
593class ContextInvalidity( Omnierror, TypeError ):
594 ''' Invalid context type provided to operation. '''