Coverage for src/lexigram/web/errors/html_error_renderer.py: 14%

153 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Debug HTML error page renderer for Lexigram Web. 

2 

3Generates rich, browser-friendly error pages in debug mode. 

4Only activated when ``debug=True`` **and** the request prefers HTML 

5(``Accept: text/html``). In production (``debug=False``) all error 

6responses remain JSON regardless of the ``Accept`` header. 

7 

8Usage:: 

9 

10 renderer = DebugHtmlErrorRenderer() 

11 html_response = renderer.render(exc, request, status_code=500) 

12""" 

13 

14from __future__ import annotations 

15 

16import html 

17import linecache 

18import sys 

19import traceback 

20from typing import TYPE_CHECKING, Any 

21 

22if TYPE_CHECKING: 

23 from starlette.responses import HTMLResponse 

24 

25_STATUS_LABELS: dict[int, str] = { 

26 400: "Bad Request", 

27 401: "Unauthorized", 

28 403: "Forbidden", 

29 404: "Not Found", 

30 405: "Method Not Allowed", 

31 408: "Request Timeout", 

32 409: "Conflict", 

33 410: "Gone", 

34 422: "Unprocessable Entity", 

35 429: "Too Many Requests", 

36 500: "Internal Server Error", 

37 501: "Not Implemented", 

38 502: "Bad Gateway", 

39 503: "Service Unavailable", 

40 504: "Gateway Timeout", 

41} 

42 

43# Number of source lines to display around the error frame 

44_CONTEXT_LINES = 7 

45 

46# Headers whose values should be redacted in the debug page 

47_SENSITIVE_HEADERS = frozenset( 

48 { 

49 "authorization", 

50 "cookie", 

51 "x-api-key", 

52 "x-auth-token", 

53 "proxy-authorization", 

54 } 

55) 

56 

57 

58def _h(tag: str, *children: str | dict[str, Any] | None, **attrs: Any) -> str: 

59 """Minimal HTML element builder — no external dependencies required. 

60 

61 String children are concatenated as-is (caller is responsible for escaping). 

62 Dict children contribute attribute key/value pairs. 

63 Keyword args become attributes; trailing underscore stripped, remaining underscores 

64 converted to hyphens (e.g. ``class_`` → ``class``, ``x_data`` → ``x-data``). 

65 """ 

66 attr_parts: list[str] = [] 

67 text_parts: list[str] = [] 

68 for child in children: 

69 if child is None: 

70 continue 

71 if isinstance(child, dict): 

72 for k, v in child.items(): 

73 if v is False or v is None: 

74 continue 

75 if v is True: 

76 attr_parts.append(f" {k}") 

77 else: 

78 attr_parts.append(f' {k}="{html.escape(str(v))}"') 

79 else: 

80 text_parts.append(child) 

81 for key, val in attrs.items(): 

82 if val is None or val is False: 

83 continue 

84 name = key.rstrip("_").replace("_", "-") 

85 if val is True: 

86 attr_parts.append(f" {name}") 

87 else: 

88 attr_parts.append(f' {name}="{html.escape(str(val))}"') 

89 return f"<{tag}{''.join(attr_parts)}>{''.join(text_parts)}</{tag}>" 

90 

91 

92def _accepts_html(request: Any) -> bool: 

93 """Return True if the HTTP client prefers an HTML response. 

94 

95 Browsers send ``Accept: text/html,...`` while REST clients typically send 

96 ``Accept: application/json`` or omit the header entirely. We render HTML 

97 only when ``text/html`` appears in the ``Accept`` header **and** has a 

98 higher weight (q-value) than ``application/json``. When no ``Accept`` 

99 header is present we assume a non-browser client and return JSON. 

