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.
Files changed (41) hide show
  1. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/PKG-INFO +6 -7
  2. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/README.md +5 -6
  3. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/pyproject.toml +1 -1
  4. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/__init__.py +7 -1
  5. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/analysis/error_analyzer.py +47 -80
  6. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/sql_converter.py +0 -3
  7. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/universal_converter.py +23 -36
  8. awesome_errors-0.4.0/src/awesome_errors/core/_utils.py +24 -0
  9. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/error_response.py +7 -9
  10. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/exceptions.py +4 -3
  11. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/renderers.py +2 -12
  12. awesome_errors-0.4.0/src/awesome_errors/i18n/translator.py +139 -0
  13. awesome_errors-0.4.0/src/awesome_errors/middleware/_base.py +108 -0
  14. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/middleware/fastapi.py +35 -93
  15. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/middleware/litestar.py +21 -61
  16. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/websocket/exceptions.py +2 -1
  17. awesome_errors-0.3.0/src/awesome_errors/i18n/translator.py +0 -150
  18. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/.gitignore +0 -0
  19. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/LICENSE +0 -0
  20. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/analysis/__init__.py +0 -0
  21. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/analysis/decorators.py +0 -0
  22. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/client/__init__.py +0 -0
  23. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/client/exceptions.py +0 -0
  24. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/client/response_parser.py +0 -0
  25. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/__init__.py +0 -0
  26. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/generic.py +0 -0
  27. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/pydantic_converter.py +0 -0
  28. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/converters/python_converter.py +0 -0
  29. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/__init__.py +0 -0
  30. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/core/error_codes.py +0 -0
  31. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/__init__.py +0 -0
  32. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/locales/en/errors.json +0 -0
  33. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/locales/test/errors.json +0 -0
  34. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/i18n/locales/uk/errors.json +0 -0
  35. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/integrations/__init__.py +0 -0
  36. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/integrations/fastapi_auto_docs.py +0 -0
  37. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/litestar_utils.py +0 -0
  38. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/middleware/__init__.py +0 -0
  39. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/py.typed +0 -0
  40. {awesome_errors-0.3.0 → awesome_errors-0.4.0}/src/awesome_errors/websocket/__init__.py +0 -0
  41. {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.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
- #### 6. **Internationalization** (`src/awesome_errors/i18n/`)
85
+ #### 7. **Internationalization** (`src/awesome_errors/i18n/`)
84
86
 
85
87
  - **ErrorTranslator**: Multi-language error message support
86
- - Locale files in `src/awesome_errors/i18n/locales/`
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
- #### 6. **Internationalization** (`src/awesome_errors/i18n/`)
51
+ #### 7. **Internationalization** (`src/awesome_errors/i18n/`)
50
52
 
51
53
  - **ErrorTranslator**: Multi-language error message support
52
- - Locale files in `src/awesome_errors/i18n/locales/`
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "awesome-errors"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "A comprehensive error handling library for Python applications with i18n support"
5
5
  authors = [{ name = "THEROER", email = "theroer09@gmail.com" }]
6
6
  readme = "README.md"
@@ -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
- __version__ = "0.1.0"
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 to find all possible errors in a function."""
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: Maximum depth for recursive analysis
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
- # Analyze the main function
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.__name__,
52
- "error_codes": sorted(list(self.errors)),
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": self.current_depth,
57
- "max_depth_reached": self.current_depth >= self.max_depth,
62
+ "analysis_depth": 0,
63
+ "max_depth_reached": False,
58
64
  }
59
65
 
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
66
+ @staticmethod
67
+ def _get_source(func: Callable) -> Optional[str]:
68
+ """Return the dedented source of ``func``, or ``None`` if unavailable.
64
69
 
65
- func_name = f"{func.__module__}.{func.__qualname__}"
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
- # Avoid infinite recursion
68
- if func_name in self.visited_functions:
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
- if not is_main:
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
- """Enhanced call analysis with context-aware error detection."""
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 = set()
439
- for pattern, (code, message) in SQLErrorConverter.SQL_PATTERNS.items():
440
- sql_error_codes.add(code.value)
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
@@ -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
@@ -1,4 +1,8 @@
1
- from typing import Any, Optional
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
- if _PydanticErrorConverter is None:
58
- raise ImportError(
59
- "Install 'awesome-errors[pydantic]' to convert Pydantic validation errors."
60
- ) from None
61
- return _PydanticErrorConverter.convert(error)
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
- # Handle HTTP-related errors
86
- if "HTTPError" in error_type:
82
+ # JSON decode errors (subclass of ValueError; check before generic HTTP)
83
+ if isinstance(error, json.JSONDecodeError):
87
84
  return AppError(
88
- code=ErrorCode.INTERNAL_ERROR,
89
- message="HTTP request failed",
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
- # Handle JSON errors
94
- if "JSONDecodeError" in error_type:
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.INVALID_FORMAT,
97
- message="Invalid JSON format",
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
- # Handle import errors
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": error.name if hasattr(error, "name") else "unknown",
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, timezone
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=_now_utc)
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.isoformat().replace("+00:00", "Z")
27
+ data["timestamp"] = to_iso_z(timestamp)
30
28
  elif isinstance(timestamp, str):
31
29
  data["timestamp"] = timestamp
32
30
  else:
33
- data["timestamp"] = _now_utc().isoformat().replace("+00:00", "Z")
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 = _now_utc()
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 _now_utc(),
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, timezone
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 = datetime.now(timezone.utc)
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.isoformat() + "Z",
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": _isoformat(error.timestamp),
90
+ "timestamp": to_iso_z(error.timestamp),
101
91
  "request_id": error.request_id,
102
92
  "details": error.details,
103
93
  }