Coverage for src / lexigram / ui / exceptions.py: 95%
93 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""UI package exceptions and standardized error responses."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from enum import Enum
8from lexigram.contracts.exceptions import LexigramError
9from lexigram.ui.core.base import el, render_to_string
10from lexigram.ui.core.zones import Zones
11from lexigram.ui.molecules.error_state import ErrorState
12from lexigram.ui.molecules.toast import InlineToast
15class UIError(LexigramError):
16 """Base exception for all UI-domain errors."""
18 _code: str = "LEX_ERR_UI_001"
20 def __init__(self, message: str, *, code: str | None = None) -> None:
21 super().__init__(message)
22 if code is not None:
23 self.code = code
25 def __str__(self) -> str:
26 """Return plain message without code prefix — safe for UI-facing output."""
27 return self.message
30class ErrorCategory(str, Enum):
31 """Categories of errors with different handling strategies."""
33 VALIDATION = "validation" # 422 - Inline field errors
34 NOT_FOUND = "not_found" # 404 - Toast + stay on page
35 PERMISSION = "permission" # 403 - Toast + disable action
36 SERVER = "server" # 500 - Toast + retry option
37 NETWORK = "network" # Connection issues
38 TIMEOUT = "timeout" # 408/504 - Toast + retry button
41@dataclass
42class FieldError:
43 """Error for a specific form field."""
45 field: str
46 message: str
47 code: str | None = None
50@dataclass
51class ErrorResponse:
52 """Standardized error response for HTMX."""
54 category: ErrorCategory
55 title: str
56 message: str
57 status_code: int = 500
58 field_errors: list[FieldError] = field(default_factory=list)
59 retry_url: str | None = None
60 can_retry: bool = False
62 def to_toast_html(self) -> str:
63 """Render as a toast notification."""
64 variant_map = {
65 ErrorCategory.VALIDATION: "warning",
66 ErrorCategory.NOT_FOUND: "error",
67 ErrorCategory.PERMISSION: "error",
68 ErrorCategory.SERVER: "error",
69 ErrorCategory.NETWORK: "warning",
70 ErrorCategory.TIMEOUT: "warning",
71 }
73 toast = InlineToast(
74 message=self.message,
75 toast_type=variant_map.get(self.category, "error"),
76 )
78 return render_to_string(toast)
80 def to_flash_html(self) -> str:
81 """Render as an OOB flash message targeting the flash container."""
82 toast_html = self.to_toast_html()
83 return render_to_string(
84 el(
85 "div",
86 toast_html,
87 id=Zones.FLASH.id,
88 hx_swap_oob="innerHTML",
89 ),
90 )
92 def to_inline_errors_html(self) -> str:
93 """Render field errors for form validation."""
94 if not self.field_errors:
95 return ""
97 errors_html = []
98 for err in self.field_errors:
99 errors_html.append(
100 el(
101 "div",
102 el("span", f"{err.field}: ", class_="font-medium"),
103 err.message,
104 class_="text-sm text-destructive",
105 id=f"error-{err.field}",
106 ),
107 )
109 return render_to_string(el("div", *errors_html, class_="space-y-1"))
111 def to_error_state_html(self) -> str:
112 """Render as a full error state component."""
113 action = None
114 if self.can_retry and self.retry_url:
115 action = el(
116 "button",
117 "Try Again",
118 class_="px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors",
119 hx_get=self.retry_url,
120 hx_target=Zones.DATA.selector,
121 hx_swap=Zones.DATA.swap_mode.value,
122 )
124 return render_to_string(
125 ErrorState(
126 title=self.title,
127 message=self.message,
128 action=action,
129 ),
130 )
133# Factory functions for common error types
136def validation_error(
137 message: str = "Please correct the errors below.",
138 field_errors: list[FieldError] | None = None,
139) -> ErrorResponse:
140 """Create a validation error response (422)."""
141 return ErrorResponse(
142 category=ErrorCategory.VALIDATION,
143 title="Validation Error",
144 message=message,
145 status_code=422,
146 field_errors=field_errors or [],
147 )
150def not_found_error(
151 message: str = "The requested resource was not found.",
152) -> ErrorResponse:
153 """Create a not found error response (404)."""
154 return ErrorResponse(
155 category=ErrorCategory.NOT_FOUND,
156 title="Not Found",
157 message=message,
158 status_code=404,
159 )
162def permission_error(
163 message: str = "You don't have permission to perform this action.",
164) -> ErrorResponse:
165 """Create a permission error response (403)."""
166 return ErrorResponse(
167 category=ErrorCategory.PERMISSION,
168 title="Permission Denied",
169 message=message,
170 status_code=403,
171 )
174def server_error(
175 message: str = "An unexpected error occurred. Please try again.",
176 retry_url: str | None = None,
177) -> ErrorResponse:
178 """Create a server error response (500)."""
179 return ErrorResponse(
180 category=ErrorCategory.SERVER,
181 title="Server Error",
182 message=message,
183 status_code=500,
184 can_retry=retry_url is not None,
185 retry_url=retry_url,
186 )
189def timeout_error(
190 message: str = "The request timed out. Please try again.",
191 retry_url: str | None = None,
192) -> ErrorResponse:
193 """Create a timeout error response (504)."""
194 return ErrorResponse(
195 category=ErrorCategory.TIMEOUT,
196 title="Request Timeout",
197 message=message,
198 status_code=504,
199 can_retry=True,
200 retry_url=retry_url,
201 )
204def render_validation_errors(
205 errors: list[FieldError] | dict[str, str | list[str]],
206 field_name: str | None = None,
207) -> str:
208 """Render validation errors as an HTML string."""
209 normalised: list[FieldError]
211 if isinstance(errors, dict):
212 normalised = []
213 for field, messages in errors.items():
214 if isinstance(messages, list):
215 for msg in messages:
216 normalised.append(FieldError(field=field, message=str(msg)))
217 else:
218 normalised.append(FieldError(field=field, message=str(messages)))
219 else:
220 normalised = list(errors)
222 if field_name is not None:
223 normalised = [e for e in normalised if e.field == field_name]
225 if not normalised:
226 return ""
228 items = [
229 el(
230 "div",
231 el("span", f"{err.field}: ", class_="font-medium"),
232 err.message,
233 class_="text-sm text-destructive",
234 id=f"error-{err.field}",
235 )
236 for err in normalised
237 ]
238 return render_to_string(el("div", *items, class_="space-y-1"))
241def htmx_error_response(
242 error: ErrorResponse,
243 include_flash: bool = True,
244) -> tuple[str, int, dict]:
245 """Build an HTMX-compatible error response."""
246 html_parts = []
248 # Include flash/toast notification
249 if include_flash:
250 html_parts.append(error.to_flash_html())
252 # For validation errors, return inline errors
253 if error.category == ErrorCategory.VALIDATION:
254 html_parts.append(error.to_inline_errors_html())
255 else:
256 # For other errors, optionally include error state
257 html_parts.append(error.to_error_state_html())
259 html = "".join(html_parts)
261 headers = {
262 "HX-Retarget": Zones.FLASH.selector if include_flash else None,
263 "HX-Reswap": "innerHTML",
264 }
265 # Remove None headers
266 headers = {k: v for k, v in headers.items() if v is not None}
268 return html, error.status_code, headers
271__all__ = [
272 "ErrorCategory",
273 "ErrorResponse",
274 "FieldError",
275 "UIError",
276 "htmx_error_response",
277 "not_found_error",
278 "permission_error",
279 "render_validation_errors",
280 "server_error",
281 "timeout_error",
282 "validation_error",
283]