100 """ 

101 accept = "" 

102 try: 

103 accept = request.headers.get("accept", "") or request.headers.get("Accept", "") 

104 except Exception: # noqa: BLE001 

105 return False 

106 

107 if not accept or ( 

108 "text/html" not in accept and "text/*" not in accept and "*/*" not in accept 

109 ): 

110 return False 

111 

112 # Parse q-values for text/html and application/json 

113 html_q = 0.0 

114 json_q = 0.0 

115 for part in accept.split(","): 

116 part = part.strip() 

117 mime, *params = part.split(";") 

118 mime = mime.strip().lower() 

119 q = 1.0 

120 for p in params: 

121 p = p.strip() 

122 if p.startswith("q="): 

123 try: 

124 q = float(p[2:]) 

125 except ValueError: 

126 pass 

127 if mime in ("text/html", "text/*", "*/*"): 

128 html_q = max(html_q, q) 

129 elif mime in ("application/json", "application/*"): 

130 json_q = max(json_q, q) 

131 

132 return html_q >= json_q and html_q > 0.0 

133 

134 

135def _extract_frames(exc: BaseException) -> list[dict[str, Any]]: 

136 """Extract traceback frames with surrounding source context. 

137 

138 Returns a list of dicts, each containing: 

139 - ``filename``: absolute path 

140 - ``lineno``: error line number (1-based) 

141 - ``name``: function/method name 

142 - ``lines``: list of ``(line_number, code, is_error_line)`` tuples 

143 """ 

144 tb = exc.__traceback__ 

145 if tb is None: 

146 return [] 

147 

148 frames = [] 

149 for frame_summary in traceback.extract_tb(tb): 

150 filename = frame_summary.filename or "<unknown>" 

151 lineno = frame_summary.lineno or 0 

152 func_name = frame_summary.name or "<unknown>" 

153 

154 # Load surrounding lines 

155 start = max(1, lineno - _CONTEXT_LINES // 2) 

156 end = lineno + _CONTEXT_LINES // 2 + 1 

157 source_lines = [] 

158 for ln in range(start, end): 

159 code = linecache.getline(filename, ln) 

160 if code: 

161 source_lines.append((ln, code.rstrip("\n"), ln == lineno)) 

162 

163 frames.append( 

164 { 

165 "filename": filename, 

166 "lineno": lineno, 

167 "name": func_name, 

168 "lines": source_lines, 

169 } 

170 ) 

171 

172 return frames 

173 

174 

175def _redact_headers(request: Any) -> list[tuple[str, str]]: 

176 """Return request headers with sensitive values replaced by ``[REDACTED]``.""" 

177 rows: list[tuple[str, str]] = [] 

178 try: 

179 for key, value in request.headers.items(): 

180 display = "[REDACTED]" if key.lower() in _SENSITIVE_HEADERS else value 

181 rows.append((key, display)) 

182 except Exception: # noqa: BLE001, S110 

183 pass 

184 return rows 

185 

186 

187# --------------------------------------------------------------------------- 

188# HTML template helpers (pure Python — no external template engine required) 

189# --------------------------------------------------------------------------- 

190 

191_CSS = """ 

192 * { box-sizing: border-box; margin: 0; padding: 0; } 

193 body { 

194 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 

195 "Helvetica Neue", Arial, sans-serif; 

196 background: #0f1117; 

197 color: #e8eaf0; 

198 line-height: 1.6; 

199 min-height: 100vh; 

200 } 

