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.
Files changed (37) hide show
  1. awesome_errors/__init__.py +232 -0
  2. awesome_errors/analysis/__init__.py +8 -0
  3. awesome_errors/analysis/decorators.py +251 -0
  4. awesome_errors/analysis/error_analyzer.py +463 -0
  5. awesome_errors/client/__init__.py +7 -0
  6. awesome_errors/client/exceptions.py +97 -0
  7. awesome_errors/client/response_parser.py +175 -0
  8. awesome_errors/converters/__init__.py +31 -0
  9. awesome_errors/converters/generic.py +23 -0
  10. awesome_errors/converters/pydantic_converter.py +75 -0
  11. awesome_errors/converters/python_converter.py +80 -0
  12. awesome_errors/converters/sql_converter.py +258 -0
  13. awesome_errors/converters/universal_converter.py +123 -0
  14. awesome_errors/core/__init__.py +0 -0
  15. awesome_errors/core/error_codes.py +104 -0
  16. awesome_errors/core/error_response.py +61 -0
  17. awesome_errors/core/exceptions.py +613 -0
  18. awesome_errors/core/renderers.py +115 -0
  19. awesome_errors/i18n/__init__.py +0 -0
  20. awesome_errors/i18n/locales/en/errors.json +32 -0
  21. awesome_errors/i18n/locales/test/errors.json +4 -0
  22. awesome_errors/i18n/locales/uk/errors.json +26 -0
  23. awesome_errors/i18n/translator.py +150 -0
  24. awesome_errors/integrations/__init__.py +11 -0
  25. awesome_errors/integrations/fastapi_auto_docs.py +220 -0
  26. awesome_errors/litestar_utils.py +55 -0
  27. awesome_errors/middleware/__init__.py +0 -0
  28. awesome_errors/middleware/fastapi.py +247 -0
  29. awesome_errors/middleware/litestar.py +333 -0
  30. awesome_errors/py.typed +1 -0
  31. awesome_errors/websocket/__init__.py +35 -0
  32. awesome_errors/websocket/error_handler.py +231 -0
  33. awesome_errors/websocket/exceptions.py +286 -0
  34. awesome_errors-0.3.0.dist-info/METADATA +239 -0
  35. awesome_errors-0.3.0.dist-info/RECORD +37 -0
  36. awesome_errors-0.3.0.dist-info/WHEEL +4 -0
  37. awesome_errors-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,463 @@
