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

1"""UI package exceptions and standardized error responses.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from enum import Enum 

7 

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 

13 

14 

15class UIError(LexigramError): 

16 """Base exception for all UI-domain errors.""" 

17 

18 _code: str = "LEX_ERR_UI_001" 

19 

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 

24 

25 def __str__(self) -> str: 

26 """Return plain message without code prefix — safe for UI-facing output.""" 

27 return self.message 

28 

29 

30class ErrorCategory(str, Enum): 

31 """Categories of errors with different handling strategies.""" 

32 

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 

39 

40 

41@dataclass 

42class FieldError: 

43 """Error for a specific form field.""" 

44 

45 field: str 

46 message: str 

47 code: str | None = None 

48 

49 

50@dataclass 

51class ErrorResponse: 

52 """Standardized error response for HTMX.""" 

53 

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 

61 

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 } 

72 

73 toast = InlineToast( 

74 message=self.message, 

75 toast_type=variant_map.get(self.category, "error"), 

76 ) 

77 

78 return render_to_string(toast) 

79 

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 ) 

91 

92 def to_inline_errors_html(self) -> str: 

93 """Render field errors for form validation.""" 

94 if not self.field_errors: 

95 return "" 

96 

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 ) 

108 

109 return render_to_string(el("div", *errors_html, class_="space-y-1")) 

110 

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 ) 

123 

124 return render_to_string( 

125 ErrorState( 

126 title=self.title, 

127 message=self.message, 

128 action=action, 

129 ), 

130 ) 

131 

132 

133# Factory functions for common error types 

134 

135 

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 ) 

148 

149 

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 ) 

160 

161 

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 ) 

172 

173 

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 ) 

187 

188 

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 ) 

202 

203 

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] 

210 

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) 

221 

222 if field_name is not None: 

223 normalised = [e for e in normalised if e.field == field_name] 

224 

225 if not normalised: 

226 return "" 

227 

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")) 

239 

240 

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 = [] 

247 

248 # Include flash/toast notification 

249 if include_flash: 

250 html_parts.append(error.to_flash_html()) 

251 

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()) 

258 

259 html = "".join(html_parts) 

260 

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} 

267 

268 return html, error.status_code, headers 

269 

270 

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]