201 .header { 

202 background: linear-gradient(135deg, #c0392b 0%, #922b21 100%); 

203 padding: 2rem 2.5rem; 

204 border-bottom: 3px solid #a93226; 

205 } 

206 .header h1 { font-size: 1rem; opacity: .7; font-weight: 400; margin-bottom: .4rem; letter-spacing: .05em; text-transform: uppercase; } 

207 .header h2 { font-size: 2rem; font-weight: 700; margin-bottom: .5rem; } 

208 .header .exc-type { 

209 display: inline-block; 

210 background: rgba(0,0,0,.3); 

211 padding: .2rem .7rem; 

212 border-radius: 4px; 

213 font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; 

214 font-size: .9rem; 

215 } 

216 .main { max-width: 1100px; margin: 0 auto; padding: 2rem 2.5rem; } 

217 .section { margin-bottom: 2.5rem; } 

218 .section-title { 

219 font-size: .7rem; 

220 text-transform: uppercase; 

221 letter-spacing: .12em; 

222 color: #8b9dc3; 

223 margin-bottom: 1rem; 

224 padding-bottom: .4rem; 

225 border-bottom: 1px solid #1e2433; 

226 } 

227 /* Traceback */ 

228 .frame { 

229 background: #161b27; 

230 border: 1px solid #252d40; 

231 border-radius: 6px; 

232 margin-bottom: 1rem; 

233 overflow: hidden; 

234 } 

235 .frame-header { 

236 background: #1a2035; 

237 padding: .6rem 1rem; 

238 font-family: monospace; 

239 font-size: .85rem; 

240 color: #8b9dc3; 

241 display: flex; 

242 justify-content: space-between; 

243 align-items: center; 

244 } 

245 .frame-header .fn-name { color: #7ec8e3; font-weight: 600; } 

246 .frame-header .file-path { color: #64748b; font-size: .78rem; } 

247 .code-block { overflow-x: auto; } 

248 .code-block table { width: 100%; border-collapse: collapse; } 

249 .code-block td { padding: .15rem 0; white-space: pre; font-family: monospace; font-size: .85rem; } 

250 .code-block .ln { color: #4a5568; padding-right: 1rem; padding-left: .8rem; user-select: none; text-align: right; min-width: 3.5rem; border-right: 2px solid #1e2433; } 

251 .code-block .src { padding-left: .8rem; color: #c0cfe0; } 

252 .code-block tr.error-line { background: rgba(231, 76, 60, .15); } 

253 .code-block tr.error-line .ln { border-right-color: #e74c3c; color: #e74c3c; } 

254 .code-block tr.error-line .src { color: #fff; } 

255 .frame.top-frame { border-color: #e74c3c; } 

256 /* Request info */ 

257 .kv-table { width: 100%; border-collapse: collapse; font-size: .88rem; } 

258 .kv-table td { padding: .5rem .8rem; border-bottom: 1px solid #1e2433; vertical-align: top; } 

259 .kv-table td:first-child { color: #8b9dc3; font-family: monospace; width: 30%; font-size: .82rem; } 

260 .kv-table td:last-child { font-family: monospace; word-break: break-all; } 

261 .kv-table tr:last-child td { border-bottom: none; } 

262 .badge { 

263 display: inline-block; 

264 padding: .15rem .5rem; 

265 border-radius: 3px; 

266 font-size: .78rem; 

267 font-weight: 600; 

268 font-family: monospace; 

269 } 

270 .badge-get { background: #1a4a2e; color: #6fcf97; } 

271 .badge-post { background: #1a2f4a; color: #56bcf9; } 

272 .badge-put { background: #3a2f1a; color: #f2994a; } 

273 .badge-patch { background: #2a1e3a; color: #bb6bd9; } 

274 .badge-delete { background: #3a1a1a; color: #eb5757; } 

275 .badge-other { background: #1e2433; color: #8b9dc3; } 

276 .footer { 

277 margin-top: 3rem; 

278 padding: 1.2rem 2.5rem; 

279 border-top: 1px solid #1e2433; 

280 font-size: .75rem; 

281 color: #4a5568; 

282 display: flex; 

283 justify-content: space-between; 

284 } 

285""" 

286 

287 

288def _method_badge(method: str) -> str: 

289 """Render an HTTP method badge as an HTML string.""" 

290 css_class = { 

291 "GET": "badge-get", 

292 "POST": "badge-post", 

293 "PUT": "badge-put", 

294 "PATCH": "badge-patch", 

295 "DELETE": "badge-delete", 

296 }.get(method.upper(), "badge-other") 

297 return f'<span class="badge {css_class}">{html.escape(method.upper())}</span>' 

298 

299 

300def _render_frames(frames: list[dict[str, Any]]) -> str: 

301 """Render traceback frames as structured HTML elements.""" 

302 if not frames: 

303 return _h( 

304 "p", "No traceback available.", style="color:#64748b;font-size:.9rem;" 

305 ) 

306 

307 parts: list[str] = [] 

308 for i, frame in enumerate(frames): 

309 is_top = i == len(frames) - 1 

310 fn_escaped = html.escape(frame["name"]) 

311 fp_escaped = html.escape(frame["filename"]) 

312 ln = frame["lineno"] 

313 

314 rows_html = "".join( 

315 _h( 

316 "tr", 

317 _h("td", str(line_no), class_="ln"), 

318 _h("td", html.escape(code), class_="src"), 

319 class_="error-line" if is_err else None, 

320 ) 

321 for line_no, code, is_err in frame["lines"] 

322 ) 

323 frame_html = _h( 

324 "div", 

325 _h( 

326 "div", 

327 _h("span", f'<span class="fn-name">{fn_escaped}</span>'), 

328 _h("span", f"{fp_escaped}:{ln}", class_="file-path"), 

329 class_="frame-header", 

330 ), 

331 _h( 

332 "div", 

333 _h("table", rows_html), 

334 class_="code-block", 

335 ), 

336 class_="frame top-frame" if is_top else "frame", 

337 ) 

338 parts.append(frame_html) 

339 return "\n".join(parts) 

340 

341 

342def _render_kv(pairs: list[tuple[str, str]]) -> str: 

343 """Render key-value pairs as a structured HTML table.""" 

344 if not pairs: 

345 return _h("p", "None", style="color:#64748b;font-size:.9rem;") 

346 rows_html = "".join( 

347 _h( 

348 "tr", 

349 _h("td", html.escape(str(k))), 

350 _h("td", html.escape(str(v))), 

351 ) 

352 for k, v in pairs 

353 ) 

354 return _h("table", rows_html, class_="kv-table") 

355 

356 

357def _render_debug_page( 

358 *, 

359 headline: str, 

360 exc_type: str, 

361 exc_msg: str, 

362 chain_html: str, 

363 traceback_html: str, 

364 request_html: str, 

365 query_html: str, 

366 python_ver: str, 

367 lx_ver: str, 

368) -> str: 

369 """Render the full debug error HTML page as a plain string.""" 

370 header = _h( 

371 "div", 

372 _h("h1", "Lexigram — Debug Mode"), 

373 _h("h2", html.escape(headline)), 

374 _h("span", f"{html.escape(exc_type)}: {exc_msg}", class_="exc-type"), 

375 chain_html, 

376 class_="header", 

377 ) 

378 main_content = _h( 

379 "div", 

380 _h( 

381 "div", 

382 _h("div", "Traceback (most recent call last)", class_="section-title"), 

383 traceback_html, 

384 class_="section", 

385 ), 

386 _h( 

387 "div", 

388 _h("div", "Request", class_="section-title"), 

389 request_html, 

390 class_="section", 

391 ), 

392 _h( 

393 "div", 

394 _h("div", "Query Parameters", class_="section-title"), 

395 query_html, 

396 class_="section", 

397 ), 

398 class_="main", 

399 ) 

400 footer = _h( 

401 "div", 

402 _h( 

403 "span", 

404 f"Python {html.escape(python_ver)}" 

405 f" &nbsp;·&nbsp; Lexigram {html.escape(lx_ver)}", 

406 ), 

407 _h("span", "This page is only visible in debug mode."), 

408 class_="footer", 

409 ) 

410 return ( 

411 "<!DOCTYPE html>" 

412 '<html lang="en">' 

413 "<head>" 

414 '<meta charset="utf-8">' 

415 f"<title>{html.escape(headline)}</title>" 

416 f"<style>{_CSS}</style>" 

417 "</head>" 

418 f"<body>{header}{main_content}{footer}</body>" 

419 "</html>" 

420 ) 

421 

422 

423class DebugHtmlErrorRenderer: 

424 """Renders a developer-friendly HTML error page for use in debug mode. 

425 

426 The page includes: 

427 - HTTP status code and error type 

428 - Full Python traceback with highlighted source context 

429 - Annotated request details (method, path, headers — sensitive values redacted) 

430 - Python / framework version footer 

431 

432 This class produces no output when ``debug=False``; it is designed to be 

433 composed into ``DefaultExceptionFilter`` and ``FilterPipeline``. 

434 """ 

435 

436 def should_render(self, request: Any) -> bool: 

437 """Return True when the client prefers an HTML response. 

438 

439 Args: 

440 request: The incoming HTTP request. 

441 

442 Returns: 

443 True when ``Accept`` header prefers HTML over JSON. 

444 """ 

445 return _accepts_html(request) 

446 

447 def render( 

448 self, 

449 exc: BaseException, 

450 request: Any, 

451 status_code: int = 500, 

452 title: str | None = None, 

453 ) -> HTMLResponse: 

454 """Build and return a rich HTML error response. 

455 

456 Args: 

457 exc: The exception that was raised. 

458 request: The incoming HTTP request. 

459 status_code: HTTP status code to return. 

460 title: Optional override for the error headline. 

461 

462 Returns: 

463 An ``HTMLResponse`` with full debug information. 

464 """ 

465 from starlette.responses import HTMLResponse 

466 

467 status_label = _STATUS_LABELS.get(status_code, "Error") 

468 exc_type = type(exc).__name__ 

469 exc_msg = html.escape(str(exc)) if str(exc) else "(no message)" 

470 headline = title or f"{status_code} {status_label}" 

471 

472 frames = _extract_frames(exc) 

473 traceback_html = _render_frames(frames) 

474 

475 # Chain of causes 

476 chain_causes: list[BaseException] = [] 

477 cause: BaseException | None = exc.__cause__ or exc.__context__ 

478 while cause is not None: 

479 chain_causes.append(cause) 

480 cause = cause.__cause__ or cause.__context__ 

481 

482 if chain_causes: 

483 chain_items_html = "".join( 

484 _h( 

485 "li", 

486 f"<code>{html.escape(type(c).__name__)}</code>:" 

487 f" {html.escape(str(c))}", 

488 ) 

489 for c in chain_causes 

490 ) 

491 chain_html = _h( 

492 "ul", 

493 chain_items_html, 

494 style="margin:.5rem 0 0 1rem;font-size:.85rem;color:#f0a500;", 

495 ) 

496 else: 

497 chain_html = "" 

498 

499 try: 

500 method = request.method 

501 path = str(request.url) 

502 except Exception: # noqa: BLE001 

503 method, path = "?", "?" 

504 

505 headers = _redact_headers(request) 

506 request_html = _render_kv([("Method", method), ("URL", path), *headers]) 

507 

508 try: 

509 qp = list(request.query_params.items()) 

510 query_html = ( 

511 _render_kv(qp) 

512 if qp 

513 else _h("p", "None", style="color:#64748b;font-size:.9rem;") 

514 ) 

515 except Exception: # noqa: BLE001 

516 query_html = _h("p", "N/A", style="color:#64748b;font-size:.9rem;") 

517 

518 python_ver = sys.version.split()[0] 

519 try: 

520 import lexigram as _lx 

521 

522 lx_ver = getattr(_lx, "__version__", "?") 

523 except Exception: # noqa: BLE001 

524 lx_ver = "?" 

525 

526 content = _render_debug_page( 

527 headline=headline, 

528 exc_type=exc_type, 

529 exc_msg=exc_msg, 

530 chain_html=chain_html, 

531 traceback_html=traceback_html, 

532 request_html=request_html, 

533 query_html=query_html, 

534 python_ver=python_ver, 

535 lx_ver=lx_ver, 

536 ) 

537 return HTMLResponse(content=content, status_code=status_code) 

538 

539 

540__all__ = ["DebugHtmlErrorRenderer"]