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,115 @@
1
+ """Utilities that render ``AppError`` instances into HTTP payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import Any, Callable, Dict, Optional
8
+
9
+ from ..core.exceptions import AppError
10
+ from .error_response import ErrorDetail, ErrorResponse
11
+
12
+
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
+ class ErrorResponseFormat(StrEnum):
25
+ """Supported HTTP error payload shapes."""
26
+
27
+ LEGACY = "legacy"
28
+ RFC7807 = "rfc7807"
29
+
30
+
31
+ @dataclass
32
+ class RenderResult:
33
+ payload: Dict[str, Any]
34
+ media_type: str
35
+
36
+
37
+ class ErrorResponseRenderer:
38
+ """Render ``AppError`` objects into dictionaries ready for JSON encoding."""
39
+
40
+ def __init__(
41
+ self,
42
+ format: ErrorResponseFormat = ErrorResponseFormat.LEGACY,
43
+ *,
44
+ problem_type_resolver: Optional[Callable[[AppError], str]] = None,
45
+ problem_extension_builder: Optional[
46
+ Callable[[AppError], Dict[str, Any]]
47
+ ] = None,
48
+ ):
49
+ self.format = format
50
+ self._problem_type_resolver = problem_type_resolver
51
+ self._problem_extension_builder = problem_extension_builder
52
+
53
+ def render(
54
+ self,
55
+ error: AppError,
56
+ *,
57
+ message: str,
58
+ request: Optional[Any] = None,
59
+ ) -> RenderResult:
60
+ if self.format == ErrorResponseFormat.RFC7807:
61
+ return self._render_problem_detail(error, message=message, request=request)
62
+ return self._render_legacy(error, message=message)
63
+
64
+ def _render_legacy(self, error: AppError, *, message: str) -> RenderResult:
65
+ detail = ErrorDetail(
66
+ code=error.code.value,
67
+ message=message,
68
+ details=error.details,
69
+ timestamp=error.timestamp,
70
+ request_id=error.request_id or "unknown",
71
+ )
72
+ envelope = ErrorResponse(error=detail)
73
+ return RenderResult(payload=envelope.to_dict(), media_type="application/json")
74
+
75
+ def _render_problem_detail(
76
+ self,
77
+ error: AppError,
78
+ *,
79
+ message: str,
80
+ request: Optional[Any],
81
+ ) -> RenderResult:
82
+ problem_type = (
83
+ self._problem_type_resolver(error)
84
+ if self._problem_type_resolver
85
+ else "about:blank"
86
+ )
87
+ instance: Optional[str] = None
88
+ if request is not None:
89
+ instance = getattr(request, "url", None)
90
+ if instance is not None:
91
+ instance = str(instance)
92
+
93
+ payload: Dict[str, Any] = {
94
+ "type": problem_type,
95
+ "title": error.message,
96
+ "status": error.status_code,
97
+ "detail": message,
98
+ "instance": instance,
99
+ "code": error.code.value,
100
+ "timestamp": _isoformat(error.timestamp),
101
+ "request_id": error.request_id,
102
+ "details": error.details,
103
+ }
104
+ extensions = (
105
+ self._problem_extension_builder(error)
106
+ if self._problem_extension_builder
107
+ else None
108
+ )
109
+ if extensions:
110
+ payload.update(extensions)
111
+
112
+ return RenderResult(
113
+ payload=payload,
114
+ media_type="application/problem+json",
115
+ )
File without changes
@@ -0,0 +1,32 @@
1
+ {
2
+ "UNKNOWN_ERROR": "An unknown error occurred",
3
+ "INTERNAL_ERROR": "Internal server error",
4
+ "VALIDATION_FAILED": "Validation failed",
5
+ "INVALID_INPUT": "Invalid input provided",
6
+ "MISSING_REQUIRED_FIELD": "Required field is missing",
7
+ "INVALID_FORMAT": "Invalid format",
8
+ "AUTH_REQUIRED": "Authentication required",
9
+ "AUTH_INVALID_TOKEN": "Invalid authentication token",
10
+ "AUTH_TOKEN_EXPIRED": "Authentication token has expired",
11
+ "AUTH_PERMISSION_DENIED": "Permission denied",
12
+ "AUTH_INSUFFICIENT_PRIVILEGES": "Insufficient privileges",
13
+ "RESOURCE_NOT_FOUND": "Resource not found",
14
+ "USER_NOT_FOUND": "User not found",
15
+ "ENTITY_NOT_FOUND": "Entity not found",
16
+ "DB_CONNECTION_ERROR": "Database connection error",
17
+ "DB_QUERY_ERROR": "Database query error",
18
+ "DB_CONSTRAINT_VIOLATION": "Database constraint violated",
19
+ "DB_DUPLICATE_ENTRY": "Duplicate entry",
20
+ "DB_INVALID_REFERENCE": "Invalid reference",
21
+ "DB_MISSING_REQUIRED": "Missing required field",
22
+ "BUSINESS_RULE_VIOLATION": "Business rule violated",
23
+ "INSUFFICIENT_BALANCE": "Insufficient balance",
24
+ "OPERATION_NOT_ALLOWED": "Operation not allowed",
25
+ "CUSTOM_ERROR": "Custom error in English",
26
+ "CUSTOM_ERROR_1": "First custom error",
27
+ "CUSTOM_ERROR_2": "Second custom error",
28
+ "USER_ERROR": "User {username} has {error_count} errors",
29
+ "TEMPLATE_ERROR": "User {username} has {missing_param} errors",
30
+ "SIMPLE_ERROR": "Simple error message",
31
+ "CUSTOM_TEST_ERROR": "Custom test error message"
32
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "UNICODE_ERROR": "Помилка з україськими символами 🇺🇦",
3
+ "EMOJI_ERROR": "Error with emojis 😊✨🚀"
4
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "UNKNOWN_ERROR": "Сталася невідома помилка",
3
+ "INTERNAL_ERROR": "Внутрішня помилка сервера",
4
+ "VALIDATION_FAILED": "Помилка валідації",
5
+ "INVALID_INPUT": "Недопустимі вхідні дані",
6
+ "MISSING_REQUIRED_FIELD": "Відсутнє обов'язкове поле",
7
+ "INVALID_FORMAT": "Невірний формат",
8
+ "AUTH_REQUIRED": "Потрібна автентифікація",
9
+ "AUTH_INVALID_TOKEN": "Недійсний токен автентифікації",
10
+ "AUTH_TOKEN_EXPIRED": "Термін дії токена закінчився",
11
+ "AUTH_PERMISSION_DENIED": "Доступ заборонено",
12
+ "AUTH_INSUFFICIENT_PRIVILEGES": "Недостатньо прав",
13
+ "RESOURCE_NOT_FOUND": "Ресурс не знайдено",
14
+ "USER_NOT_FOUND": "Користувача не знайдено",
15
+ "ENTITY_NOT_FOUND": "Об'єкт не знайдено",
16
+ "DB_CONNECTION_ERROR": "Помилка підключення до бази даних",
17
+ "DB_QUERY_ERROR": "Помилка запиту до бази даних",
18
+ "DB_CONSTRAINT_VIOLATION": "Порушено обмеження бази даних",
19
+ "DB_DUPLICATE_ENTRY": "Дублюючий запис",
20
+ "DB_INVALID_REFERENCE": "Недійсне посилання",
21
+ "DB_MISSING_REQUIRED": "Відсутнє обов'язкове поле",
22
+ "BUSINESS_RULE_VIOLATION": "Порушено бізнес-правило",
23
+ "INSUFFICIENT_BALANCE": "Недостатній баланс",
24
+ "OPERATION_NOT_ALLOWED": "Операція не дозволена",
25
+ "CUSTOM_TEST_ERROR": "Повідомлення про власну тестову помилку"
26
+ }
@@ -0,0 +1,150 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Dict, Optional, Any
4
+
5
+
6
+ class ErrorTranslator:
7
+ """Translator for error messages with i18n support."""
8
+
9
+ def __init__(self, locales_dir: Optional[Path] = None, default_locale: str = "en"):
10
+ """
11
+ Initialize translator.
12
+
13
+ Args:
14
+ locales_dir: Directory containing locale files
15
+ default_locale: Default locale to use
16
+ """
17
+ self.locales_dir = locales_dir or Path(__file__).parent / "locales"
18
+ self.default_locale = default_locale
19
+ self._translations: Dict[str, Dict[str, str]] = {}
20
+ self._load_translations()
21
+
22
+ def _load_translations(self) -> None:
23
+ """Load all translation files."""
24
+ if not self.locales_dir.exists():
25
+ self.locales_dir.mkdir(parents=True, exist_ok=True)
26
+ # Create default English translations
27
+ self._create_default_translations()
28
+
29
+ for locale_file in self.locales_dir.glob("*/errors.json"):
30
+ locale = locale_file.parent.name
31
+ try:
32
+ with open(locale_file, "r", encoding="utf-8") as f:
33
+ self._translations[locale] = json.load(f)
34
+ except Exception:
35
+ # Skip invalid files
36
+ pass
37
+
38
+ def _create_default_translations(self) -> None:
39
+ """Create default English translations."""
40
+ en_dir = self.locales_dir / "en"
41
+ en_dir.mkdir(exist_ok=True)
42
+
43
+ default_translations = {
44
+ # General errors
45
+ "UNKNOWN_ERROR": "An unknown error occurred",
46
+ "INTERNAL_ERROR": "Internal server error",
47
+ # Validation errors
48
+ "VALIDATION_FAILED": "Validation failed",
49
+ "INVALID_INPUT": "Invalid input provided",
50
+ "MISSING_REQUIRED_FIELD": "Required field is missing",
51
+ "INVALID_FORMAT": "Invalid format",
52
+ # Authentication errors
53
+ "AUTH_REQUIRED": "Authentication required",
54
+ "AUTH_INVALID_TOKEN": "Invalid authentication token",
55
+ "AUTH_TOKEN_EXPIRED": "Authentication token has expired",
56
+ # Authorization errors
57
+ "AUTH_PERMISSION_DENIED": "Permission denied",
58
+ "AUTH_INSUFFICIENT_PRIVILEGES": "Insufficient privileges",
59
+ # Not found errors
60
+ "RESOURCE_NOT_FOUND": "Resource not found",
61
+ "USER_NOT_FOUND": "User not found",
62
+ "ENTITY_NOT_FOUND": "Entity not found",
63
+ # Database errors
64
+ "DB_CONNECTION_ERROR": "Database connection error",
65
+ "DB_QUERY_ERROR": "Database query error",
66
+ "DB_CONSTRAINT_VIOLATION": "Database constraint violated",
67
+ "DB_DUPLICATE_ENTRY": "Duplicate entry",
68
+ "DB_INVALID_REFERENCE": "Invalid reference",
69
+ "DB_MISSING_REQUIRED": "Missing required field",
70
+ # Business logic errors
71
+ "BUSINESS_RULE_VIOLATION": "Business rule violated",
72
+ "INSUFFICIENT_BALANCE": "Insufficient balance",
73
+ "OPERATION_NOT_ALLOWED": "Operation not allowed",
74
+ }
75
+
76
+ with open(en_dir / "errors.json", "w", encoding="utf-8") as f:
77
+ json.dump(default_translations, f, indent=2, ensure_ascii=False)
78
+
79
+ self._translations["en"] = default_translations
80
+
81
+ def translate(
82
+ self,
83
+ error_code: str,
84
+ locale: Optional[str] = None,
85
+ params: Optional[Dict[str, Any]] = None,
86
+ ) -> str:
87
+ """
88
+ Translate error code to message.
89
+
90
+ Args:
91
+ error_code: Error code to translate
92
+ locale: Locale to use (e.g., 'en', 'uk')
93
+ params: Parameters for message formatting
94
+
95
+ Returns:
96
+ Translated message
97
+ """
98
+ locale = locale or self.default_locale
99
+
100
+ # Try requested locale
101
+ if locale in self._translations:
102
+ message = self._translations[locale].get(error_code)
103
+ if message:
104
+ return self._format_message(message, params)
105
+
106
+ # Fallback to English if available and different from requested locale (case sensitive)
107
+ if "en" in self._translations and locale.lower() != "en":
108
+ message = self._translations["en"].get(error_code)
109
+ if message:
110
+ return self._format_message(message, params)
111
+
112
+ # Return error code if no translation found
113
+ return error_code
114
+
115
+ def _format_message(self, message: str, params: Optional[Dict[str, Any]]) -> str:
116
+ """Format message with parameters."""
117
+ if not params:
118
+ return message
119
+
120
+ try:
121
+ return message.format(**params)
122
+ except Exception:
123
+ # Return unformatted message if formatting fails
124
+ return message
125
+
126
+ def add_translations(
127
+ self, locale: str, translations: Dict[str, str], *, persist: bool = True
128
+ ) -> None:
129
+ """Add or update translations for a locale.
130
+
131
+ Args:
132
+ locale: Locale identifier
133
+ translations: Mapping of error codes to translated messages
134
+ persist: Persist changes to disk. Set to ``False`` for ephemeral usage.
135
+ """
136
+ if locale not in self._translations:
137
+ self._translations[locale] = {}
138
+
139
+ self._translations[locale].update(translations)
140
+
141
+ if persist:
142
+ locale_dir = self.locales_dir / locale
143
+ locale_dir.mkdir(exist_ok=True)
144
+
145
+ with open(locale_dir / "errors.json", "w", encoding="utf-8") as f:
146
+ json.dump(self._translations[locale], f, indent=2, ensure_ascii=False)
147
+
148
+ def get_available_locales(self) -> list[str]:
149
+ """Get list of available locales."""
150
+ return list(self._translations.keys())
@@ -0,0 +1,11 @@
1
+ from .fastapi_auto_docs import (
2
+ setup_automatic_error_docs,
3
+ apply_auto_error_docs_to_router,
4
+ auto_analyze_errors,
5
+ )
6
+
7
+ __all__ = [
8
+ "setup_automatic_error_docs",
9
+ "apply_auto_error_docs_to_router",
10
+ "auto_analyze_errors",
11
+ ]
@@ -0,0 +1,220 @@
1
+ """
2
+ FastAPI automatic error documentation integration.
3
+ Automatically analyzes routes and adds error response schemas to OpenAPI docs.
4
+ """
5
+
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def auto_analyze_errors(func):
12
+ from ..analysis.decorators import analyze_errors
13
+
14
+ decorated_func = analyze_errors()(func)
15
+ decorated_func._auto_openapi = True
16
+ return decorated_func
17
+
18
+
19
+ def setup_automatic_error_docs(app, **kwargs):
20
+ """
21
+ Setup automatic error documentation for all FastAPI routes.
22
+
23
+ This function analyzes all route functions using AST analysis to find
24
+ possible errors and automatically adds comprehensive error response
25
+ schemas to the OpenAPI documentation.
26
+
27
+ Args:
28
+ app: FastAPI application instance
29
+ **kwargs: Additional configuration options
30
+ - include_http_exceptions: Whether to analyze HTTPException (default: True)
31
+ - max_depth: Maximum analysis depth for function calls (default: 3)
32
+ - exclude_paths: List of path patterns to exclude from analysis
33
+ """
34
+ try:
35
+ from fastapi import FastAPI
36
+
37
+ if not isinstance(app, FastAPI):
38
+ logger.warning("setup_automatic_error_docs expects a FastAPI app instance")
39
+ return
40
+
41
+ logger.info("Setting up automatic error documentation for FastAPI routes...")
42
+
43
+ # Apply to all routes in the app
44
+ _apply_auto_error_docs_to_app(app, **kwargs)
45
+
46
+ logger.info("Automatic error documentation setup complete")
47
+
48
+ except ImportError:
49
+ logger.warning("FastAPI not available, skipping automatic error documentation")
50
+ except Exception as e:
51
+ logger.error(f"Failed to setup automatic error documentation: {e}")
52
+
53
+
54
+ def _apply_auto_error_docs_to_app(app, **kwargs):
55
+ """Apply automatic error documentation to all routes in FastAPI app."""
56
+ from ..analysis.error_analyzer import ErrorAnalyzer
57
+ from ..analysis.decorators import _generate_openapi_responses
58
+
59
+ exclude_paths = kwargs.get("exclude_paths", [])
60
+ max_depth = kwargs.get("max_depth", 3)
61
+
62
+ routes_processed = 0
63
+ errors_found = 0
64
+
65
+ for route in app.routes:
66
+ # Skip if path is in exclude list
67
+ if any(pattern in route.path for pattern in exclude_paths):
68
+ continue
69
+
70
+ if hasattr(route, "endpoint") and hasattr(route, "methods"):
71
+ try:
72
+ endpoint_func = route.endpoint
73
+
74
+ # Analyze the function for possible errors
75
+ analyzer = ErrorAnalyzer(
76
+ endpoint_func, max_depth=max_depth, analyze_decorators=True
77
+ )
78
+ analysis = analyzer.analyze()
79
+ error_codes = analysis.get("error_codes", set())
80
+
81
+ if error_codes:
82
+ # Generate OpenAPI responses
83
+ openapi_responses = _generate_openapi_responses(error_codes, {})
84
+
85
+ # Apply responses to the route
86
+ if not hasattr(route, "responses"):
87
+ route.responses = {}
88
+
89
+ # Merge with existing responses (don't overwrite existing)
90
+ for status_code, response_schema in openapi_responses.items():
91
+ if status_code not in route.responses:
92
+ route.responses[status_code] = response_schema
93
+
94
+ routes_processed += 1
95
+ errors_found += len(error_codes)
96
+
97
+ logger.debug(
98
+ f"Applied error responses to {route.path} "
99
+ f"({', '.join(route.methods)}): {list(openapi_responses.keys())}"
100
+ )
101
+
102
+ except Exception as e:
103
+ logger.warning(f"Failed to analyze errors for route {route.path}: {e}")
104
+
105
+ # Also handle included routers
106
+ elif hasattr(route, "app") and hasattr(route.app, "routes"):
107
+ _process_sub_routes(route.app.routes, exclude_paths, max_depth)
108
+
109
+ logger.info(
110
+ f"Processed {routes_processed} routes, found {errors_found} total error codes"
111
+ )
112
+
113
+
114
+ def _process_sub_routes(routes, exclude_paths, max_depth):
115
+ """Process routes in sub-applications/routers."""
116
+ from ..analysis.error_analyzer import ErrorAnalyzer
117
+ from ..analysis.decorators import _generate_openapi_responses
118
+
119
+ for sub_route in routes:
120
+ # Skip if path is in exclude list
121
+ if any(pattern in sub_route.path for pattern in exclude_paths):
122
+ continue
123
+
124
+ if hasattr(sub_route, "endpoint") and hasattr(sub_route, "methods"):
125
+ try:
126
+ endpoint_func = sub_route.endpoint
127
+
128
+ analyzer = ErrorAnalyzer(
129
+ endpoint_func, max_depth=max_depth, analyze_decorators=True
130
+ )
131
+ analysis = analyzer.analyze()
132
+ error_codes = analysis.get("error_codes", set())
133
+
134
+ if error_codes:
135
+ openapi_responses = _generate_openapi_responses(error_codes, {})
136
+
137
+ if not hasattr(sub_route, "responses"):
138
+ sub_route.responses = {}
139
+
140
+ # Merge with existing responses
141
+ for status_code, response_schema in openapi_responses.items():
142
+ if status_code not in sub_route.responses:
143
+ sub_route.responses[status_code] = response_schema
144
+
145
+ logger.debug(
146
+ f"Applied error responses to sub-route {sub_route.path}: "
147
+ f"{list(openapi_responses.keys())}"
148
+ )
149
+
150
+ except Exception as e:
151
+ logger.warning(
152
+ f"Failed to analyze errors for sub-route {sub_route.path}: {e}"
153
+ )
154
+
155
+
156
+ def apply_auto_error_docs_to_router(router, **kwargs):
157
+ """
158
+ Apply automatic error documentation to a specific FastAPI router.
159
+
160
+ Args:
161
+ router: FastAPI APIRouter instance
162
+ **kwargs: Configuration options (same as setup_automatic_error_docs)
163
+ """
164
+ try:
165
+ from fastapi import APIRouter
166
+
167
+ if not isinstance(router, APIRouter):
168
+ logger.warning(
169
+ "apply_auto_error_docs_to_router expects an APIRouter instance"
170
+ )
171
+ return
172
+
173
+ _apply_auto_error_docs_to_routes(router.routes, **kwargs)
174
+
175
+ except ImportError:
176
+ logger.warning("FastAPI not available for router error documentation")
177
+ except Exception as e:
178
+ logger.error(f"Failed to apply error docs to router: {e}")
179
+
180
+
181
+ def _apply_auto_error_docs_to_routes(routes, **kwargs):
182
+ """Apply automatic error documentation to a list of routes."""
183
+ from ..analysis.error_analyzer import ErrorAnalyzer
184
+ from ..analysis.decorators import _generate_openapi_responses
185
+
186
+ exclude_paths = kwargs.get("exclude_paths", [])
187
+ max_depth = kwargs.get("max_depth", 3)
188
+
189
+ for route in routes:
190
+ # Skip if path is in exclude list
191
+ if any(pattern in route.path for pattern in exclude_paths):
192
+ continue
193
+
194
+ if hasattr(route, "endpoint") and hasattr(route, "methods"):
195
+ try:
196
+ endpoint_func = route.endpoint
197
+
198
+ analyzer = ErrorAnalyzer(
199
+ endpoint_func, max_depth=max_depth, analyze_decorators=True
200
+ )
201
+ analysis = analyzer.analyze()
202
+ error_codes = analysis.get("error_codes", set())
203
+
204
+ if error_codes:
205
+ openapi_responses = _generate_openapi_responses(error_codes, {})
206
+
207
+ if not hasattr(route, "responses"):
208
+ route.responses = {}
209
+
210
+ # Merge with existing responses
211
+ for status_code, response_schema in openapi_responses.items():
212
+ if status_code not in route.responses:
213
+ route.responses[status_code] = response_schema
214
+
215
+ logger.debug(
216
+ f"Applied error responses to {route.path}: {list(openapi_responses.keys())}"
217
+ )
218
+
219
+ except Exception as e:
220
+ logger.warning(f"Failed to analyze errors for route {route.path}: {e}")
@@ -0,0 +1,55 @@
1
+ """Litestar-specific helpers for API error metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Tuple, Type, TypeVar, cast
6
+
7
+ from .core.exceptions import APIError
8
+
9
+ ErrorType = TypeVar("ErrorType", bound=APIError)
10
+
11
+
12
+ def _is_http_route_handler(obj: object) -> bool:
13
+ try: # pragma: no cover - optional dependency
14
+ from litestar.handlers.http_handlers import HTTPRouteHandler
15
+ except ImportError: # pragma: no cover
16
+ return False
17
+ return isinstance(obj, HTTPRouteHandler)
18
+
19
+
20
+ def raises_from(*errors: Type[APIError]) -> list[Type[APIError]]:
21
+ """Return a Litestar-compatible raises list from APIError classes."""
22
+ return list(errors)
23
+
24
+
25
+ def errors(*errs: Type[APIError]):
26
+ """Attach OpenAPI error responses to a Litestar handler."""
27
+
28
+ def wrap(obj: Any) -> Any:
29
+ if _is_http_route_handler(obj):
30
+ current = getattr(obj, "raises", None) or []
31
+ if isinstance(current, dict):
32
+ current = []
33
+ obj.raises = cast(Any, [*list(current), *errs])
34
+ return obj
35
+ setattr(obj, "__api_errors__", errs)
36
+ return obj
37
+
38
+ return wrap
39
+
40
+
41
+ def apply_api_errors(handlers: Iterable[object]) -> None:
42
+ """Apply deferred error metadata to handlers (optional helper)."""
43
+ for handler in handlers:
44
+ if not _is_http_route_handler(handler):
45
+ continue
46
+ typed_handler = cast(Any, handler)
47
+ errs: Tuple[Type[APIError], ...] | None = getattr(
48
+ typed_handler.fn, "__api_errors__", None
49
+ )
50
+ if not errs:
51
+ continue
52
+ current = typed_handler.raises or []
53
+ if isinstance(current, dict):
54
+ current = []
55
+ typed_handler.raises = cast(Any, [*list(current), *errs])
File without changes