awesome-errors 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- awesome_errors/__init__.py +232 -0
- awesome_errors/analysis/__init__.py +8 -0
- awesome_errors/analysis/decorators.py +251 -0
- awesome_errors/analysis/error_analyzer.py +463 -0
- awesome_errors/client/__init__.py +7 -0
- awesome_errors/client/exceptions.py +97 -0
- awesome_errors/client/response_parser.py +175 -0
- awesome_errors/converters/__init__.py +31 -0
- awesome_errors/converters/generic.py +23 -0
- awesome_errors/converters/pydantic_converter.py +75 -0
- awesome_errors/converters/python_converter.py +80 -0
- awesome_errors/converters/sql_converter.py +258 -0
- awesome_errors/converters/universal_converter.py +123 -0
- awesome_errors/core/__init__.py +0 -0
- awesome_errors/core/error_codes.py +104 -0
- awesome_errors/core/error_response.py +61 -0
- awesome_errors/core/exceptions.py +613 -0
- awesome_errors/core/renderers.py +115 -0
- awesome_errors/i18n/__init__.py +0 -0
- awesome_errors/i18n/locales/en/errors.json +32 -0
- awesome_errors/i18n/locales/test/errors.json +4 -0
- awesome_errors/i18n/locales/uk/errors.json +26 -0
- awesome_errors/i18n/translator.py +150 -0
- awesome_errors/integrations/__init__.py +11 -0
- awesome_errors/integrations/fastapi_auto_docs.py +220 -0
- awesome_errors/litestar_utils.py +55 -0
- awesome_errors/middleware/__init__.py +0 -0
- awesome_errors/middleware/fastapi.py +247 -0
- awesome_errors/middleware/litestar.py +333 -0
- awesome_errors/py.typed +1 -0
- awesome_errors/websocket/__init__.py +35 -0
- awesome_errors/websocket/error_handler.py +231 -0
- awesome_errors/websocket/exceptions.py +286 -0
- awesome_errors-0.3.0.dist-info/METADATA +239 -0
- awesome_errors-0.3.0.dist-info/RECORD +37 -0
- awesome_errors-0.3.0.dist-info/WHEEL +4 -0
- awesome_errors-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, Optional, Protocol, Union, cast
|
|
5
|
+
|
|
6
|
+
from .exceptions import BackendError
|
|
7
|
+
from ..core.error_response import ErrorDetail, error_detail_from_mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _HasDetails(Protocol):
|
|
11
|
+
details: Dict[str, Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorDetailMixin:
|
|
15
|
+
@property
|
|
16
|
+
def field(self) -> Any:
|
|
17
|
+
return cast(_HasDetails, self).details.get("field")
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def field_errors(self) -> Any:
|
|
21
|
+
return cast(_HasDetails, self).details.get("field_errors")
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def table(self) -> Any:
|
|
25
|
+
return cast(_HasDetails, self).details.get("table")
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def constraint(self) -> Any:
|
|
29
|
+
return cast(_HasDetails, self).details.get("constraint")
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def duplicate_value(self) -> Any:
|
|
33
|
+
return cast(_HasDetails, self).details.get("duplicate_value")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ErrorResponseParser:
|
|
37
|
+
"""Parser for backend error responses."""
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def parse_response(
|
|
41
|
+
cls,
|
|
42
|
+
response_body: Union[str, bytes, Dict[str, Any]],
|
|
43
|
+
status_code: int,
|
|
44
|
+
headers: Optional[Dict[str, str]] = None,
|
|
45
|
+
) -> BackendError:
|
|
46
|
+
"""
|
|
47
|
+
Parse error response from backend.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
response_body: Response body (JSON string, bytes, or dict)
|
|
51
|
+
status_code: HTTP status code
|
|
52
|
+
headers: Response headers
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
BackendError instance
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If response cannot be parsed
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
data = (
|
|
62
|
+
json.loads(response_body)
|
|
63
|
+
if isinstance(response_body, (str, bytes))
|
|
64
|
+
else response_body
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
raise ValueError("Response body is not a JSON object")
|
|
69
|
+
|
|
70
|
+
detail_payload: Optional[Dict[str, Any]] = None
|
|
71
|
+
if cls.is_error_response(data):
|
|
72
|
+
error_block = data.get("error", {})
|
|
73
|
+
if isinstance(error_block, dict):
|
|
74
|
+
detail_payload = dict(error_block)
|
|
75
|
+
elif cls.is_problem_response(data):
|
|
76
|
+
detail_payload = cls._problem_detail_to_error_payload(data)
|
|
77
|
+
|
|
78
|
+
if detail_payload is None:
|
|
79
|
+
raise ValueError("Unsupported error response shape")
|
|
80
|
+
|
|
81
|
+
request_id = detail_payload.get("request_id")
|
|
82
|
+
if not request_id:
|
|
83
|
+
request_id = headers.get("X-Request-ID") if headers else None
|
|
84
|
+
detail_payload["request_id"] = request_id or "unknown"
|
|
85
|
+
|
|
86
|
+
error_detail = error_detail_from_mapping(detail_payload)
|
|
87
|
+
|
|
88
|
+
return BackendError(
|
|
89
|
+
error=error_detail,
|
|
90
|
+
status_code=status_code,
|
|
91
|
+
response_headers=headers,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
|
95
|
+
# Fallback for non-standard error responses
|
|
96
|
+
return cls._create_fallback_error(response_body, status_code, headers, e)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def _create_fallback_error(
|
|
100
|
+
cls,
|
|
101
|
+
response_body: Any,
|
|
102
|
+
status_code: int,
|
|
103
|
+
headers: Optional[Dict[str, str]],
|
|
104
|
+
parse_error: Exception,
|
|
105
|
+
) -> BackendError:
|
|
106
|
+
"""Create fallback error when parsing fails."""
|
|
107
|
+
# Try to extract message from response
|
|
108
|
+
message = "Unknown error"
|
|
109
|
+
details: Dict[str, Any] = {
|
|
110
|
+
"parse_error": str(parse_error),
|
|
111
|
+
"response_type": type(response_body).__name__,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if isinstance(response_body, dict):
|
|
115
|
+
# Try common error fields
|
|
116
|
+
message = (
|
|
117
|
+
response_body.get("message")
|
|
118
|
+
or response_body.get("error")
|
|
119
|
+
or response_body.get("detail")
|
|
120
|
+
or str(response_body)
|
|
121
|
+
)
|
|
122
|
+
details["raw_response"] = response_body
|
|
123
|
+
elif isinstance(response_body, (str, bytes)):
|
|
124
|
+
message = str(response_body)[:200] # Limit length
|
|
125
|
+
details["raw_response"] = message
|
|
126
|
+
|
|
127
|
+
# Create error detail
|
|
128
|
+
error_detail = ErrorDetail(
|
|
129
|
+
code="UNKNOWN_ERROR",
|
|
130
|
+
message=message,
|
|
131
|
+
details=details,
|
|
132
|
+
request_id=headers.get("X-Request-ID", "unknown") if headers else "unknown",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return BackendError(
|
|
136
|
+
error=error_detail, status_code=status_code, response_headers=headers
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def is_error_response(cls, response_data: Dict[str, Any]) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Check if response data is an error response.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
response_data: Response data as dictionary
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
True if this is an error response
|
|
149
|
+
"""
|
|
150
|
+
return "error" in response_data and isinstance(response_data.get("error"), dict)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def is_problem_response(cls, response_data: Dict[str, Any]) -> bool:
|
|
154
|
+
"""Check whether the payload follows RFC 7807 conventions."""
|
|
155
|
+
|
|
156
|
+
required = {"type", "title", "status", "code"}
|
|
157
|
+
return required.issubset(response_data.keys())
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _problem_detail_to_error_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
161
|
+
payload = {
|
|
162
|
+
"code": str(data.get("code", "UNKNOWN_ERROR")),
|
|
163
|
+
"message": data.get("title") or data.get("detail") or "Unknown error",
|
|
164
|
+
"request_id": data.get("request_id", ""),
|
|
165
|
+
"timestamp": data.get("timestamp"),
|
|
166
|
+
"details": dict(data.get("details") or {}),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Carry over common meta fields into details for debugging purposes
|
|
170
|
+
details = payload["details"]
|
|
171
|
+
if "detail" in data and "explanation" not in details:
|
|
172
|
+
details.setdefault("detail", data.get("detail"))
|
|
173
|
+
if "instance" in data:
|
|
174
|
+
details.setdefault("instance", data.get("instance"))
|
|
175
|
+
return payload
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .python_converter import PythonErrorConverter
|
|
6
|
+
from .sql_converter import SQLErrorConverter
|
|
7
|
+
from .universal_converter import UniversalErrorConverter
|
|
8
|
+
|
|
9
|
+
PydanticErrorConverter: Any
|
|
10
|
+
try: # pragma: no cover - optional dependency
|
|
11
|
+
from .pydantic_converter import PydanticErrorConverter as PydanticErrorConverter
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
|
|
14
|
+
class _FallbackPydanticErrorConverter:
|
|
15
|
+
"""Placeholder raising a helpful message when pydantic is missing."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def convert(*_args: Any, **_kwargs: Any) -> Any:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"Install 'awesome-errors[pydantic]' to enable Pydantic error conversions."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
PydanticErrorConverter = _FallbackPydanticErrorConverter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"SQLErrorConverter",
|
|
28
|
+
"PythonErrorConverter",
|
|
29
|
+
"PydanticErrorConverter",
|
|
30
|
+
"UniversalErrorConverter",
|
|
31
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ..core.exceptions import AppError
|
|
2
|
+
from ..core.error_codes import ErrorCode
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generic_error_handler(error: Exception, debug: bool = False) -> AppError:
|
|
6
|
+
error_type = type(error).__name__
|
|
7
|
+
error_module = type(error).__module__
|
|
8
|
+
details: dict[str, object] = {
|
|
9
|
+
"error_type": error_type,
|
|
10
|
+
"error_module": error_module,
|
|
11
|
+
}
|
|
12
|
+
if debug:
|
|
13
|
+
details["error_str"] = str(error)
|
|
14
|
+
details["error_repr"] = repr(error)
|
|
15
|
+
if hasattr(error, "__dict__"):
|
|
16
|
+
details["error_attrs"] = {
|
|
17
|
+
k: str(v) for k, v in error.__dict__.items() if not k.startswith("_")
|
|
18
|
+
}
|
|
19
|
+
return AppError(
|
|
20
|
+
code=ErrorCode.INTERNAL_ERROR,
|
|
21
|
+
message=f"Unexpected error: {error_type}",
|
|
22
|
+
details=details,
|
|
23
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Mapping, Sequence
|
|
2
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
3
|
+
|
|
4
|
+
from ..core.error_codes import ErrorCode
|
|
5
|
+
from ..core.exceptions import ValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PydanticErrorConverter:
|
|
9
|
+
"""Convert Pydantic validation errors to application errors."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def convert(cls, error: PydanticValidationError) -> ValidationError:
|
|
13
|
+
"""
|
|
14
|
+
Convert Pydantic ValidationError to application ValidationError.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
error: Pydantic validation error
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Application ValidationError with detailed field information
|
|
21
|
+
"""
|
|
22
|
+
errors = error.errors()
|
|
23
|
+
|
|
24
|
+
# Extract first error for main message
|
|
25
|
+
first_error: Mapping[str, Any] | None = errors[0] if errors else None
|
|
26
|
+
main_field = cls._get_field_path(first_error.get("loc", [])) if first_error else ""
|
|
27
|
+
main_message = first_error.get("msg", "Validation failed") if first_error else "Validation failed"
|
|
28
|
+
|
|
29
|
+
# Build detailed error information
|
|
30
|
+
field_errors = cls._build_field_errors(errors)
|
|
31
|
+
|
|
32
|
+
# Create validation error with detailed field information
|
|
33
|
+
validation_error = ValidationError(
|
|
34
|
+
message=f"Validation failed for field '{main_field}': {main_message}"
|
|
35
|
+
if main_field
|
|
36
|
+
else main_message,
|
|
37
|
+
code=ErrorCode.VALIDATION_FAILED,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Add detailed field errors
|
|
41
|
+
validation_error.details = {
|
|
42
|
+
"field_errors": field_errors,
|
|
43
|
+
"error_count": len(errors),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return validation_error
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _build_field_errors(
|
|
50
|
+
cls, errors: Sequence[Mapping[str, Any]]
|
|
51
|
+
) -> List[Dict[str, Any]]:
|
|
52
|
+
"""Build detailed field error information."""
|
|
53
|
+
field_errors = []
|
|
54
|
+
|
|
55
|
+
for error in errors:
|
|
56
|
+
field_path = cls._get_field_path(error.get("loc", []))
|
|
57
|
+
field_error = {
|
|
58
|
+
"field": field_path,
|
|
59
|
+
"message": error.get("msg", "Invalid value"),
|
|
60
|
+
"type": error.get("type", "unknown"),
|
|
61
|
+
"context": error.get("ctx", {}),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Add input value if available
|
|
65
|
+
if "input" in error:
|
|
66
|
+
field_error["input"] = error["input"]
|
|
67
|
+
|
|
68
|
+
field_errors.append(field_error)
|
|
69
|
+
|
|
70
|
+
return field_errors
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _get_field_path(cls, loc: Sequence[Any]) -> str:
|
|
74
|
+
"""Convert location list to field path string."""
|
|
75
|
+
return ".".join(str(part) for part in loc) if loc else ""
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from typing import Dict, Type, Tuple
|
|
2
|
+
|
|
3
|
+
from ..core.error_codes import ErrorCode
|
|
4
|
+
from ..core.exceptions import AppError, ValidationError, AuthError, NotFoundError
|
|
5
|
+
from .generic import generic_error_handler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PythonErrorConverter:
|
|
9
|
+
"""Convert standard Python exceptions to application errors."""
|
|
10
|
+
|
|
11
|
+
# Mapping of Python exceptions to (ErrorCode, message) tuples
|
|
12
|
+
EXCEPTION_MAP: Dict[Type[Exception], Tuple[ErrorCode, str]] = {
|
|
13
|
+
# Value errors
|
|
14
|
+
ValueError: (ErrorCode.INVALID_INPUT, "Invalid value provided"),
|
|
15
|
+
TypeError: (ErrorCode.INVALID_INPUT, "Invalid type provided"),
|
|
16
|
+
AttributeError: (ErrorCode.INVALID_INPUT, "Invalid attribute access"),
|
|
17
|
+
# Key/Index errors
|
|
18
|
+
KeyError: (ErrorCode.RESOURCE_NOT_FOUND, "Key not found"),
|
|
19
|
+
IndexError: (ErrorCode.RESOURCE_NOT_FOUND, "Index out of range"),
|
|
20
|
+
# Permission errors
|
|
21
|
+
PermissionError: (ErrorCode.AUTH_PERMISSION_DENIED, "Permission denied"),
|
|
22
|
+
# File errors
|
|
23
|
+
FileNotFoundError: (ErrorCode.RESOURCE_NOT_FOUND, "File not found"),
|
|
24
|
+
# Arithmetic errors
|
|
25
|
+
ZeroDivisionError: (ErrorCode.INVALID_INPUT, "Division by zero"),
|
|
26
|
+
ArithmeticError: (ErrorCode.INVALID_INPUT, "Arithmetic error"),
|
|
27
|
+
# System errors
|
|
28
|
+
MemoryError: (ErrorCode.INTERNAL_ERROR, "Memory error"),
|
|
29
|
+
RecursionError: (ErrorCode.INTERNAL_ERROR, "Maximum recursion depth exceeded"),
|
|
30
|
+
# Connection errors
|
|
31
|
+
ConnectionError: (ErrorCode.DB_CONNECTION_ERROR, "Connection error"),
|
|
32
|
+
TimeoutError: (ErrorCode.DB_CONNECTION_ERROR, "Operation timed out"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def convert(cls, error: Exception) -> AppError:
|
|
37
|
+
"""
|
|
38
|
+
Convert Python exception to AppError.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
error: Python exception
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
AppError instance
|
|
45
|
+
"""
|
|
46
|
+
error_type = type(error)
|
|
47
|
+
|
|
48
|
+
# Check if we have a specific mapping
|
|
49
|
+
for exc_type, (code, default_message) in cls.EXCEPTION_MAP.items():
|
|
50
|
+
if issubclass(error_type, exc_type):
|
|
51
|
+
return cls._create_app_error(error, code, default_message)
|
|
52
|
+
|
|
53
|
+
# Default to internal error
|
|
54
|
+
return generic_error_handler(error)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _create_app_error(
|
|
58
|
+
cls, error: Exception, code: ErrorCode, default_message: str
|
|
59
|
+
) -> AppError:
|
|
60
|
+
"""Create appropriate AppError subclass."""
|
|
61
|
+
message = str(error) or default_message
|
|
62
|
+
details = {"exception_type": type(error).__name__}
|
|
63
|
+
|
|
64
|
+
# Add specific details based on exception type
|
|
65
|
+
if isinstance(error, KeyError):
|
|
66
|
+
details["key"] = str(error).strip("'\"")
|
|
67
|
+
return NotFoundError(resource="key", resource_id=details["key"], code=code)
|
|
68
|
+
|
|
69
|
+
elif isinstance(error, FileNotFoundError):
|
|
70
|
+
details["filename"] = error.filename
|
|
71
|
+
return NotFoundError(resource="file", resource_id=error.filename, code=code)
|
|
72
|
+
|
|
73
|
+
elif isinstance(error, (ValueError, TypeError, AttributeError)):
|
|
74
|
+
return ValidationError(message=message, code=code)
|
|
75
|
+
|
|
76
|
+
elif isinstance(error, PermissionError):
|
|
77
|
+
return AuthError(message=message, code=code)
|
|
78
|
+
|
|
79
|
+
# Default to AppError
|
|
80
|
+
return AppError(code=code, message=message, details=details)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Dict, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from ..core.error_codes import ErrorCode
|
|
5
|
+
from ..core.exceptions import DatabaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SQLErrorConverter:
|
|
9
|
+
"""Convert SQLAlchemy errors to application errors."""
|
|
10
|
+
|
|
11
|
+
# Regex patterns for common SQL errors
|
|
12
|
+
SQL_PATTERNS: Dict[re.Pattern, Tuple[ErrorCode, str]] = {
|
|
13
|
+
# PostgreSQL patterns
|
|
14
|
+
re.compile(r"duplicate key value violates unique constraint"): (
|
|
15
|
+
ErrorCode.DB_DUPLICATE_ENTRY,
|
|
16
|
+
"Record already exists",
|
|
17
|
+
),
|
|
18
|
+
re.compile(r"violates foreign key constraint"): (
|
|
19
|
+
ErrorCode.DB_INVALID_REFERENCE,
|
|
20
|
+
"Invalid reference to related record",
|
|
21
|
+
),
|
|
22
|
+
re.compile(r"violates not-null constraint"): (
|
|
23
|
+
ErrorCode.DB_MISSING_REQUIRED,
|
|
24
|
+
"Required field is missing",
|
|
25
|
+
),
|
|
26
|
+
re.compile(r"violates check constraint"): (
|
|
27
|
+
ErrorCode.DB_CONSTRAINT_VIOLATION,
|
|
28
|
+
"Value violates constraint",
|
|
29
|
+
),
|
|
30
|
+
# MySQL patterns
|
|
31
|
+
re.compile(r"Duplicate entry .* for key"): (
|
|
32
|
+
ErrorCode.DB_DUPLICATE_ENTRY,
|
|
33
|
+
"Record already exists",
|
|
34
|
+
),
|
|
35
|
+
re.compile(
|
|
36
|
+
r"Cannot add or update a child row: a foreign key constraint fails"
|
|
37
|
+
): (ErrorCode.DB_INVALID_REFERENCE, "Invalid reference to related record"),
|
|
38
|
+
re.compile(r"Column .* cannot be null"): (
|
|
39
|
+
ErrorCode.DB_MISSING_REQUIRED,
|
|
40
|
+
"Required field is missing",
|
|
41
|
+
),
|
|
42
|
+
# SQLite patterns
|
|
43
|
+
re.compile(r"UNIQUE constraint failed"): (
|
|
44
|
+
ErrorCode.DB_DUPLICATE_ENTRY,
|
|
45
|
+
"Record already exists",
|
|
46
|
+
),
|
|
47
|
+
re.compile(r"FOREIGN KEY constraint failed"): (
|
|
48
|
+
ErrorCode.DB_INVALID_REFERENCE,
|
|
49
|
+
"Invalid reference to related record",
|
|
50
|
+
),
|
|
51
|
+
re.compile(r"NOT NULL constraint failed"): (
|
|
52
|
+
ErrorCode.DB_MISSING_REQUIRED,
|
|
53
|
+
"Required field is missing",
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def convert(cls, error: Exception) -> DatabaseError:
|
|
59
|
+
"""Convert SQLAlchemy error to DatabaseError.
|
|
60
|
+
|
|
61
|
+
SQLAlchemy is imported lazily so the rest of the library can be used
|
|
62
|
+
without SQLAlchemy installed (install the ``sqlalchemy`` extra to use
|
|
63
|
+
this converter).
|
|
64
|
+
"""
|
|
65
|
+
from sqlalchemy.exc import (
|
|
66
|
+
IntegrityError,
|
|
67
|
+
DataError,
|
|
68
|
+
OperationalError,
|
|
69
|
+
ProgrammingError,
|
|
70
|
+
DatabaseError as SQLAlchemyDatabaseError,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if isinstance(error, IntegrityError):
|
|
74
|
+
return cls._convert_integrity_error(error)
|
|
75
|
+
elif isinstance(error, DataError):
|
|
76
|
+
return cls._convert_data_error(error)
|
|
77
|
+
elif isinstance(error, OperationalError):
|
|
78
|
+
return cls._convert_operational_error(error)
|
|
79
|
+
elif isinstance(error, ProgrammingError):
|
|
80
|
+
return cls._convert_programming_error(error)
|
|
81
|
+
elif isinstance(error, SQLAlchemyDatabaseError):
|
|
82
|
+
return cls._convert_generic_database_error(error)
|
|
83
|
+
else:
|
|
84
|
+
return DatabaseError(
|
|
85
|
+
message=str(error), code=ErrorCode.DB_QUERY_ERROR, sql_error=str(error)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def _convert_integrity_error(cls, error: Any) -> DatabaseError:
|
|
90
|
+
"""Convert IntegrityError to DatabaseError with detailed field information."""
|
|
91
|
+
error_str = str(error.orig) if error.orig else str(error)
|
|
92
|
+
|
|
93
|
+
# Check patterns
|
|
94
|
+
for pattern, (code, message) in cls.SQL_PATTERNS.items():
|
|
95
|
+
if pattern.search(error_str):
|
|
96
|
+
db_error = DatabaseError(
|
|
97
|
+
message=message,
|
|
98
|
+
code=code,
|
|
99
|
+
sql_error=error_str,
|
|
100
|
+
table=cls._extract_table_name(error_str),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Add detailed field information
|
|
104
|
+
field_name = cls._extract_field_name(error_str)
|
|
105
|
+
if field_name:
|
|
106
|
+
db_error.details["field"] = field_name
|
|
107
|
+
|
|
108
|
+
constraint_name = cls._extract_constraint_name(error_str)
|
|
109
|
+
if constraint_name:
|
|
110
|
+
db_error.details["constraint"] = constraint_name
|
|
111
|
+
|
|
112
|
+
# For duplicate entries, extract the duplicate value
|
|
113
|
+
if code == ErrorCode.DB_DUPLICATE_ENTRY:
|
|
114
|
+
duplicate_value = cls._extract_duplicate_value(error_str)
|
|
115
|
+
if duplicate_value:
|
|
116
|
+
db_error.details["duplicate_value"] = duplicate_value
|
|
117
|
+
db_error.message = (
|
|
118
|
+
f"{message}: {field_name}={duplicate_value}"
|
|
119
|
+
if field_name
|
|
120
|
+
else f"{message}: {duplicate_value}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# For missing required fields
|
|
124
|
+
elif code == ErrorCode.DB_MISSING_REQUIRED and field_name:
|
|
125
|
+
db_error.message = f"{message}: {field_name}"
|
|
126
|
+
|
|
127
|
+
# For invalid references
|
|
128
|
+
elif code == ErrorCode.DB_INVALID_REFERENCE and field_name:
|
|
129
|
+
db_error.message = f"{message} in field: {field_name}"
|
|
130
|
+
|
|
131
|
+
if db_error.details.get("table"):
|
|
132
|
+
db_error.details["table"] = db_error.details["table"]
|
|
133
|
+
|
|
134
|
+
return db_error
|
|
135
|
+
|
|
136
|
+
# Default integrity error
|
|
137
|
+
return DatabaseError(
|
|
138
|
+
message="Database integrity constraint violated",
|
|
139
|
+
code=ErrorCode.DB_CONSTRAINT_VIOLATION,
|
|
140
|
+
sql_error=error_str,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _convert_data_error(cls, error: Any) -> DatabaseError:
|
|
145
|
+
"""Convert DataError to DatabaseError."""
|
|
146
|
+
return DatabaseError(
|
|
147
|
+
message="Invalid data format",
|
|
148
|
+
code=ErrorCode.INVALID_FORMAT,
|
|
149
|
+
sql_error=str(error.orig) if error.orig else str(error),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def _convert_operational_error(cls, error: Any) -> DatabaseError:
|
|
154
|
+
"""Convert OperationalError to DatabaseError."""
|
|
155
|
+
error_str = str(error.orig) if error.orig else str(error)
|
|
156
|
+
|
|
157
|
+
if "connection" in error_str.lower():
|
|
158
|
+
return DatabaseError(
|
|
159
|
+
message="Database connection error",
|
|
160
|
+
code=ErrorCode.DB_CONNECTION_ERROR,
|
|
161
|
+
sql_error=error_str,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return DatabaseError(
|
|
165
|
+
message="Database operational error",
|
|
166
|
+
code=ErrorCode.DB_QUERY_ERROR,
|
|
167
|
+
sql_error=error_str,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def _convert_programming_error(cls, error: Any) -> DatabaseError:
|
|
172
|
+
"""Convert ProgrammingError to DatabaseError."""
|
|
173
|
+
return DatabaseError(
|
|
174
|
+
message="Database programming error",
|
|
175
|
+
code=ErrorCode.DB_QUERY_ERROR,
|
|
176
|
+
sql_error=str(error.orig) if error.orig else str(error),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def _convert_generic_database_error(
|
|
181
|
+
cls, error: Any
|
|
182
|
+
) -> DatabaseError:
|
|
183
|
+
"""Convert generic DatabaseError to DatabaseError."""
|
|
184
|
+
return DatabaseError(
|
|
185
|
+
message="Database error occurred",
|
|
186
|
+
code=ErrorCode.DB_QUERY_ERROR,
|
|
187
|
+
sql_error=str(error.orig) if error.orig else str(error),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def _extract_table_name(cls, error_str: str) -> Optional[str]:
|
|
192
|
+
"""Extract table name from error string."""
|
|
193
|
+
# PostgreSQL pattern
|
|
194
|
+
match = re.search(r'relation "([^"]+)"', error_str)
|
|
195
|
+
if match:
|
|
196
|
+
return match.group(1)
|
|
197
|
+
|
|
198
|
+
# MySQL pattern
|
|
199
|
+
match = re.search(r"`([^`]+)`\.`([^`]+)`", error_str)
|
|
200
|
+
if match:
|
|
201
|
+
return match.group(2)
|
|
202
|
+
|
|
203
|
+
# SQLite pattern
|
|
204
|
+
match = re.search(r"table (\w+)", error_str)
|
|
205
|
+
if match:
|
|
206
|
+
return match.group(1)
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def _extract_field_name(cls, error_str: str) -> Optional[str]:
|
|
212
|
+
"""Extract field/column name from error string."""
|
|
213
|
+
# PostgreSQL patterns
|
|
214
|
+
match = re.search(r'column "([^"]+)"', error_str)
|
|
215
|
+
if match:
|
|
216
|
+
return match.group(1)
|
|
217
|
+
|
|
218
|
+
# MySQL patterns
|
|
219
|
+
match = re.search(r"Column '([^']+)'", error_str)
|
|
220
|
+
if match:
|
|
221
|
+
return match.group(1)
|
|
222
|
+
|
|
223
|
+
# SQLite patterns
|
|
224
|
+
match = re.search(r"column (\w+)", error_str)
|
|
225
|
+
if match:
|
|
226
|
+
return match.group(1)
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def _extract_constraint_name(cls, error_str: str) -> Optional[str]:
|
|
232
|
+
"""Extract constraint name from error string."""
|
|
233
|
+
# PostgreSQL pattern
|
|
234
|
+
match = re.search(r'constraint "([^"]+)"', error_str)
|
|
235
|
+
if match:
|
|
236
|
+
return match.group(1)
|
|
237
|
+
|
|
238
|
+
# MySQL pattern
|
|
239
|
+
match = re.search(r"key '([^']+)'", error_str)
|
|
240
|
+
if match:
|
|
241
|
+
return match.group(1)
|
|
242
|
+
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def _extract_duplicate_value(cls, error_str: str) -> Optional[str]:
|
|
247
|
+
"""Extract duplicate value from error string."""
|
|
248
|
+
# PostgreSQL pattern
|
|
249
|
+
match = re.search(r"Key \([^)]+\)=\(([^)]+)\)", error_str)
|
|
250
|
+
if match:
|
|
251
|
+
return match.group(1)
|
|
252
|
+
|
|
253
|
+
# MySQL pattern
|
|
254
|
+
match = re.search(r"Duplicate entry '([^']+)'", error_str)
|
|
255
|
+
if match:
|
|
256
|
+
return match.group(1)
|
|
257
|
+
|
|
258
|
+
return None
|