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
« 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.
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.
8Usage::
10 renderer = DebugHtmlErrorRenderer()
11 html_response = renderer.render(exc, request, status_code=500)
12"""
14from __future__ import annotations
16import html
17import linecache
18import sys
19import traceback
20from typing import TYPE_CHECKING, Any
22if TYPE_CHECKING:
23 from starlette.responses import HTMLResponse
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}
43# Number of source lines to display around the error frame
44_CONTEXT_LINES = 7
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)
58def _h(tag: str, *children: str | dict[str, Any] | None, **attrs: Any) -> str:
59 """Minimal HTML element builder — no external dependencies required.
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}>"
92def _accepts_html(request: Any) -> bool:
93 """Return True if the HTTP client prefers an HTML response.
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
107 if not accept or (
108 "text/html" not in accept and "text/*" not in accept and "*/*" not in accept
109 ):
110 return False
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)
132 return html_q >= json_q and html_q > 0.0
135def _extract_frames(exc: BaseException) -> list[dict[str, Any]]:
136 """Extract traceback frames with surrounding source context.
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 []
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>"
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))
163 frames.append(
164 {
165 "filename": filename,
166 "lineno": lineno,
167 "name": func_name,
168 "lines": source_lines,
169 }
170 )
172 return frames
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
187# ---------------------------------------------------------------------------
188# HTML template helpers (pure Python — no external template engine required)
189# ---------------------------------------------------------------------------
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"""
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>'
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 )
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"]
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)
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")
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" · 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 )
423class DebugHtmlErrorRenderer:
424 """Renders a developer-friendly HTML error page for use in debug mode.
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
432 This class produces no output when ``debug=False``; it is designed to be
433 composed into ``DefaultExceptionFilter`` and ``FilterPipeline``.
434 """
436 def should_render(self, request: Any) -> bool:
437 """Return True when the client prefers an HTML response.
439 Args:
440 request: The incoming HTTP request.
442 Returns:
443 True when ``Accept`` header prefers HTML over JSON.
444 """
445 return _accepts_html(request)
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.
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.
462 Returns:
463 An ``HTMLResponse`` with full debug information.
464 """
465 from starlette.responses import HTMLResponse
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}"
472 frames = _extract_frames(exc)
473 traceback_html = _render_frames(frames)
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__
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 = ""
499 try:
500 method = request.method
501 path = str(request.url)
502 except Exception: # noqa: BLE001
503 method, path = "?", "?"
505 headers = _redact_headers(request)
506 request_html = _render_kv([("Method", method), ("URL", path), *headers])
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;")
518 python_ver = sys.version.split()[0]
519 try:
520 import lexigram as _lx
522 lx_ver = getattr(_lx, "__version__", "?")
523 except Exception: # noqa: BLE001
524 lx_ver = "?"
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)
540__all__ = ["DebugHtmlErrorRenderer"]