1from __future__ import annotations
2
3import linecache
4import logging
5import reprlib
6import traceback
7from types import FrameType, TracebackType
8from typing import Any
9
10from plain.runtime import settings
11
12logger = logging.getLogger(__name__)
13
14
15class ExceptionFrame:
16 """Information about a single traceback frame."""
17
18 def __init__(
19 self, frame: FrameType, lineno: int, *, capture_locals: bool = False
20 ) -> None:
21 self.filename = frame.f_code.co_filename
22 self.lineno = lineno
23 self.name = frame.f_code.co_name
24 self.category = self._get_category(self.filename)
25 self.source_lines: list[dict[str, Any]] = self._extract_source_lines(
26 self.filename, lineno
27 )
28 self.locals: list[dict[str, str]] = (
29 self._extract_locals(frame.f_locals) if capture_locals else []
30 )
31
32 @staticmethod
33 def _get_category(filename: str) -> str:
34 """Categorize a frame by its source: app, plain, plainx, python, or third-party."""
35 # Python stdlib
36 if "lib/python" in filename and "site-packages" not in filename:
37 return "python"
38
39 # Plain framework - core and extension packages
40 # Installed: site-packages/plain/
41 # Local dev: /plain/plain/ or /plain-*/plain/
42 if (
43 "site-packages/plain/" in filename
44 or "/plain/plain/" in filename
45 or "/plain-" in filename
46 ):
47 return "plain"
48
49 # Plainx community packages (separate namespace)
50 # Installed: site-packages/plainx/
51 # Local dev: /plainx/ or /plainx-*/plainx/
52 if "site-packages/plainx/" in filename or "/plainx" in filename:
53 return "plainx"
54
55 # Third-party packages
56 if "site-packages" in filename or "dist-packages" in filename:
57 return "third-party"
58
59 # Everything else is app code
60 return "app"
61
62 @staticmethod
63 def _extract_source_lines(
64 filename: str, lineno: int, context_lines: int = 5
65 ) -> list[dict[str, Any]]:
66 """Extract source code lines around the error line."""
67 source_lines = []
68 start = max(1, lineno - context_lines)
69 end = lineno + context_lines + 1
70
71 for i in range(start, end):
72 line = linecache.getline(filename, i)
73 if line:
74 source_lines.append(
75 {
76 "lineno": i,
77 "code": line.rstrip("\n"),
78 "is_error_line": i == lineno,
79 }
80 )
81
82 return source_lines
83
84 @staticmethod
85 def _safe_repr(value: Any, max_length: int = 200) -> str:
86 """Safely repr a value, handling large objects and errors."""
87 try:
88 r = reprlib.Repr()
89 r.maxstring = max_length
90 r.maxother = max_length
91 return r.repr(value)
92 except Exception:
93 return f"<{type(value).__name__}>"
94
95 @classmethod
96 def _extract_locals(cls, f_locals: dict[str, Any]) -> list[dict[str, str]]:
97 """Extract local variables for display, sorted alphabetically."""
98 result = []
99 for name in sorted(f_locals.keys()):
100 if name.startswith("_"): # Skip private/dunder vars
101 continue
102 value = f_locals[name]
103 result.append(
104 {
105 "name": name,
106 "value": cls._safe_repr(value),
107 "type": type(value).__name__,
108 }
109 )
110 return result
111
112 @classmethod
113 def from_traceback(cls, tb: TracebackType | None) -> list[ExceptionFrame]:
114 """Extract all frames from a traceback."""
115 if tb is None:
116 return []
117
118 # Only capture locals in DEBUG mode to avoid exposing sensitive data in production
119 capture_locals = settings.DEBUG
120
121 frames = []
122 current_tb = tb
123
124 while current_tb is not None:
125 frames.append(
126 cls(
127 current_tb.tb_frame,
128 current_tb.tb_lineno,
129 capture_locals=capture_locals,
130 )
131 )
132 current_tb = current_tb.tb_next
133
134 return frames
135
136
137class ExceptionContext:
138 """Wrapper for exception with traceback data for template rendering."""
139
140 def __init__(self, exception: BaseException) -> None:
141 self.exception = exception
142 self.traceback_string: str = "".join(
143 traceback.format_tb(exception.__traceback__)
144 )
145
146 # Extract frames with source context (more complex, may fail)
147 try:
148 self.traceback_frames: list[ExceptionFrame] = list(
149 reversed(ExceptionFrame.from_traceback(exception.__traceback__))
150 )
151 except Exception:
152 logger.exception("Failed to extract traceback frames")
153 self.traceback_frames = []