Coverage for sources/librovore/exceptions.py: 40%
223 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-06 02:25 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-06 02:25 +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 )
357class StructureIncompatibility( Omnierror, ValueError ):
358 ''' Documentation structure incompatible with processor. '''
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." )
369class StructureProcessFailure( Omnierror, RuntimeError ):
370 ''' Structure processor failed to complete processing. '''
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}" )
380class ContentExtractFailure( StructureProcessFailure ):
381 ''' Failed to extract meaningful content from documentation. '''
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 )
402class ContentIdInvalidity( Omnierror, ValueError ):
403 ''' Content ID has invalid format or encoding. '''
405 def __init__( self, content_id: str, cause: str ):
406 self.content_id = content_id
407 self.cause = cause
408 super( ).__init__(
409 f"Content ID '{content_id}' is invalid. {cause}" )
411 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]:
412 ''' Renders content ID invalidity as JSON-compatible dictionary. '''
413 return __.immut.Dictionary[
414 str, __.typx.Any
415 ](
416 type = 'content_id_invalid',
417 title = 'Invalid Content ID Format',
418 message = str( self ),
419 content_id = self.content_id,
420 cause = self.cause,
421 suggestion = (
422 'Verify the content ID was generated correctly and not '
423 'corrupted during transmission.' ),
424 )
426 def render_as_markdown(
427 self, /, *,
428 reveal_internals: bool = True,
429 ) -> tuple[ str, ... ]:
430 ''' Renders content ID invalidity as Markdown lines for display. '''
431 lines = [ "## Error: Invalid Content ID Format" ]
432 lines.append( f"**Message:** {self}" )
433 lines.append(
434 "**Suggestion:** Verify the content ID was generated correctly "
435 "and not corrupted during transmission." )
436 if reveal_internals:
437 lines.append( f"**Content ID:** {self.content_id}" )
438 lines.append( f"**Cause:** {self.cause}" )
439 lines.append( "**Error Type:** content_id_invalid" )
440 return tuple( lines )
443class ContentIdLocationMismatch( Omnierror, ValueError ):
444 ''' Content ID location does not match term query location. '''
446 def __init__( self, content_id_location: str, term_location: str ):
447 self.content_id_location = content_id_location
448 self.term_location = term_location
449 super( ).__init__(
450 f"Content ID location '{content_id_location}' does not match "
451 f"term location '{term_location}'" )
453 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]:
454 ''' Renders content ID location mismatch as JSON-compatible dict. '''
455 return __.immut.Dictionary[
456 str, __.typx.Any
457 ](
458 type = 'content_id_location_mismatch',
459 title = 'Content ID Location Mismatch',
460 message = str( self ),
461 content_id_location = self.content_id_location,
462 term_location = self.term_location,
463 suggestion = (
464 'Ensure the content ID was generated from the same location '
465 'being queried, or use a content ID from the correct '
466 'location.' ),
467 )
469 def render_as_markdown(
470 self, /, *,
471 reveal_internals: bool = True,
472 ) -> tuple[ str, ... ]:
473 ''' Renders content ID location mismatch as Markdown lines. '''
474 lines = [ "## Error: Content ID Location Mismatch" ]
475 lines.append( f"**Message:** {self}" )
476 lines.append(
477 "**Suggestion:** Ensure the content ID was generated from the "
478 "same location being queried, or use a content ID from the "
479 "correct location." )
480 if reveal_internals:
481 lines.append(
482 f"**Content ID Location:** {self.content_id_location}" )
483 lines.append( f"**Term Location:** {self.term_location}" )
484 lines.append( "**Error Type:** content_id_location_mismatch" )
485 return tuple( lines )
488class ContentIdObjectAbsence( Omnierror, ValueError ):
489 ''' Object specified in content ID not found in location. '''
491 def __init__( self, object_name: str, location: str ):
492 self.object_name = object_name
493 self.location = location
494 super( ).__init__(
495 f"Object '{object_name}' not found at location '{location}'" )
497 def render_as_json( self ) -> __.immut.Dictionary[ str, __.typx.Any ]:
498 ''' Renders content ID object absence as JSON-compatible dict. '''
499 return __.immut.Dictionary[
500 str, __.typx.Any
501 ](
502 type = 'content_id_object_not_found',
503 title = 'Content ID Object Not Found',
504 message = str( self ),
505 object_name = self.object_name,
506 location = self.location,
507 suggestion = (
508 'Verify the object name exists at the location, or check '
509 'if the object has been renamed or removed.' ),
510 )
512 def render_as_markdown(
513 self, /, *,
514 reveal_internals: bool = True,
515 ) -> tuple[ str, ... ]:
516 ''' Renders content ID object absence as Markdown lines. '''
517 lines = [ "## Error: Content ID Object Not Found" ]
518 lines.append( f"**Message:** {self}" )
519 lines.append(
520 "**Suggestion:** Verify the object name exists at the location, "
521 "or check if the object has been renamed or removed." )
522 if reveal_internals:
523 lines.append( f"**Object Name:** {self.object_name}" )
524 lines.append( f"**Location:** {self.location}" )
525 lines.append( "**Error Type:** content_id_object_not_found" )
526 return tuple( lines )
529class ThemeDetectFailure( StructureProcessFailure ):
530 ''' Theme detection failed during processing. '''
532 def __init__( self, processor_name: str, source: str, theme_error: str ):
533 self.theme_error = theme_error
534 super( ).__init__(
535 processor_name, source, f"Theme detection failed: {theme_error}" )
538class UrlImpermissibility( Omnierror, PermissionError ):
539 ''' URL access blocked by robots.txt directive. '''
541 def __init__( self, url: str, user_agent: str ):
542 message = (
543 f"URL '{url}' blocked by robots.txt for "
544 f"user agent '{user_agent}'" )
545 self.url = url
546 self.user_agent = user_agent
547 super( ).__init__( message )