Plain is headed towards 1.0! Subscribe for development updates →

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