awesome-errors 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → awesome_errors-0.4.0}/PKG-INFO +6 -7
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/README.md +5 -6
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/pyproject.toml +1 -1
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/__init__.py +7 -1
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/analysis/error_analyzer.py +47 -80
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/sql_converter.py +0 -3
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/universal_converter.py +23 -36
- awesome_errors-0.4.0/src/awesome_errors/core/_utils.py +24 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/error_response.py +7 -9
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/exceptions.py +4 -3
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/renderers.py +2 -12
- awesome_errors-0.4.0/src/awesome_errors/i18n/translator.py +139 -0
- awesome_errors-0.4.0/src/awesome_errors/middleware/_base.py +108 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/middleware/fastapi.py +35 -93
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/middleware/litestar.py +21 -61
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/websocket/exceptions.py +2 -1
- awesome_errors-0.3.0/src/awesome_errors/i18n/translator.py +0 -150
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/.gitignore +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/LICENSE +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/analysis/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/analysis/decorators.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/client/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/client/exceptions.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/client/response_parser.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/generic.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/pydantic_converter.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/python_converter.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/error_codes.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/locales/en/errors.json +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/locales/test/errors.json +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/locales/uk/errors.json +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/integrations/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/integrations/fastapi_auto_docs.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/litestar_utils.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/middleware/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/py.typed +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/websocket/__init__.py +0 -0
- {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/websocket/error_handler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awesome-errors
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A comprehensive error handling library for Python applications with i18n support
|
|
5
5
|
Project-URL: Homepage, https://github.com/THEROER/awesome-errors
|
|
6
6
|
Project-URL: Repository, https://github.com/THEROER/awesome-errors
|
|
@@ -79,15 +79,14 @@ A comprehensive Python library for standardized error handling, analysis, and do
|
|
|
79
79
|
|
|
80
80
|
- **FastAPI middleware**: `setup_error_handling`
|
|
81
81
|
- **Litestar handlers**: `create_litestar_exception_handlers`
|
|
82
|
+
- Both integrations share their translation, logging, and exception-mapping
|
|
83
|
+
logic via `middleware/_base.py`.
|
|
82
84
|
|
|
83
|
-
####
|
|
85
|
+
#### 7. **Internationalization** (`src/awesome_errors/i18n/`)
|
|
84
86
|
|
|
85
87
|
- **ErrorTranslator**: Multi-language error message support
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
#### 7. **Middleware** (`src/awesome_errors/middleware/`)
|
|
89
|
-
|
|
90
|
-
- **setup_error_handling**: FastAPI error handling middleware
|
|
88
|
+
- English defaults are built in; additional locales load from
|
|
89
|
+
`src/awesome_errors/i18n/locales/` (importing the library never writes to disk)
|
|
91
90
|
|
|
92
91
|
## Usage Examples
|
|
93
92
|
|
|
@@ -45,15 +45,14 @@ A comprehensive Python library for standardized error handling, analysis, and do
|
|
|
45
45
|
|
|
46
46
|
- **FastAPI middleware**: `setup_error_handling`
|
|
47
47
|
- **Litestar handlers**: `create_litestar_exception_handlers`
|
|
48
|
+
- Both integrations share their translation, logging, and exception-mapping
|
|
49
|
+
logic via `middleware/_base.py`.
|
|
48
50
|
|
|
49
|
-
####
|
|
51
|
+
#### 7. **Internationalization** (`src/awesome_errors/i18n/`)
|
|
50
52
|
|
|
51
53
|
- **ErrorTranslator**: Multi-language error message support
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
#### 7. **Middleware** (`src/awesome_errors/middleware/`)
|
|
55
|
-
|
|
56
|
-
- **setup_error_handling**: FastAPI error handling middleware
|
|
54
|
+
- English defaults are built in; additional locales load from
|
|
55
|
+
`src/awesome_errors/i18n/locales/` (importing the library never writes to disk)
|
|
57
56
|
|
|
58
57
|
## Usage Examples
|
|
59
58
|
|
|
@@ -162,9 +162,15 @@ def setup_websocket_error_handling(app: Any) -> Any:
|
|
|
162
162
|
return _setup_websocket_error_handling(app)
|
|
163
163
|
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
try:
|
|
166
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
167
|
+
|
|
168
|
+
__version__ = _pkg_version("awesome-errors")
|
|
169
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
170
|
+
__version__ = "0.4.0"
|
|
166
171
|
|
|
167
172
|
__all__ = [
|
|
173
|
+
"__version__",
|
|
168
174
|
"AppError",
|
|
169
175
|
"APIError",
|
|
170
176
|
"ValidationError",
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import ast
|
|
2
2
|
import inspect
|
|
3
|
+
import textwrap
|
|
3
4
|
from typing import Set, List, Dict, Any, Optional, Callable
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class ErrorAnalyzer(ast.NodeVisitor):
|
|
7
|
-
"""AST analyzer
|
|
8
|
+
"""AST analyzer that discovers the error codes a function may raise.
|
|
9
|
+
|
|
10
|
+
The analysis is static: it parses the function's own source (and its
|
|
11
|
+
decorators) looking for ``raise`` statements and well-known library call
|
|
12
|
+
patterns. It does not follow calls into other functions — resolving call
|
|
13
|
+
targets reliably requires runtime introspection that isn't available from
|
|
14
|
+
the AST alone.
|
|
15
|
+
|
|
16
|
+
``max_depth`` and ``analyze_decorators`` are accepted for backwards
|
|
17
|
+
compatibility; ``max_depth`` currently has no effect since cross-function
|
|
18
|
+
recursion is not performed.
|
|
19
|
+
"""
|
|
8
20
|
|
|
9
21
|
def __init__(
|
|
10
22
|
self, function: Callable, max_depth: int = 10, analyze_decorators: bool = True
|
|
@@ -14,16 +26,14 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
14
26
|
|
|
15
27
|
Args:
|
|
16
28
|
function: Function to analyze
|
|
17
|
-
max_depth:
|
|
29
|
+
max_depth: Retained for compatibility (no cross-function recursion)
|
|
18
30
|
analyze_decorators: Whether to analyze decorators
|
|
19
31
|
"""
|
|
20
32
|
self.function = function
|
|
21
33
|
self.max_depth = max_depth
|
|
22
34
|
self.analyze_decorators = analyze_decorators
|
|
23
|
-
self.current_depth = 0
|
|
24
35
|
self.errors: Set[str] = set()
|
|
25
36
|
self.error_details: List[Dict[str, Any]] = []
|
|
26
|
-
self.visited_functions: Set[str] = set()
|
|
27
37
|
self.decorator_errors: List[Dict[str, Any]] = []
|
|
28
38
|
|
|
29
39
|
def analyze(self) -> Dict[str, Any]:
|
|
@@ -33,64 +43,57 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
33
43
|
Returns:
|
|
34
44
|
Dictionary with error analysis results
|
|
35
45
|
"""
|
|
36
|
-
# Reset state
|
|
46
|
+
# Reset state so an analyzer can be reused.
|
|
37
47
|
self.errors.clear()
|
|
38
48
|
self.error_details.clear()
|
|
39
|
-
self.visited_functions.clear()
|
|
40
49
|
self.decorator_errors.clear()
|
|
41
|
-
self.current_depth = 0
|
|
42
50
|
|
|
43
|
-
# Analyze decorators first
|
|
44
51
|
if self.analyze_decorators:
|
|
45
52
|
self._analyze_decorators(self.function)
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
self._analyze_function(self.function, is_main=True)
|
|
54
|
+
self._analyze_function(self.function)
|
|
49
55
|
|
|
50
56
|
return {
|
|
51
|
-
"function_name": self.function
|
|
52
|
-
"error_codes": sorted(
|
|
57
|
+
"function_name": getattr(self.function, "__name__", "<unknown>"),
|
|
58
|
+
"error_codes": sorted(self.errors),
|
|
53
59
|
"error_details": self.error_details,
|
|
54
60
|
"decorator_errors": self.decorator_errors,
|
|
55
61
|
"total_errors": len(self.errors),
|
|
56
|
-
"analysis_depth":
|
|
57
|
-
"max_depth_reached":
|
|
62
|
+
"analysis_depth": 0,
|
|
63
|
+
"max_depth_reached": False,
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _get_source(func: Callable) -> Optional[str]:
|
|
68
|
+
"""Return the dedented source of ``func``, or ``None`` if unavailable.
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
Handles wrapped functions (``functools.wraps``) and environments where
|
|
71
|
+
``inspect.getsource`` fails because the recorded filename can't be read.
|
|
72
|
+
"""
|
|
73
|
+
target = inspect.unwrap(func)
|
|
74
|
+
for candidate in (target, func):
|
|
75
|
+
try:
|
|
76
|
+
return textwrap.dedent(inspect.getsource(candidate))
|
|
77
|
+
except (OSError, TypeError):
|
|
78
|
+
continue
|
|
79
|
+
return None
|
|
66
80
|
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
def _analyze_function(self, func: Callable) -> None:
|
|
82
|
+
"""Parse a function's source and visit its AST for raised errors."""
|
|
83
|
+
source = self._get_source(func)
|
|
84
|
+
if source is None:
|
|
85
|
+
# Source unavailable (builtin/C function or unreadable file) — fall
|
|
86
|
+
# back to inferring errors from the callable's identity.
|
|
87
|
+
self._analyze_builtin_function(func)
|
|
69
88
|
return
|
|
70
89
|
|
|
71
|
-
self.visited_functions.add(func_name)
|
|
72
|
-
|
|
73
|
-
if not is_main:
|
|
74
|
-
self.current_depth += 1
|
|
75
|
-
|
|
76
90
|
try:
|
|
77
|
-
# Get function source and parse AST
|
|
78
|
-
source = inspect.getsource(func)
|
|
79
|
-
# Remove leading indentation to avoid IndentationError
|
|
80
|
-
import textwrap
|
|
81
|
-
|
|
82
|
-
source = textwrap.dedent(source)
|
|
83
91
|
tree = ast.parse(source)
|
|
84
|
-
|
|
85
|
-
# Visit AST nodes
|
|
86
|
-
self.visit(tree)
|
|
87
|
-
|
|
88
|
-
except (OSError, TypeError):
|
|
89
|
-
# Can't get source - try to infer common library errors
|
|
92
|
+
except SyntaxError:
|
|
90
93
|
self._analyze_builtin_function(func)
|
|
94
|
+
return
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
self.current_depth -= 1
|
|
96
|
+
self.visit(tree)
|
|
94
97
|
|
|
95
98
|
def visit_Raise(self, node: ast.Raise) -> None:
|
|
96
99
|
"""Handle raise statements."""
|
|
@@ -103,20 +106,8 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
103
106
|
self.generic_visit(node)
|
|
104
107
|
|
|
105
108
|
def visit_Call(self, node: ast.Call) -> None:
|
|
106
|
-
"""
|
|
107
|
-
# Analyze call context for known error patterns
|
|
109
|
+
"""Detect known error-producing call patterns (ORM/validation)."""
|
|
108
110
|
self._analyze_call_context(node)
|
|
109
|
-
|
|
110
|
-
# Handle method calls (obj.method())
|
|
111
|
-
if isinstance(node.func, ast.Attribute):
|
|
112
|
-
self._analyze_method_call(node)
|
|
113
|
-
|
|
114
|
-
# Handle regular function calls
|
|
115
|
-
else:
|
|
116
|
-
called_func = self._resolve_function_call(node)
|
|
117
|
-
if called_func and self.current_depth < self.max_depth:
|
|
118
|
-
self._analyze_function(called_func)
|
|
119
|
-
|
|
120
111
|
self.generic_visit(node)
|
|
121
112
|
|
|
122
113
|
def _analyze_call_context(self, node: ast.Call) -> None:
|
|
@@ -152,10 +143,6 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
152
143
|
):
|
|
153
144
|
self.errors.add("VALIDATION_FAILED")
|
|
154
145
|
|
|
155
|
-
# Async operations
|
|
156
|
-
elif "await " in call_str:
|
|
157
|
-
self.errors.update(["INTERNAL_ERROR", "TIMEOUT_ERROR"])
|
|
158
|
-
|
|
159
146
|
def _get_call_string(self, node: ast.Call) -> str:
|
|
160
147
|
"""Get string representation of call for pattern matching."""
|
|
161
148
|
try:
|
|
@@ -404,14 +391,6 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
404
391
|
except Exception:
|
|
405
392
|
pass
|
|
406
393
|
|
|
407
|
-
def _analyze_method_call(self, node: ast.Call) -> None:
|
|
408
|
-
"""Analyze method calls by trying to resolve and analyze the actual method."""
|
|
409
|
-
if isinstance(node.func, ast.Attribute):
|
|
410
|
-
# Try to resolve the actual method and analyze it
|
|
411
|
-
resolved_method = self._resolve_method_call(node)
|
|
412
|
-
if resolved_method and self.current_depth < self.max_depth:
|
|
413
|
-
self._analyze_function(resolved_method)
|
|
414
|
-
|
|
415
394
|
def _analyze_builtin_function(self, func: Callable) -> None:
|
|
416
395
|
"""Analyze built-in functions for common error patterns using existing converters."""
|
|
417
396
|
func_name = getattr(func, "__name__", str(func))
|
|
@@ -435,9 +414,9 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
435
414
|
from ..core.error_codes import ErrorCode
|
|
436
415
|
|
|
437
416
|
# Get all possible error codes from SQL converter patterns
|
|
438
|
-
sql_error_codes =
|
|
439
|
-
|
|
440
|
-
|
|
417
|
+
sql_error_codes = {
|
|
418
|
+
code.value for code, _message in SQLErrorConverter.SQL_PATTERNS.values()
|
|
419
|
+
}
|
|
441
420
|
|
|
442
421
|
# Add common SQLAlchemy errors based on existing converter
|
|
443
422
|
sql_error_codes.update(
|
|
@@ -449,15 +428,3 @@ class ErrorAnalyzer(ast.NodeVisitor):
|
|
|
449
428
|
)
|
|
450
429
|
|
|
451
430
|
self.errors.update(sql_error_codes)
|
|
452
|
-
|
|
453
|
-
def _resolve_method_call(self, node: ast.Call) -> Optional[Callable]:
|
|
454
|
-
"""Try to resolve method call to actual method object."""
|
|
455
|
-
# For now, return None but analyze based on context
|
|
456
|
-
# This is complex and would require runtime introspection
|
|
457
|
-
return None
|
|
458
|
-
|
|
459
|
-
def _resolve_function_call(self, node: ast.Call) -> Optional[Callable]:
|
|
460
|
-
"""Try to resolve function call to actual function object."""
|
|
461
|
-
# For now, return None but analyze based on context
|
|
462
|
-
# This is complex and would require runtime introspection
|
|
463
|
-
return None
|
{awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/sql_converter.py
RENAMED
|
@@ -128,9 +128,6 @@ class SQLErrorConverter:
|
|
|
128
128
|
elif code == ErrorCode.DB_INVALID_REFERENCE and field_name:
|
|
129
129
|
db_error.message = f"{message} in field: {field_name}"
|
|
130
130
|
|
|
131
|
-
if db_error.details.get("table"):
|
|
132
|
-
db_error.details["table"] = db_error.details["table"]
|
|
133
|
-
|
|
134
131
|
return db_error
|
|
135
132
|
|
|
136
133
|
# Default integrity error
|
{awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/universal_converter.py
RENAMED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import json
|
|
2
|
+
from typing import TYPE_CHECKING, Optional, cast
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
2
6
|
|
|
3
7
|
from ..core.error_codes import ErrorCode
|
|
4
8
|
from ..core.exceptions import AppError
|
|
@@ -22,14 +26,6 @@ except ImportError: # pragma: no cover
|
|
|
22
26
|
else: # pragma: no cover
|
|
23
27
|
PydanticValidationErrorType = _LoadedPydanticValidationError
|
|
24
28
|
|
|
25
|
-
_PydanticErrorConverter: Any = None
|
|
26
|
-
try: # pragma: no cover - optional dependency
|
|
27
|
-
from .pydantic_converter import PydanticErrorConverter as _LoadedPydanticErrorConverter
|
|
28
|
-
except ImportError: # pragma: no cover
|
|
29
|
-
pass
|
|
30
|
-
else: # pragma: no cover
|
|
31
|
-
_PydanticErrorConverter = _LoadedPydanticErrorConverter
|
|
32
|
-
|
|
33
29
|
|
|
34
30
|
class UniversalErrorConverter:
|
|
35
31
|
"""Universal error converter that handles any type of exception."""
|
|
@@ -54,11 +50,11 @@ class UniversalErrorConverter:
|
|
|
54
50
|
if PydanticValidationErrorType is not None and isinstance(
|
|
55
51
|
error, PydanticValidationErrorType
|
|
56
52
|
):
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
53
|
+
from .pydantic_converter import PydanticErrorConverter
|
|
54
|
+
|
|
55
|
+
return PydanticErrorConverter.convert(
|
|
56
|
+
cast("PydanticValidationError", error)
|
|
57
|
+
)
|
|
62
58
|
|
|
63
59
|
# SQLAlchemy errors
|
|
64
60
|
if SQLAlchemyErrorType is not None and isinstance(error, SQLAlchemyErrorType):
|
|
@@ -78,46 +74,37 @@ class UniversalErrorConverter:
|
|
|
78
74
|
|
|
79
75
|
@classmethod
|
|
80
76
|
def _handle_special_cases(cls, error: Exception) -> Optional[AppError]:
|
|
81
|
-
"""Handle special error cases
|
|
77
|
+
"""Handle special error cases from the standard library and popular
|
|
78
|
+
third-party packages that are not covered by the dedicated converters."""
|
|
82
79
|
error_type = type(error).__name__
|
|
83
80
|
error_str = str(error)
|
|
84
81
|
|
|
85
|
-
#
|
|
86
|
-
if
|
|
82
|
+
# JSON decode errors (subclass of ValueError; check before generic HTTP)
|
|
83
|
+
if isinstance(error, json.JSONDecodeError):
|
|
87
84
|
return AppError(
|
|
88
|
-
code=ErrorCode.
|
|
89
|
-
message="
|
|
85
|
+
code=ErrorCode.INVALID_FORMAT,
|
|
86
|
+
message="Invalid JSON format",
|
|
90
87
|
details={"error_type": error_type, "error": error_str},
|
|
91
88
|
)
|
|
92
89
|
|
|
93
|
-
#
|
|
94
|
-
|
|
90
|
+
# HTTP client errors (requests/httpx/urllib) are matched by name so we
|
|
91
|
+
# don't have to import those optional packages just to identify them.
|
|
92
|
+
if "HTTPError" in error_type:
|
|
95
93
|
return AppError(
|
|
96
|
-
code=ErrorCode.
|
|
97
|
-
message="
|
|
94
|
+
code=ErrorCode.INTERNAL_ERROR,
|
|
95
|
+
message="HTTP request failed",
|
|
98
96
|
details={"error_type": error_type, "error": error_str},
|
|
99
97
|
)
|
|
100
98
|
|
|
101
|
-
#
|
|
99
|
+
# Missing modules
|
|
102
100
|
if isinstance(error, (ImportError, ModuleNotFoundError)):
|
|
103
101
|
return AppError(
|
|
104
102
|
code=ErrorCode.INTERNAL_ERROR,
|
|
105
103
|
message="Missing required module",
|
|
106
104
|
details={
|
|
107
|
-
"module":
|
|
105
|
+
"module": getattr(error, "name", None) or "unknown",
|
|
108
106
|
"error": error_str,
|
|
109
107
|
},
|
|
110
108
|
)
|
|
111
109
|
|
|
112
110
|
return None
|
|
113
|
-
|
|
114
|
-
@staticmethod
|
|
115
|
-
def _is_serializable(value: Any) -> bool:
|
|
116
|
-
"""Check if value can be safely serialized."""
|
|
117
|
-
try:
|
|
118
|
-
import json
|
|
119
|
-
|
|
120
|
-
json.dumps(value)
|
|
121
|
-
return True
|
|
122
|
-
except Exception:
|
|
123
|
-
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared internal helpers for the core package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def utc_now() -> datetime:
|
|
9
|
+
"""Return the current time as a timezone-aware UTC ``datetime``."""
|
|
10
|
+
return datetime.now(timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_iso_z(value: datetime) -> str:
|
|
14
|
+
"""Render a ``datetime`` as an ISO-8601 string with a ``Z`` UTC suffix.
|
|
15
|
+
|
|
16
|
+
Naive datetimes are assumed to be UTC. Aware datetimes are converted to
|
|
17
|
+
UTC. The result always ends in ``Z`` and never contains a ``+00:00``
|
|
18
|
+
offset, giving a single canonical timestamp format across the library.
|
|
19
|
+
"""
|
|
20
|
+
if value.tzinfo is None:
|
|
21
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
22
|
+
else:
|
|
23
|
+
value = value.astimezone(timezone.utc)
|
|
24
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import datetime
|
|
5
|
+
from datetime import datetime
|
|
6
6
|
from typing import Any, Dict, Mapping
|
|
7
7
|
|
|
8
8
|
import msgspec
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
def _now_utc() -> datetime:
|
|
12
|
-
return datetime.now(timezone.utc)
|
|
10
|
+
from ._utils import to_iso_z, utc_now
|
|
13
11
|
|
|
14
12
|
|
|
15
13
|
class ErrorDetail(msgspec.Struct, kw_only=True, omit_defaults=True):
|
|
@@ -19,18 +17,18 @@ class ErrorDetail(msgspec.Struct, kw_only=True, omit_defaults=True):
|
|
|
19
17
|
message: str
|
|
20
18
|
request_id: str
|
|
21
19
|
details: Dict[str, Any] = msgspec.field(default_factory=dict)
|
|
22
|
-
timestamp: datetime = msgspec.field(default_factory=
|
|
20
|
+
timestamp: datetime = msgspec.field(default_factory=utc_now)
|
|
23
21
|
|
|
24
22
|
def to_dict(self) -> Dict[str, Any]:
|
|
25
23
|
"""Convert to builtin types, ensuring ISO timestamps."""
|
|
26
24
|
data = msgspec.to_builtins(self, builtin_types=None)
|
|
27
25
|
timestamp = data.get("timestamp")
|
|
28
26
|
if isinstance(timestamp, datetime):
|
|
29
|
-
data["timestamp"] = timestamp
|
|
27
|
+
data["timestamp"] = to_iso_z(timestamp)
|
|
30
28
|
elif isinstance(timestamp, str):
|
|
31
29
|
data["timestamp"] = timestamp
|
|
32
30
|
else:
|
|
33
|
-
data["timestamp"] =
|
|
31
|
+
data["timestamp"] = to_iso_z(utc_now())
|
|
34
32
|
return data
|
|
35
33
|
|
|
36
34
|
|
|
@@ -50,12 +48,12 @@ def error_detail_from_mapping(data: Mapping[str, Any]) -> ErrorDetail:
|
|
|
50
48
|
try:
|
|
51
49
|
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
52
50
|
except ValueError:
|
|
53
|
-
timestamp =
|
|
51
|
+
timestamp = utc_now()
|
|
54
52
|
|
|
55
53
|
return ErrorDetail(
|
|
56
54
|
code=str(data.get("code", "UNKNOWN_ERROR")),
|
|
57
55
|
message=str(data.get("message", "")),
|
|
58
56
|
details=dict(data.get("details") or {}),
|
|
59
|
-
timestamp=timestamp if isinstance(timestamp, datetime) else
|
|
57
|
+
timestamp=timestamp if isinstance(timestamp, datetime) else utc_now(),
|
|
60
58
|
request_id=str(data.get("request_id", "")),
|
|
61
59
|
)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from typing import Any, ClassVar, Dict, Optional, Union, TYPE_CHECKING
|
|
2
|
-
from datetime import datetime
|
|
2
|
+
from datetime import datetime
|
|
3
3
|
import uuid
|
|
4
4
|
|
|
5
|
+
from ._utils import to_iso_z, utc_now
|
|
5
6
|
from .error_codes import ErrorCode, get_http_status
|
|
6
7
|
|
|
7
8
|
if TYPE_CHECKING:
|
|
@@ -41,7 +42,7 @@ class AppError(Exception):
|
|
|
41
42
|
self.code = code if isinstance(code, ErrorCode) else ErrorCode(code)
|
|
42
43
|
self.message = message
|
|
43
44
|
self.details = details or {}
|
|
44
|
-
self.timestamp =
|
|
45
|
+
self.timestamp = utc_now()
|
|
45
46
|
self.request_id = str(uuid.uuid4())
|
|
46
47
|
|
|
47
48
|
# Use provided status code or get from mapping
|
|
@@ -54,7 +55,7 @@ class AppError(Exception):
|
|
|
54
55
|
"code": self.code.value,
|
|
55
56
|
"message": self.message,
|
|
56
57
|
"details": self.details,
|
|
57
|
-
"timestamp": self.timestamp
|
|
58
|
+
"timestamp": to_iso_z(self.timestamp),
|
|
58
59
|
"request_id": self.request_id,
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -7,20 +7,10 @@ from enum import StrEnum
|
|
|
7
7
|
from typing import Any, Callable, Dict, Optional
|
|
8
8
|
|
|
9
9
|
from ..core.exceptions import AppError
|
|
10
|
+
from ._utils import to_iso_z
|
|
10
11
|
from .error_response import ErrorDetail, ErrorResponse
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
from datetime import timezone
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _isoformat(dt):
|
|
17
|
-
if dt.tzinfo is None:
|
|
18
|
-
dt = dt.replace(tzinfo=timezone.utc)
|
|
19
|
-
else:
|
|
20
|
-
dt = dt.astimezone(timezone.utc)
|
|
21
|
-
return dt.isoformat().replace("+00:00", "Z")
|
|
22
|
-
|
|
23
|
-
|
|
24
14
|
class ErrorResponseFormat(StrEnum):
|
|
25
15
|
"""Supported HTTP error payload shapes."""
|
|
26
16
|
|
|
@@ -97,7 +87,7 @@ class ErrorResponseRenderer:
|
|
|
97
87
|
"detail": message,
|
|
98
88
|
"instance": instance,
|
|
99
89
|
"code": error.code.value,
|
|
100
|
-
"timestamp":
|
|
90
|
+
"timestamp": to_iso_z(error.timestamp),
|
|
101
91
|
"request_id": error.request_id,
|
|
102
92
|
"details": error.details,
|
|
103
93
|
}
|