tracerite 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tracerite/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ from .fastapi import patch_fastapi
2
+ from .html import html_traceback
3
+ from .inspector import extract_variables, prettyvalue
4
+ from .notebook import load_ipython_extension, unload_ipython_extension
5
+ from .trace import extract_chain
6
+ from .tty import load, tty_traceback, unload
7
+
8
+ __all__ = [
9
+ "load",
10
+ "unload",
11
+ "tty_traceback",
12
+ "html_traceback",
13
+ "extract_chain",
14
+ "prettyvalue",
15
+ "extract_variables",
16
+ "load_ipython_extension",
17
+ "unload_ipython_extension",
18
+ "patch_fastapi",
19
+ ]
tracerite/fastapi.py ADDED
@@ -0,0 +1,46 @@
1
+ """TraceRite extension for FastAPI/Starlette applications."""
2
+
3
+ from .html import html_traceback
4
+ from .logging import logger
5
+
6
+ _original_debug_response = None
7
+
8
+
9
+ def patch_fastapi():
10
+ """
11
+ Load TraceRite extension for FastAPI by patching ServerErrorMiddleware.
12
+
13
+ This patches Starlette's ServerErrorMiddleware.debug_response to return
14
+ TraceRite HTML tracebacks instead of the default debug HTML when running
15
+ in debug mode and the client accepts HTML.
16
+ """
17
+ global _original_debug_response
18
+ if _original_debug_response is not None:
19
+ return # Already loaded
20
+ try:
21
+ import fastapi # ty: ignore
22
+ from starlette.middleware.errors import ServerErrorMiddleware # ty: ignore
23
+ from starlette.responses import HTMLResponse # ty: ignore
24
+ except ImportError:
25
+ logger.info("TraceRite FastAPI cannot load: FastAPI/Starlette not found")
26
+ return
27
+ _original_debug_response = ServerErrorMiddleware.debug_response
28
+
29
+ def tracerite_debug_response(self, request, exc):
30
+ """Return TraceRite HTML traceback instead of Starlette's debug response."""
31
+ accept = request.headers.get("accept", "")
32
+ if "text/html" not in accept:
33
+ return _original_debug_response(self, request, exc) # type: ignore
34
+ try:
35
+ html = str(html_traceback(exc=exc, include_js_css=True))
36
+ return HTMLResponse(
37
+ "<!DOCTYPE html><title>FastAPI TraceRite</title>" + html,
38
+ status_code=500,
39
+ )
40
+ except Exception as e:
41
+ logger.error(f"Failed to generate TraceRite response: {e}")
42
+ return _original_debug_response(self, request, exc) # type: ignore
43
+
44
+ ServerErrorMiddleware.debug_response = tracerite_debug_response # type: ignore
45
+ fastapi.routing.__tracebackhide__ = "until" # type: ignore
46
+ logger.info("TraceRite FastAPI extension loaded")
tracerite/html.py ADDED
@@ -0,0 +1,343 @@
1
+ from importlib.resources import files
2
+
3
+ from html5tagger import HTML, E
4
+
5
+ from .trace import chainmsg, extract_chain
6
+
7
+ style = files(__package__).joinpath("style.css").read_text(encoding="UTF-8")
8
+ javascript = files(__package__).joinpath("script.js").read_text(encoding="UTF-8")
9
+
10
+ detail_show = "{display: inherit}"
11
+
12
+ symbols = {"call": "➤", "warning": "⚠️", "error": "💣", "stop": "🛑"}
13
+ tooltips = {
14
+ "call": "Call",
15
+ "warning": "Call from your code",
16
+ "error": "{type}",
17
+ "stop": "{type}",
18
+ }
19
+
20
+
21
+ def _collapse_call_runs(frames, min_run_length=10):
22
+ """Collapse consecutive runs of 'call' frames, keeping first and last of each run.
23
+
24
+ Only collapses runs of frames with relevance='call'. Non-call frames
25
+ (error, warning, stop) are never collapsed.
26
+ """
27
+ if not frames:
28
+ return frames
29
+
30
+ result = []
31
+ run_start = None
32
+
33
+ for i, frinfo in enumerate(frames):
34
+ if frinfo.get("relevance", "call") == "call":
35
+ if run_start is None:
36
+ run_start = i
37
+ else:
38
+ # End of a call run - process it
39
+ if run_start is not None:
40
+ run_length = i - run_start
41
+ if run_length >= min_run_length:
42
+ # Keep first and last of the run, add ellipsis
43
+ result.append(frames[run_start])
44
+ result.append(...)
45
+ result.append(frames[i - 1])
46
+ else:
47
+ # Run too short, keep all
48
+ result.extend(frames[run_start:i])
49
+ run_start = None
50
+ # Add the non-call frame
51
+ result.append(frinfo)
52
+
53
+ # Handle final run at end
54
+ if run_start is not None:
55
+ run_length = len(frames) - run_start
56
+ if run_length >= min_run_length:
57
+ result.append(frames[run_start])
58
+ result.append(...)
59
+ result.append(frames[-1])
60
+ else:
61
+ result.extend(frames[run_start:])
62
+
63
+ return result
64
+
65
+
66
+ def html_traceback(
67
+ exc=None,
68
+ chain=None,
69
+ *,
70
+ include_js_css=True,
71
+ local_urls=False,
72
+ replace_previous=False,
73
+ **extract_args,
74
+ ):
75
+ chain = chain or extract_chain(exc=exc, **extract_args)[-3:]
76
+ # Chain is oldest-first from extract_chain
77
+ with E.div(
78
+ class_="tracerite", data_replace_previous="1" if replace_previous else None
79
+ ) as doc:
80
+ if include_js_css:
81
+ doc._style(style)
82
+ for i, e in enumerate(chain):
83
+ # Get chaining suffix for exception header
84
+ chain_suffix = ""
85
+ if i > 0:
86
+ chain_suffix = chainmsg.get(e.get("from", "none"), "")
87
+ _exception(doc, e, local_urls=local_urls, chain_suffix=chain_suffix)
88
+
89
+ if include_js_css:
90
+ # Build scrollto calls
91
+ scrollto_calls = []
92
+ for e in reversed(chain):
93
+ for info in e["frames"]:
94
+ if info["relevance"] != "call":
95
+ scrollto_calls.append(f"tracerite_scrollto('{info['id']}')")
96
+ break
97
+ doc._script(javascript + "\n" + "\n".join(scrollto_calls))
98
+ return doc
99
+
100
+
101
+ def _exception(doc, info, *, local_urls=False, chain_suffix=""):
102
+ """Format single exception message and traceback"""
103
+ summary, message = info["summary"], info["message"]
104
+ doc.h3(E.span(f"{info['type']}{chain_suffix}:", class_="exctype")(f" {summary}"))
105
+ if summary != message:
106
+ if message.startswith(summary):
107
+ message = message[len(summary) :]
108
+ doc.pre(message, class_="excmessage")
109
+ # Traceback available?
110
+ frames = info["frames"]
111
+ if not frames:
112
+ return
113
+ # Format call chain, suppress middle of consecutive call runs if too long
114
+ limitedframes = _collapse_call_runs(frames, min_run_length=10)
115
+ # Collect symbols for floating indicators
116
+ frame_symbols = []
117
+ for frinfo in limitedframes:
118
+ if frinfo is not ... and frinfo["relevance"] != "call":
119
+ frame_symbols.append((frinfo["id"], symbols.get(frinfo["relevance"], "")))
120
+ with doc.div(class_="traceback-wrapper"):
121
+ # Floating overlay symbols (one per frame, positioned dynamically)
122
+ for fid, sym in frame_symbols:
123
+ with doc.span(class_="floating-symbol", data_frame=fid):
124
+ doc.span("◂", class_="arrow arrow-left")
125
+ doc.span(sym, class_="sym")
126
+ doc.span("▸", class_="arrow arrow-right")
127
+ with doc.div(class_="traceback-frames"):
128
+ # Render frames
129
+ for frinfo in limitedframes:
130
+ if frinfo is ...:
131
+ with doc.div(class_="traceback-details traceback-ellipsis"):
132
+ doc.p("...")
133
+ continue
134
+ attrs = {
135
+ "class_": "traceback-details",
136
+ "data_function": frinfo["function"],
137
+ "id": frinfo["id"],
138
+ }
139
+ with doc.div(**attrs):
140
+ _frame_label(doc, frinfo, local_urls=local_urls)
141
+ with doc.div(class_="frame-content"):
142
+ traceback_detail(doc, info, frinfo)
143
+ variable_inspector(doc, frinfo["variables"])
144
+
145
+
146
+ def _frame_label(doc, frinfo, *, local_urls=False):
147
+ """Render sticky label for a code frame with optional editor links."""
148
+ # Build title text: full path with line number
149
+ if frinfo["filename"]:
150
+ lineno = frinfo["range"].lfirst if frinfo["range"] else "?"
151
+ title = f"{frinfo['filename']}:{lineno}"
152
+ else:
153
+ title = frinfo["location"] or frinfo["function"] or ""
154
+
155
+ with doc.div(class_="frame-label", title=title):
156
+ doc.strong(frinfo["location"])
157
+ if frinfo["function"]:
158
+ doc(" ")
159
+ doc.span(frinfo["function"], class_="frame-function")
160
+ # Editor links if available
161
+ urls = frinfo.get("urls", {})
162
+ if local_urls and urls:
163
+ for name, href in urls.items():
164
+ doc.a(name, href=href, class_="frame-link")
165
+
166
+
167
+ def traceback_detail(doc, info, frinfo):
168
+ # Code printout
169
+ fragments = frinfo.get("fragments", [])
170
+ if not fragments:
171
+ doc.p("Source code not available")
172
+ if frinfo is info["frames"][-1]:
173
+ doc(" but ").strong(info["type"])(" was raised from here")
174
+ else:
175
+ with doc.pre, doc.code:
176
+ start = frinfo["linenostart"]
177
+ for line_info in fragments:
178
+ line_num = line_info["line"]
179
+ abs_line = start + line_num - 1
180
+ fragments = line_info["fragments"]
181
+
182
+ # Prepare tooltip attributes for tooltip span on final line
183
+ tooltip_attrs = {}
184
+ if frinfo["range"] and abs_line == frinfo["range"].lfinal:
185
+ relevance = frinfo["relevance"]
186
+ symbol = symbols.get(relevance, frinfo["relevance"])
187
+ try:
188
+ text = tooltips[relevance].format(**info, **frinfo)
189
+ # Replace newlines with spaces for HTML attribute
190
+ text = text.replace("\n", " ")
191
+ except Exception:
192
+ text = repr(relevance)
193
+ tooltip_attrs = {
194
+ "class": "tracerite-tooltip",
195
+ "data-symbol": symbol,
196
+ "data-tooltip": text,
197
+ }
198
+
199
+ # Render content fragments inside the codeline span
200
+ with doc.span(class_="codeline", data_lineno=abs_line):
201
+ # Find the first non-trailing fragment to start the tooltip span
202
+ non_trailing_fragments = []
203
+ trailing_fragment = None
204
+ for fragment in fragments:
205
+ if "trailing" in fragment:
206
+ trailing_fragment = fragment
207
+ break
208
+ non_trailing_fragments.append(fragment)
209
+
210
+ # Render leading whitespace/indentation first (outside tooltip span)
211
+ if non_trailing_fragments:
212
+ first_fragment = non_trailing_fragments[0]
213
+ code = first_fragment["code"]
214
+ leading_whitespace = code[: len(code) - len(code.lstrip())]
215
+ if leading_whitespace:
216
+ doc(leading_whitespace)
217
+ # Create modified first fragment without leading whitespace
218
+ first_fragment_modified = {
219
+ **first_fragment,
220
+ "code": code.lstrip(),
221
+ }
222
+ non_trailing_fragments[0] = first_fragment_modified
223
+
224
+ # Render the tooltip span around the actual code content
225
+ if tooltip_attrs and non_trailing_fragments:
226
+ with doc.span(
227
+ class_="tracerite-tooltip",
228
+ data_tooltip=tooltip_attrs["data-tooltip"],
229
+ ):
230
+ for fragment in non_trailing_fragments:
231
+ _render_fragment(doc, fragment)
232
+ # Add separate symbol and tooltip text elements
233
+ doc.span(
234
+ class_="tracerite-symbol",
235
+ data_symbol=tooltip_attrs["data-symbol"],
236
+ )
237
+ doc.span(
238
+ class_="tracerite-tooltip-text",
239
+ data_tooltip=tooltip_attrs["data-tooltip"],
240
+ )
241
+ else:
242
+ for fragment in non_trailing_fragments:
243
+ _render_fragment(doc, fragment)
244
+
245
+ # Set fragment for trailing handling
246
+ fragment = trailing_fragment
247
+ # Render trailing fragment outside the span
248
+ if fragment:
249
+ _render_fragment(doc, fragment)
250
+
251
+
252
+ def _render_fragment(doc, fragment):
253
+ """Render a single fragment with appropriate styling."""
254
+ code = fragment["code"]
255
+
256
+ mark = fragment.get("mark")
257
+ em = fragment.get("em")
258
+
259
+ # Render opening tags for "mark" and "em" if applicable
260
+ if mark in ["solo", "beg"]:
261
+ doc(HTML("<mark>"))
262
+ if em in ["solo", "beg"]:
263
+ doc(HTML("<em>"))
264
+
265
+ # Render the code
266
+ doc(code)
267
+
268
+ # Render closing tags for "mark" and "em" if applicable
269
+ if em in ["fin", "solo"]:
270
+ doc(HTML("</em>"))
271
+ if mark in ["fin", "solo"]:
272
+ doc(HTML("</mark>"))
273
+
274
+
275
+ def variable_inspector(doc, variables):
276
+ if not variables:
277
+ return
278
+ with doc.table(class_="inspector key-value"):
279
+ for var_info in variables:
280
+ # Handle both old tuple format and new VarInfo namedtuple
281
+ if hasattr(var_info, "name"):
282
+ n, t, v, fmt = (
283
+ var_info.name,
284
+ var_info.typename,
285
+ var_info.value,
286
+ var_info.format_hint,
287
+ )
288
+ else:
289
+ # Backwards compatibility with old tuple format
290
+ n, t, v = var_info
291
+ fmt = "inline"
292
+
293
+ doc.tr.td.span(n, class_="var")
294
+ if t:
295
+ doc(": ").span(f"{t}\u00a0=\u00a0", class_="type")
296
+ else:
297
+ doc("\u00a0").span("=\u00a0", class_="type") # No type printed
298
+ doc.td(class_=f"val val-{fmt}")
299
+ if isinstance(v, str):
300
+ if fmt == "block":
301
+ # For block format, use <pre> tag for proper formatting
302
+ doc.pre(v)
303
+ else:
304
+ doc(v)
305
+ elif isinstance(v, dict) and v.get("type") == "keyvalue":
306
+ _format_keyvalue(doc, v["rows"])
307
+ elif isinstance(v, dict) and v.get("type") == "array":
308
+ with doc.div(class_="array-with-scale"):
309
+ _format_matrix(doc, v["rows"])
310
+ if v.get("suffix"):
311
+ doc.span(v["suffix"], class_="scale-suffix")
312
+ else:
313
+ _format_matrix(doc, v)
314
+
315
+
316
+ def _format_keyvalue(doc, rows):
317
+ """Format key-value pairs (dicts, dataclasses) as a definition list."""
318
+ with doc.dl(class_="keyvalue-dl"):
319
+ for key, val in rows:
320
+ doc.dt(key)
321
+ doc.dd(val)
322
+
323
+
324
+ def _format_matrix(doc, v):
325
+ skipcol = skiprow = False
326
+ with doc.table:
327
+ for row in v:
328
+ if row[0] is None:
329
+ skiprow = True
330
+ continue
331
+ doc.tr()
332
+ if skiprow:
333
+ skiprow = False
334
+ doc(class_="skippedabove")
335
+ for e in row:
336
+ if e is None:
337
+ skipcol = True
338
+ continue
339
+ if skipcol:
340
+ skipcol = False
341
+ doc.td(e, class_="skippedleft")
342
+ else:
343
+ doc.td(e)