1
+ import ast
2
+ import inspect
3
+ from typing import Set, List, Dict, Any, Optional, Callable
4
+
5
+
6
+ class ErrorAnalyzer(ast.NodeVisitor):
7
+ """AST analyzer to find all possible errors in a function."""
8
+
9
+ def __init__(
10
+ self, function: Callable, max_depth: int = 10, analyze_decorators: bool = True
11
+ ):
12
+ """
13
+ Initialize error analyzer.
14
+
15
+ Args:
16
+ function: Function to analyze
17
+ max_depth: Maximum depth for recursive analysis
18
+ analyze_decorators: Whether to analyze decorators
19
+ """
20
+ self.function = function
21
+ self.max_depth = max_depth
22
+ self.analyze_decorators = analyze_decorators
23
+ self.current_depth = 0
24
+ self.errors: Set[str] = set()
25
+ self.error_details: List[Dict[str, Any]] = []
26
+ self.visited_functions: Set[str] = set()
27
+ self.decorator_errors: List[Dict[str, Any]] = []
28
+
29
+ def analyze(self) -> Dict[str, Any]:
30
+ """
31
+ Analyze function for all possible errors.
32
+
33
+ Returns:
34
+ Dictionary with error analysis results
35
+ """
36
+ # Reset state
37
+ self.errors.clear()
38
+ self.error_details.clear()
39
+ self.visited_functions.clear()
40
+ self.decorator_errors.clear()
41
+ self.current_depth = 0
42
+
43
+ # Analyze decorators first
44
+ if self.analyze_decorators:
45
+ self._analyze_decorators(self.function)
46
+
47
+ # Analyze the main function
48
+ self._analyze_function(self.function, is_main=True)
49
+
50
+ return {
51
+ "function_name": self.function.__name__,
52
+ "error_codes": sorted(list(self.errors)),
53
+ "error_details": self.error_details,
54
+ "decorator_errors": self.decorator_errors,
55
+ "total_errors": len(self.errors),
56
+ "analysis_depth": self.current_depth,
57
+ "max_depth_reached": self.current_depth >= self.max_depth,
58
+ }
59
+
60
+ def _analyze_function(self, func: Callable, is_main: bool = False) -> None:
61
+ """Analyze a specific function for errors with depth control."""
62
+ if self.current_depth >= self.max_depth and not is_main:
63
+ return
64
+
65
+ func_name = f"{func.__module__}.{func.__qualname__}"
66
+
67
+ # Avoid infinite recursion
68
+ if func_name in self.visited_functions:
69
+ return
70
+
71
+ self.visited_functions.add(func_name)
72
+
73
+ if not is_main:
74
+ self.current_depth += 1
75
+
76
+ 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
+ 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
90
+ self._analyze_builtin_function(func)
91
+
92
+ if not is_main:
93
+ self.current_depth -= 1
94
+
95
+ def visit_Raise(self, node: ast.Raise) -> None:
96
+ """Handle raise statements."""
97
+ if node.exc:
98
+ error_info = self._extract_error_info(node.exc)
99
+ if error_info:
100
+ self.errors.add(error_info["code"])
101
+ self.error_details.append(error_info)
102
+
103
+ self.generic_visit(node)
104
+
105
+ def visit_Call(self, node: ast.Call) -> None:
106
+ """Enhanced call analysis with context-aware error detection."""
107
+ # Analyze call context for known error patterns
108
+ 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
+ self.generic_visit(node)
121
+
122
+ def _analyze_call_context(self, node: ast.Call) -> None:
123
+ """Analyze call context to detect common error patterns."""
124
+ call_str = self._get_call_string(node)
125
+
126
+ # SQLAlchemy session operations
127
+ if any(
128
+ pattern in call_str
129
+ for pattern in [
130
+ "session.",
131
+ ".execute(",
132
+ ".commit(",
133
+ ".rollback(",
134
+ ".query(",
135
+ ".add(",
136
+ ".delete(",
137
+ ".merge(",
138
+ ".flush(",
139
+ ]
140
+ ):
141
+ self._add_sqlalchemy_errors()
142
+
143
+ # Pydantic validation
144
+ elif any(
145
+ pattern in call_str
146
+ for pattern in [
147
+ ".model_validate(",
148
+ ".parse_obj(",
149
+ ".model_dump(",
150
+ ".model_validate_json(",
151
+ ]
152
+ ):
153
+ self.errors.add("VALIDATION_FAILED")
154
+
155
+ # Async operations
156
+ elif "await " in call_str:
157
+ self.errors.update(["INTERNAL_ERROR", "TIMEOUT_ERROR"])
158
+
159
+ def _get_call_string(self, node: ast.Call) -> str:
160
+ """Get string representation of call for pattern matching."""
161
+ try:
162
+ if isinstance(node.func, ast.Attribute):
163
+ return f"{self._get_attr_chain(node.func)}()"
164
+ elif isinstance(node.func, ast.Name):
165
+ return f"{node.func.id}()"
166
+ except Exception:
167
+ pass
168
+ return ""
169
+
170
+ def _get_attr_chain(self, node: ast.Attribute) -> str:
171
+ """Get full attribute chain like 'obj.method'."""
172
+ parts = []
173
+ current: ast.AST = node
174
+
175
+ while isinstance(current, ast.Attribute):
176
+ parts.append(current.attr)
177
+ current = current.value
178
+
179
+ if isinstance(current, ast.Name):
180
+ parts.append(current.id)
181
+
182
+ return ".".join(reversed(parts)) if parts else ""
183
+
184
+ def _extract_error_info(self, node: ast.AST) -> Optional[Dict[str, Any]]:
185
+ """Extract error information from raise statement."""
186
+ if isinstance(node, ast.Call):
187
+ # Handle: raise SomeError("message", code="ERROR_CODE")
188
+ func_name = self._get_function_name(node.func)
189
+
190
+ if func_name and self._is_app_error_class(func_name):
191
+ if func_name == "HTTPException":
192
+ # Special handling for HTTPException
193
+ error_info = self._extract_http_exception_info(node)
194
+ else:
195
+ error_info = {
196
+ "type": func_name,
197
+ "code": self._extract_error_code(node),
198
+ "message": self._extract_error_message(node),
199
+ "line": getattr(node, "lineno", None),
200
+ }
201
+ return error_info
202
+
203
+ elif isinstance(node, ast.Name):
204
+ # Handle: raise existing_error
205
+ return {
206
+ "type": "unknown",
207
+ "code": "UNKNOWN_ERROR",
208
+ "message": "Re-raised error",
209
+ "line": getattr(node, "lineno", None),
210
+ }
211
+
212
+ return None
213
+
214
+ def _extract_error_code(self, node: ast.Call) -> str:
215
+ """Extract error code from exception constructor."""
216
+ # Look for code parameter
217
+ for keyword in node.keywords:
218
+ if keyword.arg == "code":
219
+ # Handle string constants
220
+ if isinstance(keyword.value, ast.Constant) and isinstance(
221
+ keyword.value.value, str
222
+ ):
223
+ return keyword.value.value
224
+ # Handle ErrorCode.CONSTANT_NAME
225
+ elif isinstance(keyword.value, ast.Attribute):
226
+ if (
227
+ isinstance(keyword.value.value, ast.Name)
228
+ and keyword.value.value.id == "ErrorCode"
229
+ ):
230
+ return keyword.value.attr
231
+ # Handle ErrorCode("CUSTOM_ERROR_CODE")
232
+ elif isinstance(keyword.value, ast.Call):
233
+ if (
234
+ isinstance(keyword.value.func, ast.Name)
235
+ and keyword.value.func.id == "ErrorCode"
236
+ and keyword.value.args
237
+ and isinstance(keyword.value.args[0], ast.Constant)
238
+ and isinstance(keyword.value.args[0].value, str)
239
+ ):
240
+ return keyword.value.args[0].value
241
+ return "UNKNOWN_ERROR"
242
+
243
+ # Look for ErrorCode in positional args
244
+ for arg in node.args:
245
+ if isinstance(arg, ast.Call):
246
+ func_name = self._get_function_name(arg.func)
247
+ if func_name == "ErrorCode" and arg.args:
248
+ return self._extract_string_value(arg.args[0]) or "UNKNOWN_ERROR"
249
+
250
+ # Default based on exception type
251
+ func_name = self._get_function_name(node.func)
252
+ return self._get_default_error_code(func_name or "unknown")
253
+
254
+ def _extract_error_message(self, node: ast.Call) -> str:
255
+ """Extract error message from exception constructor."""
256
+ if node.args:
257
+ message = self._extract_string_value(node.args[0])
258
+ if message:
259
+ return message
260
+
261
+ # Look for message parameter
262
+ for keyword in node.keywords:
263
+ if keyword.arg == "message":
264
+ return self._extract_string_value(keyword.value) or "Unknown error"
265
+
266
+ return "Unknown error"
267
+
268
+ def _extract_string_value(self, node: ast.AST) -> Optional[str]:
269
+ """Extract string value from AST node."""
270
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
271
+ return node.value
272
+ return None
273
+
274
+ def _get_function_name(self, node: ast.AST) -> Optional[str]:
275
+ """Get function name from call node."""
276
+ if isinstance(node, ast.Name):
277
+ return node.id
278
+ elif isinstance(node, ast.Attribute):
279
+ return node.attr
280
+ return None
281
+
282
+ def _is_app_error_class(self, name: str) -> bool:
283
+ """Check if name is an AppError class."""
284
+ app_error_classes = {
285
+ "AppError",
286
+ "ValidationError",
287
+ "AuthError",
288
+ "NotFoundError",
289
+ "DatabaseError",
290
+ "BusinessLogicError",
291
+ "HTTPException", # Support FastAPI HTTPException
292
+ }
293
+ return name in app_error_classes
294
+
295
+ def _get_default_error_code(self, class_name: str) -> str:
296
+ """Get default error code for exception class."""
297
+ default_codes = {
298
+ "ValidationError": "VALIDATION_FAILED",
299
+ "AuthError": "AUTH_REQUIRED",
300
+ "NotFoundError": "RESOURCE_NOT_FOUND",
301
+ "DatabaseError": "DB_QUERY_ERROR",
302
+ "BusinessLogicError": "BUSINESS_RULE_VIOLATION",
303
+ "AppError": "INTERNAL_ERROR",
304
+ "HTTPException": "HTTP_EXCEPTION",
305
+ }
306
+ return default_codes.get(class_name, "UNKNOWN_ERROR")
307
+
308
+ def _extract_http_exception_info(self, node: ast.Call) -> Dict[str, Any]:
309
+ """Extract error information from HTTPException constructor."""
310
+ status_code = 500
311
+ message = "HTTP Error"
312
+
313
+ # Extract status_code from kwargs or positional args
314
+ for keyword in node.keywords:
315
+ if keyword.arg == "status_code":
316
+ if isinstance(keyword.value, ast.Constant) and isinstance(
317
+ keyword.value.value, int
318
+ ):
319
+ status_code = keyword.value.value
320
+ elif keyword.arg == "detail":
321
+ if isinstance(keyword.value, ast.Constant) and isinstance(
322
+ keyword.value.value, str
323
+ ):
324
+ message = keyword.value.value
325
+
326
+ # Check positional args for status_code
327
+ if (
328
+ node.args
329
+ and isinstance(node.args[0], ast.Constant)
330
+ and isinstance(node.args[0].value, int)
331
+ ):
332
+ status_code = node.args[0].value
333
+
334
+ # Map status code to error code
335
+ error_code = self._map_status_code_to_error_code(status_code)
336
+
337
+ return {
338
+ "type": "HTTPException",
339
+ "code": error_code,
340
+ "message": message,
341
+ "status_code": status_code,
342
+ "line": getattr(node, "lineno", None),
343
+ }
344
+
345
+ def _map_status_code_to_error_code(self, status_code: int) -> str:
346
+ """Map HTTP status code to appropriate error code."""
347
+ status_to_error = {
348
+ 400: "VALIDATION_FAILED",
349
+ 401: "AUTH_REQUIRED",
350
+ 403: "AUTH_PERMISSION_DENIED",
351
+ 404: "RESOURCE_NOT_FOUND",
352
+ 409: "RESOURCE_CONFLICT",
353
+ 422: "BUSINESS_RULE_VIOLATION",
354
+ 500: "INTERNAL_ERROR",
355
+ }
356
+ return status_to_error.get(status_code, "HTTP_EXCEPTION")
357
+
358
+ def _analyze_decorators(self, func: Callable) -> None:
359
+ """Analyze function decorators for potential errors."""
360
+ try:
361
+ source_lines = inspect.getsourcelines(func)[0]
362
+
363
+ decorator_lines = []
364
+ for line in source_lines:
365
+ stripped = line.strip()
366
+ if stripped.startswith("@"):
367
+ decorator_lines.append(stripped)
368
+ elif stripped.startswith("def "):
369
+ break
370
+
371
+ for decorator_line in decorator_lines:
372
+ self._analyze_decorator_line(decorator_line)
373
+
374
+ except (OSError, TypeError):
375
+ pass
376
+
377
+ def _analyze_decorator_line(self, decorator_line: str) -> None:
378
+ """Analyze a single decorator line."""
379
+ try:
380
+ decorator_name = decorator_line.replace("@", "").strip()
381
+
382
+ if "(" in decorator_name:
383
+ decorator_name = decorator_name.split("(")[0]
384
+
385
+ # Common decorator patterns
386
+ decorator_errors = {
387
+ "require_auth": ["AUTH_REQUIRED", "AUTH_PERMISSION_DENIED"],
388
+ "validate_input": ["VALIDATION_FAILED", "INVALID_INPUT"],
389
+ "rate_limit": ["RATE_LIMIT_EXCEEDED"],
390
+ "cache": ["CACHE_ERROR"],
391
+ }
392
+
393
+ if decorator_name in decorator_errors:
394
+ errors = decorator_errors[decorator_name]
395
+ self.errors.update(errors)
396
+ self.decorator_errors.append(
397
+ {
398
+ "decorator": decorator_name,
399
+ "possible_errors": errors,
400
+ "type": "decorator_analysis",
401
+ }
402
+ )
403
+
404
+ except Exception:
405
+ pass
406
+
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
+ def _analyze_builtin_function(self, func: Callable) -> None:
416
+ """Analyze built-in functions for common error patterns using existing converters."""
417
+ func_name = getattr(func, "__name__", str(func))
418
+ module_name = getattr(func, "__module__", "")
419
+
420
+ # SQLAlchemy errors - use existing SQL converter knowledge
421
+ if "sqlalchemy" in module_name.lower():
422
+ self._add_sqlalchemy_errors()
423
+
424
+ # JSON errors
425
+ elif func_name in ["loads", "dumps"] and "json" in module_name:
426
+ self.errors.add("INVALID_FORMAT")
427
+
428
+ # HTTP client errors
429
+ elif "requests" in module_name or "httpx" in module_name:
430
+ self.errors.update(["INTERNAL_ERROR", "NETWORK_ERROR"])
431
+
432
+ def _add_sqlalchemy_errors(self) -> None:
433
+ """Add SQLAlchemy errors using existing converter knowledge."""
434
+ from ..converters.sql_converter import SQLErrorConverter
435
+ from ..core.error_codes import ErrorCode
436
+
437
+ # Get all possible error codes from SQL converter patterns
438
+ sql_error_codes = set()
439
+ for pattern, (code, message) in SQLErrorConverter.SQL_PATTERNS.items():
440
+ sql_error_codes.add(code.value)
441
+
442
+ # Add common SQLAlchemy errors based on existing converter
443
+ sql_error_codes.update(
444
+ [
445
+ ErrorCode.DB_CONNECTION_ERROR.value,
446
+ ErrorCode.DB_QUERY_ERROR.value,
447
+ ErrorCode.DB_TRANSACTION_ERROR.value,
448
+ ]
449
+ )
450
+
451
+ 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
@@ -0,0 +1,7 @@
1
+ from .response_parser import ErrorResponseParser
2
+ from .exceptions import BackendError
3
+
4
+ __all__ = [
5
+ "ErrorResponseParser",
6
+ "BackendError",
7
+ ]
@@ -0,0 +1,97 @@
1
+ from typing import Optional, Dict, Any
2
+ from ..core.error_response import ErrorDetail
3
+
4
+
5
+ class BackendError(Exception):
6
+ """
7
+ Client-side exception for handling backend API errors.
8
+
9
+ This exception is raised when the client receives an error response
10
+ from the backend API. It wraps the error details from the server
11
+ and provides convenient properties for error handling.
12
+
13
+ Unlike core exceptions (AppError, ValidationError, etc.), this is used
14
+ on the client side to handle errors received from the server.
15
+
16
+ Usage:
17
+ try:
18
+ response = api_client.get_user(123)
19
+ except BackendError as e:
20
+ if e.is_not_found_error():
21
+ print("User not found")
22
+ elif e.is_validation_error():
23
+ print("Invalid input")
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ error: ErrorDetail,
29
+ status_code: int,
30
+ response_headers: Optional[Dict[str, str]] = None,
31
+ ):
32
+ """
33
+ Initialize backend error.
34
+
35
+ Args:
36
+ error: Error detail model from server response
37
+ status_code: HTTP status code from response
38
+ response_headers: Response headers for additional context
39
+ """
40
+ self.error = error
41
+ self.status_code = status_code
42
+ self.response_headers = response_headers or {}
43
+
44
+ super().__init__(error.message)
45
+
46
+ @property
47
+ def code(self) -> str:
48
+ """Get error code from server response."""
49
+ return self.error.code
50
+
51
+ @property
52
+ def message(self) -> str:
53
+ """Get error message from server response."""
54
+ return self.error.message
55
+
56
+ @property
57
+ def details(self) -> Dict[str, Any]:
58
+ """Get error details from server response."""
59
+ return self.error.details
60
+
61
+ @property
62
+ def request_id(self) -> str:
63
+ """Get request ID for tracing."""
64
+ return self.error.request_id
65
+
66
+ @property
67
+ def timestamp(self):
68
+ """Get error timestamp from server."""
69
+ return self.error.timestamp
70
+
71
+ def is_validation_error(self) -> bool:
72
+ """Check if this is a validation error from server."""
73
+ return self.code in [
74
+ "VALIDATION_FAILED",
75
+ "INVALID_INPUT",
76
+ "MISSING_REQUIRED_FIELD",
77
+ ]
78
+
79
+ def is_auth_error(self) -> bool:
80
+ """Check if this is an authentication/authorization error from server."""
81
+ return self.code.startswith("AUTH_")
82
+
83
+ def is_not_found_error(self) -> bool:
84
+ """Check if this is a not found error from server."""
85
+ return self.code.endswith("_NOT_FOUND")
86
+
87
+ def is_database_error(self) -> bool:
88
+ """Check if this is a database error from server."""
89
+ return self.code.startswith("DB_")
90
+
91
+ def is_business_error(self) -> bool:
92
+ """Check if this is a business logic error from server."""
93
+ return self.code in [
94
+ "BUSINESS_RULE_VIOLATION",
95
+ "INSUFFICIENT_BALANCE",
96
+ "OPERATION_NOT_ALLOWED",
97
+ ]