chore: rename project coven → hack-house ⛧

Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"),
README, CLI strings, and branch (coven → hack-house). Gitea repo renamed
cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest +
golden-vector test still green, binary is now `hack-house`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -0,0 +1,19 @@
from .fastapi import patch_fastapi
from .html import html_traceback
from .inspector import extract_variables, prettyvalue
from .notebook import load_ipython_extension, unload_ipython_extension
from .trace import extract_chain
from .tty import load, tty_traceback, unload
__all__ = [
"load",
"unload",
"tty_traceback",
"html_traceback",
"extract_chain",
"prettyvalue",
"extract_variables",
"load_ipython_extension",
"unload_ipython_extension",
"patch_fastapi",
]
@@ -0,0 +1,703 @@
"""AST-based analysis for exception chain try-except block matching.
This module provides utilities to build a chronological chain of events
during a multi-exception chain by analyzing the AST to identify which
try block contains the code that raised the inner exception, relative
to the except block where the outer exception was raised.
Key insight about Python exception frames:
- Inner exception frames start from the try block (not app entry point)
So the first frame is always in the try body where the exception occurred.
- Outer exception frames start from app entry point and traverse through
the call stack. We search these to find a frame in an except handler
that corresponds to the inner's try block.
ExceptionGroups (Python 3.11+) introduce parallel timelines:
- An ExceptionGroup contains multiple subexceptions that occurred concurrently
- Each subexception has its own traceback chain
- These parallel timelines are represented as branches in the chronological view
- The ExceptionGroup's own traceback provides the surrounding context
"""
from __future__ import annotations
import ast
__all__ = [
"TryExceptBlock",
"ChainLink",
"parse_source_for_try_except",
"parse_source_string_for_try_except",
"find_try_block_for_except_line",
"find_matching_try_for_inner_exception",
"analyze_exception_chain_links",
"enrich_chain_with_links",
"build_chronological_frames",
]
import linecache
from dataclasses import dataclass
from .logging import logger
@dataclass
class TryExceptBlock:
"""Represents a try-except block with its line ranges."""
try_start: int # First line of try keyword
try_end: int # Last line of try body (before except/else/finally)
except_start: int | None # First line of except handlers
except_end: int | None # Last line of except handlers
finally_start: int | None = None
finally_end: int | None = None
def contains_in_try(self, lineno: int) -> bool:
"""Check if a line number is within the try body."""
return self.try_start <= lineno <= self.try_end
def contains_in_except(self, lineno: int) -> bool:
"""Check if a line number is within an except handler."""
if self.except_start is None or self.except_end is None:
return False
return self.except_start <= lineno <= self.except_end
@dataclass
class ChainLink:
"""Represents a link between two exceptions in a chain.
Attributes:
outer_frame_idx: Index of the frame in the outer exception that's in the except block
try_block: The TryExceptBlock that links the inner and outer exceptions
matched: Whether we successfully matched the try-except relationship
"""
outer_frame_idx: int
try_block: TryExceptBlock | None
matched: bool
class TryExceptVisitor(ast.NodeVisitor):
"""AST visitor that collects all try-except blocks with their line ranges."""
def __init__(self):
self.try_except_blocks: list[TryExceptBlock] = []
def visit_Try(self, node: ast.Try):
"""Visit a Try node and record its structure."""
# Find the end of the try body
try_body_end = self._get_last_line(node.body)
# Process except handlers
except_start = None
except_end = None
if node.handlers:
except_start = node.handlers[0].lineno
except_end = self._get_last_line(list(node.handlers))
# Process finally block
finally_start = None
finally_end = None
if node.finalbody:
finally_start = node.finalbody[0].lineno
finally_end = self._get_last_line(node.finalbody)
if except_start is not None:
block = TryExceptBlock(
try_start=node.lineno,
try_end=try_body_end,
except_start=except_start,
except_end=except_end,
finally_start=finally_start,
finally_end=finally_end,
)
self.try_except_blocks.append(block)
# Continue visiting nested structures
self.generic_visit(node)
def _get_last_line(self, nodes) -> int:
"""Get the last line number from a list of AST nodes."""
last_line = 0
for node in nodes:
last_line = max(last_line, getattr(node, "end_lineno", node.lineno))
return last_line
def parse_source_for_try_except(
filename: str, function_name: str | None = None
) -> list[TryExceptBlock]:
"""Parse source file and extract try-except blocks.
Args:
filename: Path to the source file
function_name: Optional function name to limit scope
Returns:
List of TryExceptBlock objects found in the source
"""
try:
lines = linecache.getlines(filename)
if not lines:
return []
source = "".join(lines)
tree = ast.parse(source, filename=filename)
visitor = TryExceptVisitor()
visitor.visit(tree)
return visitor.try_except_blocks
except (SyntaxError, OSError, ValueError) as e:
logger.debug(f"Failed to parse {filename} for try-except analysis: {e}")
return []
def parse_source_string_for_try_except(
source: str, start_line: int = 1
) -> list[TryExceptBlock]:
"""Parse source string and extract try-except blocks.
Args:
source: The source code as a string
start_line: The line number where this source starts (for offset adjustment)
Returns:
List of TryExceptBlock objects found in the source
"""
try:
if not source:
return []
tree = ast.parse(source)
visitor = TryExceptVisitor()
visitor.visit(tree)
# Adjust line numbers if source doesn't start at line 1
if start_line != 1:
offset = start_line - 1
adjusted_blocks = []
for block in visitor.try_except_blocks:
adjusted_blocks.append(
TryExceptBlock(
try_start=block.try_start + offset,
try_end=block.try_end + offset,
except_start=block.except_start + offset
if block.except_start
else None,
except_end=block.except_end + offset
if block.except_end
else None,
finally_start=block.finally_start + offset
if block.finally_start
else None,
finally_end=block.finally_end + offset
if block.finally_end
else None,
)
)
return adjusted_blocks
return visitor.try_except_blocks
except (SyntaxError, ValueError) as e:
logger.debug(f"Failed to parse source string for try-except analysis: {e}")
return []
def find_try_block_for_except_line(
blocks: list[TryExceptBlock], except_lineno: int
) -> TryExceptBlock | None:
"""Find the try-except block that contains the given line in its except handler.
Args:
blocks: List of TryExceptBlock objects
except_lineno: Line number that should be within an except handler
Returns:
The TryExceptBlock if found, None otherwise
"""
# Sort by specificity - prefer innermost blocks (those with higher try_start)
matching_blocks = [b for b in blocks if b.contains_in_except(except_lineno)]
if not matching_blocks:
return None
# Return the most specific (innermost) block
return max(matching_blocks, key=lambda b: b.try_start)
def find_matching_try_for_inner_exception(
blocks: list[TryExceptBlock], inner_first_lineno: int, outer_except_lineno: int
) -> TryExceptBlock | None:
"""Find the try block that contains the inner exception's first frame
and whose except block contains the outer exception's frame.
This is the key function for building the chronological chain:
- inner_first_lineno: The line in the inner exception's first frame (in the try body)
- outer_except_lineno: A line from the outer exception that's in an except block
Args:
blocks: List of TryExceptBlock objects
inner_first_lineno: First line of the inner exception's traceback
outer_except_lineno: Line from outer exception that should be in except block
Returns:
The TryExceptBlock that links both exceptions, or None if no match
"""
for block in blocks:
# The outer exception line must be in the except handler
if not block.contains_in_except(outer_except_lineno):
continue
# The inner exception's first line must be in the try body
if block.contains_in_try(inner_first_lineno):
return block
return None
def analyze_exception_chain_links(chain: list[dict]) -> list[ChainLink | None]:
"""Analyze an exception chain to find try-except relationships.
For each pair of consecutive exceptions in the chain (inner, outer),
find the try-except block that links them.
Args:
chain: List of exception info dicts from extract_chain (oldest first)
Returns:
List of ChainLink objects (one per exception, first is always None
since the first exception has no prior exception to link to)
"""
if len(chain) <= 1:
return [None] * len(chain)
links: list[ChainLink | None] = [None] # First exception has no link
for i in range(1, len(chain)):
inner_exc = chain[i - 1]
outer_exc = chain[i]
link = _find_chain_link(inner_exc, outer_exc)
links.append(link)
return links
def _get_frame_lineno(frame: dict) -> int | None:
"""Extract the line number from a frame dict.
Tries in order:
1. range[0] (lfirst) - most precise, from Python 3.11+ co_positions
2. lineno - actual traceback line number (always available)
3. linenostart - first line of displayed source (fallback)
"""
frame_range = frame.get("range")
if frame_range:
return frame_range[0] # lfirst from Range namedtuple
# Fallback to lineno (actual error line from traceback)
if frame.get("lineno"):
return frame.get("lineno")
# Final fallback to linenostart (first line of displayed source)
return frame.get("linenostart")
def _find_chain_link(inner_exc: dict, outer_exc: dict) -> ChainLink | None:
"""Find the try-except link between two consecutive exceptions.
The inner exception's frames start from the try block (not app entry point),
so its first frame is always the one in the try block we're looking for.
The outer exception's frames start from app entry point and traverse through
the call stack. We need to search these frames to find one that's within
an except handler that corresponds to the inner's try block.
Args:
inner_exc: The earlier/inner exception info dict
outer_exc: The later/outer exception info dict
Returns:
ChainLink if a relationship is found, None otherwise
"""
inner_frames = inner_exc.get("frames", [])
outer_frames = outer_exc.get("frames", [])
if not inner_frames or not outer_frames:
return None
# Inner exception: first frame is always in the try block
# (Python captures frames starting from the try block, not app entry)
inner_first_frame = inner_frames[0]
inner_first_lineno = _get_frame_lineno(inner_first_frame)
if inner_first_lineno is None:
return None
# Get try-except blocks from the frame's full source (works with any source)
# This avoids reading from files, using the source Python already has
inner_full_source = inner_first_frame.get("full_source")
inner_source_start = inner_first_frame.get("full_source_start", 1)
if inner_full_source:
try_except_blocks = parse_source_string_for_try_except(
inner_full_source, inner_source_start
)
else:
# Fallback to file-based parsing if full_source not available
inner_filename = inner_first_frame.get(
"original_filename"
) or inner_first_frame.get("filename")
if not inner_filename:
return None
try_except_blocks = parse_source_for_try_except(inner_filename)
if not try_except_blocks:
return None
# Outer exception: search through ALL frames to find one in an except block
# The frame list starts from app entry point and may visit the same function
# multiple times, or have additional frames after the except block
for frame_idx, frame in enumerate(outer_frames):
frame_lineno = _get_frame_lineno(frame)
if frame_lineno is None:
continue
# Try to find a try-except block where:
# - inner_first_lineno is in the try body
# - frame_lineno is in the except handler
matching_block = find_matching_try_for_inner_exception(
try_except_blocks, inner_first_lineno, frame_lineno
)
if matching_block:
return ChainLink(
outer_frame_idx=frame_idx,
try_block=matching_block,
matched=True,
)
return None
def enrich_chain_with_links(chain: list[dict]) -> list[dict]:
"""Enrich exception chain with try-except link information.
Adds a 'chain_link' key to each exception dict with information
about how it relates to the previous exception in the chain.
Args:
chain: List of exception info dicts from extract_chain (oldest first)
Returns:
The same chain list, with 'chain_link' added to each dict
"""
links = analyze_exception_chain_links(chain)
for _i, (exc, link) in enumerate(zip(chain, links)):
if link and link.matched:
exc["chain_link"] = {
"outer_frame_idx": link.outer_frame_idx,
"try_start": link.try_block.try_start,
"try_end": link.try_block.try_end,
"except_start": link.try_block.except_start,
"except_end": link.try_block.except_end,
}
else:
exc["chain_link"] = None
return chain
def build_chronological_frames(chain: list[dict]) -> list[dict]:
"""Build a chronological list of frames showing the actual sequence of events.
This creates a flat list of frames in the order they were executed, with
exception information embedded in the frames. The result shows:
1. Frames from entry point leading to the try block
2. The inner exception's frames (from try block to error)
3. The except handler frame (promoted to relevance="except")
4. Any frames after except leading to the next exception
5. ...and so on for nested chains
For ExceptionGroups:
- Subexceptions form parallel timelines that occurred concurrently
- These are represented as a special "parallel" frame containing multiple branches
- Each branch is itself a list of chronological frames
- The parallel block is inserted before the ExceptionGroup's error frame
The key insight is that the LAST exception in the chain has the full call
stack from entry point. Inner exceptions only have partial stacks starting
from where they were raised. We use the outer exception's frames as the
"backbone" and insert inner exception frames at the appropriate positions.
Hidden frames (marked with hidden=True, e.g., from __tracebackhide__) are
kept during chain analysis to enable proper try-except matching, then
filtered out at the end.
Args:
chain: List of exception info dicts from extract_chain (oldest first),
should already be enriched with chain_link info
Returns:
List of frame dicts in chronological order. Each frame may have:
- "exception": dict with exception info (type, message, from) if this
frame is where an exception was raised
- "relevance": may be promoted to "except" for except handler frames
- "parallel": list of parallel branches (for ExceptionGroup subexceptions)
"""
if not chain:
return []
# First, ensure chain has link info
links = analyze_exception_chain_links(chain)
# Build chronological frames by working backwards from the last exception
# The last exception has the full call stack; inner exceptions have partial stacks
chronological: list[dict] = []
# Process from the last (outermost) exception backwards
# This lets us use the outer exception's frames as the backbone
for exc_idx in range(len(chain) - 1, -1, -1):
exc = chain[exc_idx]
frames = exc.get("frames", [])
if not frames:
continue
# Check if this exception has a link to an inner exception
# (i.e., is there a next exception in the chain that links to this one?)
links[exc_idx + 1] if exc_idx + 1 < len(chain) else None
if exc_idx == len(chain) - 1:
# This is the outermost exception - use its frames as the backbone
# But we need to insert inner exception frames at the right positions
_build_backbone_frames(chronological, exc, exc_idx, frames, links, chain)
# Inner exceptions are handled by _build_backbone_frames inserting them
# Filter out hidden frames (kept for chain analysis, not for display)
chronological = _filter_hidden_frames(chronological)
# Apply suppression for BaseExceptions (KeyboardInterrupt, SystemExit, etc.)
# These should only show frames up to the last user code frame ("bug frame"),
# suppressing library internals that came after
chronological = _apply_base_exception_suppression(chronological, chain)
return chronological
def _filter_hidden_frames(chronological: list[dict]) -> list[dict]:
"""Filter out hidden frames, handling parallel branches recursively."""
result = []
for frame in chronological:
if frame.get("hidden"):
continue
# Recursively filter parallel branches
if "parallel" in frame:
filtered_branches = []
for branch in frame["parallel"]:
filtered_branch = _filter_hidden_frames(branch)
if filtered_branch:
filtered_branches.append(filtered_branch)
if filtered_branches:
frame = {**frame, "parallel": filtered_branches}
result.append(frame)
else:
result.append(frame)
return result
def _apply_base_exception_suppression(
chronological: list[dict], chain: list[dict]
) -> list[dict]:
"""Suppress library frames after the last user code frame for BaseExceptions.
For BaseExceptions (KeyboardInterrupt, SystemExit, etc.), we don't want to
show library internals - only show frames up to the last user code frame
(the "bug frame"). The exception info is transferred to the last shown frame.
Args:
chronological: The full list of chronological frames
chain: The exception chain (to check suppress_inner flag)
Returns:
Filtered list of frames with appropriate adjustments
"""
if not chronological or not chain:
return chronological
# Check if any exception in the chain has suppress_inner=True
# (This is set for BaseExceptions like KeyboardInterrupt, SystemExit)
has_suppress = any(exc.get("suppress_inner") for exc in chain)
if not has_suppress:
return chronological
# Find the last "bug frame" (last user code frame before library code)
# This is marked by relevance="warning" during extraction
last_bug_frame_idx = None
for idx, frame in enumerate(chronological):
if frame.get("relevance") == "warning":
last_bug_frame_idx = idx
if last_bug_frame_idx is None:
# No bug frame found, return as-is
return chronological
# Suppress all frames after the bug frame
result = chronological[: last_bug_frame_idx + 1]
# Transfer exception info and parallel branches from suppressed frames to the last shown frame
if result: # pragma: no cover
# Check if any suppressed frame had an exception or parallel branches attached
suppressed_exception = None
suppressed_parallel = None
for suppressed in chronological[last_bug_frame_idx + 1 :]:
if suppressed.get("exception") and not suppressed_exception:
suppressed_exception = suppressed["exception"]
if suppressed.get("parallel") and not suppressed_parallel:
suppressed_parallel = suppressed["parallel"]
# If we're suppressing frames with an exception, transfer it to the last shown frame
if suppressed_exception and not result[-1].get("exception"):
result[-1] = {**result[-1], "exception": suppressed_exception}
# Transfer parallel branches (subexceptions) to the last shown frame
if suppressed_parallel and not result[-1].get("parallel"):
result[-1] = {**result[-1], "parallel": suppressed_parallel}
# Change relevance to "stop" to indicate suppression point
# (unless it already has an exception, which sets its own relevance)
if result[-1].get("relevance") in ("call", "warning"): # pragma: no cover
result[-1] = {**result[-1], "relevance": "stop"}
return result
def _build_backbone_frames(
chronological: list[dict],
exc: dict,
exc_idx: int,
frames: list[dict],
links: list,
chain: list[dict],
) -> None:
"""Build chronological frames using this exception's frames as backbone.
Recursively inserts inner exception frames at the appropriate positions.
Also handles ExceptionGroup subexceptions as parallel branches.
"""
# Find if there's an inner exception that links to one of our frames
inner_exc_idx = exc_idx - 1
inner_link = links[exc_idx] if exc_idx > 0 else None
# If we have an inner exception with a matched link, we need to:
# 1. Output frames from start up to (but not including) the except handler
# 2. Insert the inner exception's frames
# 3. Output the except handler frame and any frames after it
if inner_link and inner_link.matched and inner_exc_idx >= 0:
except_frame_idx = inner_link.outer_frame_idx
inner_exc = chain[inner_exc_idx]
inner_frames = inner_exc.get("frames", [])
# Output frames before the except handler (these are the call stack leading to try)
for frame_idx in range(except_frame_idx):
frame = frames[frame_idx]
chrono_frame = _copy_frame(frame)
chronological.append(chrono_frame)
# Recursively handle the inner exception
# Always recurse to handle even more inner exceptions (best effort)
_build_backbone_frames(
chronological, inner_exc, inner_exc_idx, inner_frames, links, chain
)
# Now output the except handler frame and frames after it
for frame_idx in range(except_frame_idx, len(frames)):
frame = frames[frame_idx]
chrono_frame = _copy_frame(frame)
is_except_entry = frame_idx == except_frame_idx
is_last = frame_idx == len(frames) - 1
if is_except_entry:
# Promote relevance to "except" if it was just a "call" or "warning" frame
if chrono_frame.get("relevance") in ("call", "warning"):
chrono_frame["relevance"] = "except"
chrono_frame["function_suffix"] = "⚡except"
if is_last:
chrono_frame["exception"] = {
"type": exc.get("type"),
"message": exc.get("message"),
"summary": exc.get("summary"),
"from": exc.get("from"),
"exc_idx": exc_idx,
}
# Handle subexceptions for this ExceptionGroup
_add_subexceptions_to_frame(chrono_frame, exc)
chronological.append(chrono_frame)
else:
# No matched inner exception link - but still include inner exceptions (best effort)
# First, recursively process any inner exceptions
if exc_idx > 0:
inner_exc = chain[exc_idx - 1]
inner_frames = inner_exc.get("frames", [])
if inner_frames:
_build_backbone_frames(
chronological,
inner_exc,
exc_idx - 1,
inner_frames,
links,
chain,
)
# Then output all frames for this exception
for frame_idx, frame in enumerate(frames):
chrono_frame = _copy_frame(frame)
is_last = frame_idx == len(frames) - 1
if is_last:
chrono_frame["exception"] = {
"type": exc.get("type"),
"message": exc.get("message"),
"summary": exc.get("summary"),
"from": exc.get("from"),
"exc_idx": exc_idx,
}
# Handle subexceptions for this ExceptionGroup
_add_subexceptions_to_frame(chrono_frame, exc)
chronological.append(chrono_frame)
def _add_subexceptions_to_frame(frame: dict, exc: dict) -> None:
"""Add subexceptions from an ExceptionGroup as parallel branches.
If the exception has subexceptions (from an ExceptionGroup), each one
is processed into its own chronological frame list and added as a
parallel branch to the frame.
Args:
frame: The frame dict to add parallel branches to
exc: The exception dict that may contain subexceptions
"""
subexceptions = exc.get("subexceptions")
if not subexceptions:
return
parallel_branches = []
for sub_chain in subexceptions:
# Build chronological frames for this subexception chain
sub_chrono = build_chronological_frames(sub_chain)
if sub_chrono:
parallel_branches.append(sub_chrono)
if parallel_branches:
frame["parallel"] = parallel_branches
def _copy_frame(frame: dict) -> dict:
"""Create a shallow copy of a frame dict."""
return {**frame}
@@ -0,0 +1,57 @@
"""TraceRite extension for FastAPI/Starlette applications."""
from __future__ import annotations
from typing import Any
from .html import html_traceback
from .logging import logger
from .tty import load as load_tty
_original_debug_response = None
def patch_fastapi(*, tty: bool = True) -> None:
"""
Load TraceRite extension for FastAPI by patching ServerErrorMiddleware.
This patches Starlette's ServerErrorMiddleware.debug_response to return
TraceRite HTML tracebacks instead of the default debug HTML when running
in debug mode and the client accepts HTML.
Additionally, we load the TTY formatting for console output.
"""
global _original_debug_response
if _original_debug_response is not None:
return # Already loaded
try:
import fastapi # ty: ignore
from starlette.middleware.errors import ServerErrorMiddleware # ty: ignore
from starlette.responses import HTMLResponse # ty: ignore
except ImportError:
logger.info("TraceRite FastAPI cannot load: FastAPI/Starlette not found")
return
_original_debug_response = ServerErrorMiddleware.debug_response
def tracerite_debug_response(self, request, exc) -> Any:
"""Return TraceRite HTML traceback instead of Starlette's debug response."""
accept = request.headers.get("accept", "")
if "text/html" not in accept:
return _original_debug_response(self, request, exc) # type: ignore
try:
html = str(html_traceback(exc=exc, include_js_css=True))
return HTMLResponse(
"<!DOCTYPE html><title>FastAPI TraceRite</title>" + html,
status_code=500,
)
except Exception as e:
logger.error(f"Failed to generate TraceRite response: {e}")
return _original_debug_response(self, request, exc) # type: ignore
ServerErrorMiddleware.debug_response = tracerite_debug_response # type: ignore
fastapi.routing.__tracebackhide__ = "until" # type: ignore
if tty:
load_tty()
logger.info("TraceRite FastAPI extension loaded")
@@ -0,0 +1,526 @@
from __future__ import annotations
from importlib.resources import files
from typing import Any, cast
from html5tagger import HTML, E # type: ignore[import]
from .chain_analysis import build_chronological_frames
from .trace import build_chain_header, chainmsg, extract_chain, symbols, symdesc
style = files(cast(str, __package__)).joinpath("style.css").read_text(encoding="UTF-8")
javascript = (
files(cast(str, __package__)).joinpath("script.js").read_text(encoding="UTF-8")
)
detail_show = "{display: inherit}"
def _collapse_call_runs(
frames: list[dict[str, Any]], min_run_length: int = 10
) -> list[Any]:
"""Collapse consecutive runs of 'call' frames, keeping first and last of each run.
Only collapses runs of frames with relevance='call'. Non-call frames
(error, warning, stop) are never collapsed.
"""
if not frames:
return frames
result = []
run_start = None
for i, frinfo in enumerate(frames):
if frinfo["relevance"] == "call":
if run_start is None:
run_start = i
else:
# End of a call run - process it
if run_start is not None:
run_length = i - run_start
if run_length >= min_run_length:
# Keep first and last of the run, add ellipsis
result.append(frames[run_start])
result.append(...)
result.append(frames[i - 1])
else:
# Run too short, keep all
result.extend(frames[run_start:i])
run_start = None
# Add the non-call frame
result.append(frinfo)
# Handle final run at end
if run_start is not None:
run_length = len(frames) - run_start
if run_length >= min_run_length:
result.append(frames[run_start])
result.append(...)
result.append(frames[-1])
else:
result.extend(frames[run_start:])
return result
def html_traceback(
exc: BaseException | None = None,
chain: list[dict[str, Any]] | None = None,
*,
msg: str | None = ..., # type: ignore[assignment]
include_js_css: bool = True,
local_urls: bool = False,
replace_previous: bool = False,
cleanup_mode: str = "replace",
autodark: bool = True,
**extract_args: Any,
) -> Any:
chain = chain or extract_chain(exc=exc, **extract_args)[-3:]
# Chain is oldest-first from extract_chain
classes = "tracerite autodark" if autodark else "tracerite"
with E.div(
class_=classes,
data_replace_previous="1" if replace_previous else None,
data_cleanup_mode=cleanup_mode if replace_previous else None,
) as doc:
if include_js_css:
doc._style(style)
# Add chain header (use default if msg is ..., skip if msg is None/empty)
if msg is ...:
msg = build_chain_header(chain) if chain else None
if msg:
doc.h2(msg)
_chronological_output(doc, chain, local_urls=local_urls)
if include_js_css:
doc._script(javascript)
return doc
def _chronological_output(
doc: Any,
chain: list[dict[str, Any]],
*,
local_urls: bool = False,
) -> None:
"""Output frames in chronological order with exception info after error frames."""
chrono_frames = build_chronological_frames(chain)
if not chrono_frames:
# No frames, but still show exception banners for any exceptions in chain
for exc in chain:
exc_info = {
"type": exc.get("type"),
"message": exc.get("message"),
"summary": exc.get("summary"),
"from": exc.get("from"),
}
_exception_banner(doc, exc_info)
return
_render_frame_list(doc, chrono_frames, local_urls=local_urls)
def _render_frame_list(
doc: Any,
frames: list[dict[str, Any]],
*,
local_urls: bool = False,
) -> None:
"""Render a list of chronological frames, handling parallel branches."""
# Collapse consecutive call runs
limited_frames = _collapse_call_runs(frames, min_run_length=10)
for frinfo in limited_frames:
if frinfo is ...:
doc.p("...", class_="traceback-ellipsis")
continue
relevance = frinfo["relevance"]
exc_info = frinfo.get("exception")
parallel_branches = frinfo.get("parallel")
attrs = {
"class_": f"traceback-details traceback-{relevance}",
"data_function": frinfo["function"],
"id": frinfo["id"],
}
with doc.div(**attrs):
# Hidden checkbox for CSS-only toggle (all frames are collapsible)
toggle_id = f"toggle-{frinfo['id']}"
# Non-call frames are open by default (checked)
if relevance == "call":
doc.input_(
type="checkbox", id=toggle_id, class_="frame-toggle-checkbox"
)
else:
doc.input_(
type="checkbox",
id=toggle_id,
class_="frame-toggle-checkbox",
checked="checked",
)
_frame_label(
doc,
frinfo,
local_urls=local_urls,
toggle_id=toggle_id,
)
with doc.span(class_="compact-call-line"):
_compact_code_line(doc, frinfo)
# Animated wrapper for expandable content
with doc.div(class_="expand-wrapper"), doc.div(class_="expand-content"):
_traceback_detail_chrono(doc, frinfo)
variable_inspector(doc, frinfo["variables"])
# Render parallel branches (subexceptions) before the exception banner
if parallel_branches:
_render_parallel_branches(doc, parallel_branches, local_urls=local_urls)
# Print exception info AFTER the error frame (and parallel branches)
if exc_info:
_exception_banner(doc, exc_info)
def _render_parallel_branches(
doc: Any,
branches: list[list[dict[str, Any]]],
*,
local_urls: bool = False,
) -> None:
"""Render parallel exception branches from an ExceptionGroup.
Each branch is rendered side by side.
"""
with doc.div(class_="parallel-branches"):
for branch in branches:
with doc.div(class_="parallel-branch"):
_render_frame_list(doc, branch, local_urls=local_urls)
def _exception_banner(doc: Any, exc_info: dict[str, Any]) -> None:
"""Output exception type and message as a banner after the error frame."""
exc_type = exc_info.get("type", "Exception")
summary = exc_info.get("summary", "")
message = exc_info.get("message", "")
from_type = exc_info.get("from", "none")
chain_suffix = chainmsg.get(from_type, "")
doc.h3(E.span(f"{exc_type}{chain_suffix}:", class_="exctype")(f" {summary}"))
# Show remaining lines in pre only if message has multiple lines
# Summary is always the first line, so we strip that from pre
parts = message.split("\n", 1)
if len(parts) > 1:
rest = parts[1].rstrip() # Only strip trailing whitespace, preserve leading
if rest:
doc.pre(rest, class_="excmessage")
def _compact_code_line(doc: Any, frinfo: dict[str, Any]) -> None:
"""Render compact one-liner showing all marked code regions.
Em parts (typically function arguments) longer than 20 chars are
collapsed to show only first and last char with ellipsis.
"""
fragments = frinfo["fragments"]
relevance = frinfo["relevance"]
symbol = symbols.get(relevance, symbols["call"])
# Use highlight styling (yellow bg, red caret) for error/stop frames
use_highlight = relevance in ("error", "stop")
if fragments:
# First pass: collect text and track em ranges
code_parts = [] # [(text, is_em), ...]
for line_info in fragments:
for fragment in line_info.get("fragments", []):
mark = fragment.get("mark")
if mark:
em = fragment.get("em")
text = fragment["code"]
is_em = em is not None
code_parts.append((text, is_em))
# Find the em span (from first em start to last em end)
em_indices = [i for i, (_, is_em) in enumerate(code_parts) if is_em]
# Collapse em parts longer than 20 chars
if em_indices:
first_em_idx = min(em_indices)
last_em_idx = max(em_indices)
em_text = "".join(
text
for i, (text, _) in enumerate(code_parts)
if first_em_idx <= i <= last_em_idx
)
if len(em_text) > 20:
# Collapse: keep first and last char (typically parentheses)
collapsed = em_text[0] + "" + em_text[-1]
# Rebuild code_parts with collapsed em
new_parts = []
for i, (text, _is_em) in enumerate(code_parts):
if i < first_em_idx:
new_parts.append((text, False))
elif i == first_em_idx:
new_parts.append((collapsed, True))
elif i > last_em_idx: # pragma: no cover
new_parts.append((text, False))
# Skip parts within em range (already collapsed)
code_parts = new_parts
with doc.code(class_="compact-code"):
if use_highlight:
doc(HTML("<mark>"))
for text, is_em in code_parts:
if is_em:
doc(HTML("<em>"))
doc(text)
if is_em:
doc(HTML("</em>"))
if use_highlight:
doc(HTML("</mark>"))
# Add space before symbol for error/stop frames
if use_highlight:
doc(" ")
doc.span(symbol, class_="compact-symbol")
def _traceback_detail_chrono(doc: Any, frinfo: dict[str, Any]) -> None:
"""Render frame detail in chronological mode."""
fragments = frinfo["fragments"]
relevance = frinfo["relevance"]
if not fragments:
# Show "(no source code)" with the symbol emoji like a code line would have
symbol = symbols.get(relevance, "")
doc.p(f"(no source code) {symbol}")
return
with doc.pre, doc.code:
start = frinfo["linenostart"]
for line_info in fragments:
line_num = line_info["line"]
abs_line = start + line_num - 1
line_fragments = line_info["fragments"]
# Prepare tooltip attributes for tooltip span on final line
tooltip_attrs = {}
frame_range = frinfo["range"]
if frame_range and abs_line == frame_range.lfinal:
relevance = frinfo["relevance"]
symbol = symbols.get(relevance, relevance)
text = symdesc.get(relevance, relevance)
tooltip_attrs = {
"class": "tracerite-tooltip",
"data-symbol": symbol,
"data-tooltip": text,
}
with doc.span(class_="codeline", data_lineno=abs_line):
non_trailing_fragments = []
trailing_fragment = None
for fragment in line_fragments:
if "trailing" in fragment:
trailing_fragment = fragment
break
non_trailing_fragments.append(fragment)
if non_trailing_fragments:
first_fragment = non_trailing_fragments[0]
code = first_fragment["code"]
leading_whitespace = code[: len(code) - len(code.lstrip())]
if leading_whitespace:
doc(leading_whitespace)
first_fragment_modified = {
**first_fragment,
"code": code.lstrip(),
}
non_trailing_fragments[0] = first_fragment_modified
if tooltip_attrs and non_trailing_fragments:
with doc.span(
class_="tracerite-tooltip",
data_tooltip=tooltip_attrs["data-tooltip"],
):
for fragment in non_trailing_fragments:
_render_fragment(doc, fragment)
doc.span(
class_="tracerite-symbol",
data_symbol=tooltip_attrs["data-symbol"],
)
doc.span(
class_="tracerite-tooltip-text",
data_tooltip=tooltip_attrs["data-tooltip"],
)
else:
for fragment in non_trailing_fragments:
_render_fragment(doc, fragment)
fragment = trailing_fragment
if fragment:
_render_fragment(doc, fragment)
def _frame_label(
doc: Any,
frinfo: dict[str, Any],
local_urls: bool = False,
toggle_id: str | None = None,
) -> None:
function_name = frinfo["function"]
function_suffix = frinfo.get("function_suffix", "")
if function_name:
function_display = f"{function_name}{function_suffix}"
elif function_suffix:
function_display = function_suffix
else:
function_display = None # No function to display
# Location comes first, with line number for non-notebook frames
# Notebook cells (In [N]) don't need line numbers displayed
notebook_cell = frinfo.get("notebook_cell", False)
if notebook_cell:
lineno = None
else:
# Use cursor_line for display (preferred error position)
cursor_line = frinfo.get("cursor_line") or frinfo.get("linenostart", 1)
lineno = E.span(
f":{cursor_line}",
class_="frame-lineno",
)
# Colon after function name, or after location if no function
colon = E.span(":", class_="frame-colon")
# Get VS Code URL if local_urls enabled
urls = frinfo.get("urls", {})
vscode_url = urls.get("VS Code") if local_urls else None
# Build location element - wrap in <a> if we have a VS Code URL
def render_location(with_colon: bool = False):
location_parts = (
(frinfo["location"], lineno) if lineno else (frinfo["location"],)
)
if with_colon:
location_parts = (*location_parts, colon)
if vscode_url:
doc.a(*location_parts, href=vscode_url, class_="frame-location")
else:
doc.span(*location_parts, class_="frame-location")
if toggle_id:
# Wrap both location and function in a label for click-to-toggle
with doc.label(for_=toggle_id, class_="frame-label-wrapper"):
if function_display:
render_location()
doc.span(function_display, colon, class_="frame-function")
else:
# No function: colon goes with location, empty span for grid column 2
render_location(with_colon=True)
doc.span(class_="frame-function")
else:
if function_display:
render_location()
doc.span(function_display, colon, class_="frame-function")
else:
# No function: colon goes with location, empty span for grid column 2
render_location(with_colon=True)
doc.span(class_="frame-function")
def _render_fragment(doc: Any, fragment: dict[str, Any]) -> None:
"""Render a single fragment with appropriate styling."""
code = fragment["code"]
mark = fragment.get("mark")
em = fragment.get("em")
# Render opening tags for "mark" and "em" if applicable
if mark in ["solo", "beg"]:
doc(HTML("<mark>"))
if em in ["solo", "beg"]:
doc(HTML("<em>"))
# Render the code
doc(code)
# Render closing tags for "mark" and "em" if applicable
if em in ["fin", "solo"]:
doc(HTML("</em>"))
if mark in ["fin", "solo"]:
doc(HTML("</mark>"))
def variable_inspector(doc: Any, variables: list[Any]) -> None:
if not variables:
return
with doc.dl(class_="inspector"):
for var_info in variables:
# Handle both old tuple format and new VarInfo namedtuple
if hasattr(var_info, "name"):
n, t, v, fmt = (
var_info.name,
var_info.typename,
var_info.value,
var_info.format_hint,
)
else:
# Backwards compatibility with old tuple format
n, t, v = var_info
fmt = "inline"
doc.dt.span(n, class_="var")
if t:
doc(": ").span(f"{t}\u00a0=\u00a0", class_="type")
else:
doc("\u00a0").span("=\u00a0", class_="type") # No type printed
doc.dd(class_=f"val val-{fmt}")
if isinstance(v, str):
if fmt == "block":
# For block format, use <pre> tag for proper formatting
doc.pre(v)
else:
doc(v)
elif isinstance(v, dict) and v.get("type") == "keyvalue":
_format_keyvalue(doc, v["rows"])
elif isinstance(v, dict) and v.get("type") == "array":
with doc.div(class_="array-with-scale"):
_format_matrix(doc, v["rows"])
if v.get("suffix"):
doc.span(v["suffix"], class_="scale-suffix")
else:
_format_matrix(doc, v)
def _format_keyvalue(doc: Any, rows: list[tuple[str, Any]]) -> None:
"""Format key-value pairs (dicts, dataclasses) as a definition list."""
with doc.dl(class_="keyvalue-dl"):
for key, val in rows:
doc.dt(key)
doc.dd(val)
def _format_matrix(doc: Any, v: Any) -> None:
skipcol = skiprow = False
with doc.table:
for row in v:
if row[0] is None:
skiprow = True
continue
doc.tr()
if skiprow:
skiprow = False
doc(class_="skippedabove")
for e in row:
if e is None:
skipcol = True
continue
if skipcol:
skipcol = False
doc.td(e, class_="skippedleft")
else:
doc.td(e)
@@ -0,0 +1,452 @@
from __future__ import annotations
import ast
import contextlib
import dataclasses
import math
import re
import types
from collections import namedtuple
from functools import reduce
from typing import Any, Callable
from .logging import logger
# Variable info with formatting metadata
VarInfo = namedtuple("VarInfo", ["name", "typename", "value", "format_hint"])
def _format_scalar(v: Any) -> str:
"""Format a single numeric value intelligently."""
# Integer types (including numpy integers) - display without decimals
if isinstance(v, int) or (hasattr(v, "dtype") and "int" in str(v.dtype)):
return str(int(v))
# Float types
if isinstance(v, float) or (hasattr(v, "dtype") and "float" in str(v.dtype)):
v = float(v)
if v != v: # NaN
return "NaN"
if abs(v) == float("inf"):
return "" if v > 0 else "-∞"
if v == 0:
return "0"
if v == int(v) and abs(v) < 1e15:
return str(int(v))
# Compact fixed-point: strip trailing zeros
return f"{v:.6f}".rstrip("0").rstrip(".")
return str(v)
# Superscript digits for formatting powers of 10
_SUPERSCRIPT_DIGITS = str.maketrans("-0123456789", "⁻⁰¹²³⁴⁵⁶⁷⁸⁹")
def _get_flat(arr: Any) -> list[Any]:
"""Get a flat/1D view of an array, supporting numpy and torch."""
# Try numpy-style .flat first
if hasattr(arr, "flat"):
return arr.flat
# PyTorch tensors use .flatten()
if hasattr(arr, "flatten"):
return arr.flatten()
# Fallback - just return the array itself
return arr
def _array_formatter(arr: Any) -> tuple[Callable[[Any], str], str]:
"""
Create an optimal formatter for displaying array values consistently.
For integers: display as integers without decimals.
For floats: determine scale from max(abs(values)), apply SI-style
scaling (×10⁶, ×10⁻³, etc.), and display with consistent fixed precision.
Returns (formatter_func, scale_suffix) where scale_suffix may be empty.
"""
try:
dtype_str = str(arr.dtype)
except AttributeError:
dtype_str = ""
# Integer arrays - display as integers, no scaling
if "int" in dtype_str or "bool" in dtype_str:
return lambda v: str(int(v)), ""
# For float arrays, analyze the values to determine optimal formatting
if "float" in dtype_str or "complex" in dtype_str:
flat = _get_flat(arr)
# Sample values for analysis
n = len(flat)
if n <= 200:
sample = [float(v) for v in flat]
else:
sample = [float(flat[i]) for i in range(100)]
sample += [float(flat[n - 100 + i]) for i in range(100)]
# Filter out non-finite values for scale analysis
finite = [abs(v) for v in sample if v == v and abs(v) != float("inf")]
if not finite:
# All NaN/Inf
return lambda v: ("NaN" if v != v else ("" if v > 0 else "-∞")), ""
max_abs = max(finite) if finite else 0
log_max = math.log10(max_abs) if max_abs > 0 else 0
scale_power = int(log_max // 3) * 3
if scale_power in (-3, 0, 3):
scale_power = 0 # No scientific notation for average scales
scale_suffix = (
f"×10{str(scale_power).translate(_SUPERSCRIPT_DIGITS)}"
if scale_power
else ""
)
scale_factor = 10.0 ** (-scale_power) if scale_power else 1.0
log_scaled = log_max - scale_power if scale_power else log_max
decimals = max(0, 2 - math.floor(log_scaled)) if max_abs > 0 else 0
def fmt(v: Any, sf: float = scale_factor, d: int = decimals) -> str:
if v != v:
return "NaN"
if v == float("inf"):
return ""
if v == float("-inf"):
return "-∞"
scaled = v * sf
if scaled == 0:
return "0"
return f"{scaled:.{d}f}"
return fmt, scale_suffix
# Fallback for other types
return (lambda v: f"{v}"), ""
blacklist_names = {"_", "In", "Out"}
blacklist_types = (
type,
types.ModuleType,
types.FunctionType,
types.MethodType,
types.BuiltinFunctionType,
)
no_str_conv = re.compile(r"<.* object at 0x[0-9a-fA-F]{5,}>")
def _extract_identifiers_ast(sourcecode: str) -> set[str] | None:
"""
Extract variable identifiers from source code using AST.
Returns a set of variable names (including attribute access like "obj.attr"),
or None if AST parsing fails.
"""
# Try to parse as an expression first (most common case for error lines)
for wrapper in ("({})", "{}"):
try:
code = wrapper.format(sourcecode)
tree = ast.parse(code, mode="eval")
break
except SyntaxError:
continue
else:
# Try parsing as statements
try:
tree = ast.parse(sourcecode, mode="exec")
except SyntaxError:
return None
identifiers: set[str] = set()
class IdentifierVisitor(ast.NodeVisitor):
def visit_Name(self, node: ast.Name) -> None:
identifiers.add(node.id)
self.generic_visit(node)
def visit_Attribute(self, node: ast.Attribute) -> None:
# Build the full dotted name for attribute access
parts = [node.attr]
current = node.value
while isinstance(current, ast.Attribute):
parts.append(current.attr)
current = current.value
if isinstance(current, ast.Name):
parts.append(current.id)
# Add the full dotted name (e.g., "obj.attr")
identifiers.add(".".join(reversed(parts)))
# Also add intermediate names (e.g., "obj" for "obj.attr.sub")
for i in range(len(parts)):
identifiers.add(".".join(reversed(parts[i:])))
self.generic_visit(node)
IdentifierVisitor().visit(tree)
return identifiers
def _extract_identifiers_regex(sourcecode: str) -> set[str]:
"""
Extract variable identifiers from source code using regex (fallback).
This is less accurate than AST as it can match names in strings/comments,
but works when AST parsing fails.
"""
return {
m.group(0) for p in (r"\w+", r"\w+\.\w+") for m in re.finditer(p, sourcecode)
}
def extract_variables(variables: dict[str, Any], sourcecode: str) -> list[VarInfo]:
# Try AST-based extraction first, fall back to regex
identifiers = _extract_identifiers_ast(sourcecode)
if identifiers is None:
identifiers = _extract_identifiers_regex(sourcecode)
rows = []
for name, value in variables.items():
if name in blacklist_names or isinstance(value, blacklist_types):
continue
try:
typename = type(value).__name__
if name not in identifiers:
continue
try:
strvalue = str(value)
reprvalue = repr(value)
except Exception:
continue # Skip variables failing str() or repr()
# Using repr is better for empty strings and some other cases
if not strvalue and reprvalue:
strvalue = reprvalue
# Try to print members of objects that don't have proper __str__
elif no_str_conv.fullmatch(strvalue):
found = False
try:
members = safe_vars(value).items()
except Exception:
members = []
for n, v in members:
mname = f"{name}.{n}"
if sourcecode and mname not in identifiers:
continue
tname = type(v).__name__
if isinstance(v, blacklist_types):
continue
# Check if the member also has a poor representation
try:
member_str = str(v)
if no_str_conv.fullmatch(member_str):
continue # Skip members with poor representations
except Exception:
continue # Skip members that fail str()
tname += f" in {typename}"
val_str, val_fmt = prettyvalue(v)
rows += (VarInfo(mname, tname, val_str, val_fmt),)
found = True
if found:
continue
value = ""
# Full types for Numpy-like arrays, PyTorch tensors, etc.
try:
dtype = str(object.__getattribute__(value, "dtype")).rsplit(".", 1)[-1]
if typename == dtype:
raise AttributeError # Numpy scalars need no further info
shape = object.__getattribute__(value, "shape")
dims = "×".join(str(d + 0) for d in shape) + " " if shape else ""
try:
dev = object.__getattribute__(value, "device")
dev = f"@{dev}" if dev and dev.type != "cpu" else ""
except AttributeError:
dev = ""
typename += f" of {dims}{dtype}{dev}"
except AttributeError:
pass
val_str, val_fmt = prettyvalue(value)
# Don't show type for None values
if typename == "NoneType":
typename = ""
rows += (VarInfo(name, typename, val_str, val_fmt),)
except Exception:
logger.exception("Variable inspector failed (please report a bug)")
return rows
def safe_vars(obj: Any) -> dict[str, Any]:
"""Like vars(), but also supports objects with slots."""
ret = {}
for attr in dir(obj):
with contextlib.suppress(AttributeError):
ret[attr] = object.__getattribute__(obj, attr)
return ret
def prettyvalue(val: Any) -> tuple[Any, str]:
"""
Format a value for display in the inspector.
Returns:
tuple: (formatted_value, format_hint) where format_hint is one of:
'block' - left-aligned block format (for multi-line strings)
'inline' - inline right-aligned format (default)
"""
# Handle namedtuple formatting before regular tuple check
# namedtuples have _fields attribute which is a tuple of field names
if (
isinstance(val, tuple)
and hasattr(val, "_fields")
and isinstance(val._fields, tuple)
):
fields = val._fields
if not fields:
return (f"{type(val).__name__}()", "inline")
# For small namedtuples, return as structured table
if len(fields) <= 10:
rows = []
for name in fields:
key_str = name if len(name) <= 40 else name[:37] + ""
field_val = getattr(val, name)
val_str = (
f"{field_val!s}"
if len(f"{field_val!s}") <= 60
else f"{field_val!s}"[:57] + ""
)
rows.append([key_str, val_str])
return ({"type": "keyvalue", "rows": rows}, "inline")
# For larger namedtuples, show summary
return (f"({len(fields)} fields)", "inline")
if isinstance(val, (list, tuple)):
if not 0 < len(val) <= 10:
return (f"({len(val)} items)", "inline")
return (", ".join(repr(v)[:80] for v in val), "inline")
if isinstance(val, dict):
# Handle dict formatting specially
if not val:
return ("{}", "inline")
# For small dicts, return as structured table
if len(val) <= 10:
# Return list of [key, value] pairs for structured rendering
rows = []
for k, v in val.items():
key_str = f"{k!s}" if len(f"{k!s}") <= 40 else f"{k!s}"[:37] + ""
val_str = f"{v!s}" if len(f"{v!s}") <= 60 else f"{v!s}"[:57] + ""
rows.append([key_str, val_str])
return ({"type": "keyvalue", "rows": rows}, "inline")
# For larger dicts, show summary
return (f"({len(val)} items)", "inline")
if dataclasses.is_dataclass(val) and not isinstance(val, type):
# Handle dataclass formatting similar to dict
fields = dataclasses.fields(val)
if not fields:
return (f"{type(val).__name__}()", "inline")
# For small dataclasses, return as structured table
if len(fields) <= 10:
rows = []
for field in fields:
key_str = field.name if len(field.name) <= 40 else field.name[:37] + ""
field_val = object.__getattribute__(val, field.name)
val_str = (
f"{field_val!s}"
if len(f"{field_val!s}") <= 60
else f"{field_val!s}"[:57] + ""
)
rows.append([key_str, val_str])
return ({"type": "keyvalue", "rows": rows}, "inline")
# For larger dataclasses, show summary
return (f"({len(fields)} fields)", "inline")
# msgspec Struct and Pydantic BaseModel support (without importing either)
# msgspec uses __struct_fields__ (tuple), Pydantic v2 uses model_fields (dict)
struct_fields = None
if isinstance(fields := getattr(type(val), "__struct_fields__", None), tuple):
struct_fields = fields
elif isinstance(fields := getattr(type(val), "model_fields", None), dict):
struct_fields = tuple(fields.keys())
if struct_fields is not None:
if not struct_fields:
return (f"{type(val).__name__}()", "inline")
if len(struct_fields) <= 10:
rows = []
for name in struct_fields:
key_str = name if len(name) <= 40 else name[:37] + ""
field_val = object.__getattribute__(val, name)
val_str = (
f"{field_val!s}"
if len(f"{field_val!s}") <= 60
else f"{field_val!s}"[:57] + ""
)
rows.append([key_str, val_str])
return ({"type": "keyvalue", "rows": rows}, "inline")
return (f"({len(struct_fields)} fields)", "inline")
if isinstance(val, type):
return (f"{val.__module__}.{val.__name__}", "inline")
try:
# This only works for Numpy-like arrays, and should cause exceptions otherwise
shape = object.__getattribute__(val, "shape")
if isinstance(shape, tuple) and val.shape:
numelem = reduce(lambda x, y: x * y, shape)
if numelem <= 1:
flat = _get_flat(val)
return (_format_scalar(flat[0]), "inline")
# 1D arrays
if len(shape) == 1:
fmt, suffix = _array_formatter(val)
if shape[0] <= 100:
result = ", ".join(fmt(v) for v in val)
else:
formatted = [fmt(v) for v in (*val[:3], *val[-3:])]
result = ", ".join([*formatted[:3], "", *formatted[-3:]])
if suffix:
result = f"{result} {suffix}"
return (result, "inline")
# 2D arrays
if len(shape) == 2 and shape[0] <= 10 and shape[1] <= 10:
fmt, suffix = _array_formatter(val)
table = [[fmt(v) for v in row] for row in val]
if suffix:
return (
{"type": "array", "rows": table, "suffix": suffix},
"inline",
)
return (table, "inline")
except (AttributeError, ValueError):
pass
except Exception:
logger.exception(
"Pretty-printing in variable inspector failed (please report a bug)"
)
try:
# Handle numpy scalars and plain floats/ints (but not arrays)
dtype_str = str(getattr(val, "dtype", ""))
# Check it's not an array (no shape, or empty shape)
shape = getattr(val, "shape", ())
is_scalar = not shape or (isinstance(shape, tuple) and len(shape) == 0)
is_numeric = isinstance(val, (int, float)) or (dtype_str and is_scalar)
if is_numeric and not isinstance(val, bool):
return (_format_scalar(val), "inline")
except (AttributeError, TypeError, ValueError):
pass
# Determine format hint based on content
format_hint = "inline"
# Format exceptions using str() for cleaner display
if isinstance(val, BaseException):
ret = str(val)
elif isinstance(val, str):
ret = str(val)
# Multi-line strings should be displayed as blocks
if "\n" in ret.rstrip():
format_hint = "block"
else:
ret = repr(val)
# For inline format, collapse newlines to avoid display issues
if format_hint == "inline":
if "\n" in ret:
ret = " ".join(line.strip() for line in ret.split("\n") if line.strip())
# Only truncate inline values
if len(ret) > 120:
ret = ret[:30] + "" + ret[-30:]
# For block format, don't truncate but limit line count if needed
else:
lines = ret.split("\n")
if len(lines) > 20:
# Show first 10 and last 10 lines
ret = "\n".join(lines[:10] + [""] + lines[-10:])
return (ret, format_hint)
@@ -0,0 +1,4 @@
import logging
logger = logging.getLogger("tracerite")
logger.setLevel(logging.INFO)
@@ -0,0 +1,115 @@
from __future__ import annotations
import contextlib
import sys
from typing import Any
from . import trace
from .html import html_traceback
from .logging import logger
from .tty import tty_traceback
# Cleanup mode: "replace" (default) removes old reports, "keep" only removes script/style
_cleanup_mode = "replace"
def _can_display_html() -> bool:
# Spyder runs IPython ZMQInteractiveShell but lacks HTML support. Using
# argv seems like the most portable way to autodetect HTML capability.
#
# "ipykernel_launcher.py" in Jupyter Notebook/Lab
# "ipykernel/__main__.py" in Azure Notebooks
# "colab_kernel_launcher.py" in Google Colab
return any(name in sys.argv[0] for name in ["ipykernel", "colab_kernel_launcher"])
def load_ipython_extension(ipython: Any) -> None:
trace.ipython = ipython
# Hide IPython's internal frames from tracebacks
try:
from IPython.core import interactiveshell # type: ignore[import]
interactiveshell.__tracebackhide__ = True # type: ignore[attr-defined]
except ImportError:
pass
# Define handlers that check HTML capability at call time, not load time.
# This allows the same extension to work in both Jupyter and terminal.
def showtraceback(*args: Any, **kwargs: Any) -> None:
try:
if _can_display_html():
from IPython.display import display # type: ignore[import]
display(
html_traceback(
skip_until="<ipython-input-",
replace_previous=True,
cleanup_mode=_cleanup_mode,
autodark=False,
)
)
else:
tty_traceback(skip_until="<ipython-input-")
except Exception:
# Fall back to built-in showtraceback
ipython.__class__.showtraceback(ipython, *args, **kwargs)
def showsyntaxerror(*args: Any, **kwargs: Any) -> None:
try:
if _can_display_html():
from IPython.display import display # type: ignore[import]
display(
html_traceback(
skip_until="<ipython-input-",
replace_previous=True,
cleanup_mode=_cleanup_mode,
autodark=False,
)
)
else:
tty_traceback(skip_until="<ipython-input-")
except Exception:
# Fall back to built-in showsyntaxerror
ipython.__class__.showsyntaxerror(ipython, *args, **kwargs)
# Install the handlers
try:
ipython.showtraceback = showtraceback
ipython.showsyntaxerror = showsyntaxerror
except Exception:
logger.error("Unable to load TraceRite (please report a bug!)")
raise
# Register the %tracerite magic command
from IPython.core.magic import register_line_magic # type: ignore[import]
@register_line_magic
def tracerite(line: str) -> None:
"""Configure tracerite behavior.
Usage:
%tracerite keep - Keep all previous error reports visible
"""
global _cleanup_mode
if line.strip().lower() == "keep":
_cleanup_mode = "keep"
else:
print("Usage: %tracerite keep")
def unload_ipython_extension(ipython: Any) -> None:
with contextlib.suppress(AttributeError):
del ipython.showtraceback
with contextlib.suppress(AttributeError):
del ipython.showsyntaxerror
# Remove the __tracebackhide__ we injected
try:
from IPython.core import interactiveshell # type: ignore[import]
with contextlib.suppress(AttributeError):
del interactiveshell.__tracebackhide__ # type: ignore[attr-defined]
except ImportError:
pass
trace.ipython = None
@@ -0,0 +1,20 @@
(()=>{
// Move style to head (replacing any old version) to preserve it when .tracerite elements are deleted
const current=document.currentScript?.parentElement
const style=current?.querySelector('style')
if(style){
document.getElementById('tracerite-style')?.remove()
style.id='tracerite-style'
document.head.appendChild(style)
}
// Remove contents of any previous tracerite elements based on cleanup mode
if(current?.dataset.replacePrevious){
const mode=current.dataset.cleanupMode||'replace'
document.querySelectorAll('.tracerite').forEach(el=>{
if(el!==current){
// In replace mode, remove all direct children except h2
if(mode==='replace')[...el.children].forEach(c=>{if(c.tagName!=='H2')c.remove()})
}
})
}
})()
@@ -0,0 +1,466 @@
/* Compact mode uses CSS Grid - see COMPACT MODE section below */
@import url('https://cdn.jsdelivr.net/npm/@fontsource/monaspace-krypton/index.css');
/** TraceRite **/
:root {
--tracerite-var: #8af;
--tracerite-type: #5c8;
--tracerite-val: #8af;
--tracerite-highlight: #ff8;
--tracerite-highlight-text: #000;
--tracerite-call-symbol-color: #ff8;
--tracerite-call-symbol-shadow: 0 0 .1em black;
--tracerite-call-highlight: #da0;
--tracerite-caret: #f00;
--tracerite-exception: #777;
--tracerite-tooltip: #000;
--tracerite-tooltip-text: inherit;
--tracerite-code: inherit;
--tracerite-lineno: #888;
--tracerite-link-bg: #fff5;
--tracerite-link-hover: #fff8;
--tracerite-function: #68f;
--tracerite-location: #5a6;
--tracerite-code-font: 'Monaspace Krypton', 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Source Code Pro', 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Ubuntu Mono', 'Consolas', 'Courier New';
--tracerite-ui-font: system-ui, -apple-system, 'Segoe UI', 'Roboto', 'Ubuntu', 'Cantarell', 'Noto Sans', sans-serif;
}
:root:has(.tracerite.autodark) {
color-scheme: light dark;
}
:root:has(.tracerite.autodark) {
@media (prefers-color-scheme: dark) {
--tracerite-var: #8af;
--tracerite-type: #5c8;
--tracerite-val: #8af;
--tracerite-highlight: #ff0;
--tracerite-highlight-text: #000;
--tracerite-call-symbol-color: #ff0;
--tracerite-call-symbol-shadow: none;
--tracerite-call-highlight: #ff0;
--tracerite-caret: #f55;
--tracerite-exception: #aaa;
--tracerite-tooltip: #fff;
--tracerite-tooltip-text: #fff;
--tracerite-code: #ccc;
--tracerite-lineno: #888;
--tracerite-link-bg: #0005;
--tracerite-link-hover: #0008;
--tracerite-function: #8af;
--tracerite-location: #6b8;
}
}
:root .tracerite { font-family: var(--tracerite-ui-font); font-size: 16px; }
:root .tracerite,
:root .tracerite *,
:root .tracerite .traceback-details table,
:root .tracerite > h2,
:root .tracerite > h3 { margin: 0; padding: 0; outline: none; box-sizing: border-box; line-height: 1.2; font: var(--tracerite-ui-font); font-weight: 700;}
:root .tracerite > h2 { font-size: 1.1em; }
:root .tracerite > h3 { font-size: 1em; }
/* Code font declarations with high specificity - only for code tags */
:root .tracerite pre,
:root .tracerite code {
font-family: var(--tracerite-code-font);
font-feature-settings: "liga" 1, "ss01" 1, "ss02" 1;
font-variant-ligatures: common-ligatures;
background: none;
color: var(--tracerite-code);
text-overflow: ellipsis;
word-break: normal;
}
:root .tracerite strong,
:root .tracerite > h3 { font-weight: bold; padding: 0.2em 0; }
:root .tracerite .excmessage { font-size: 0.8em; max-height: 12em; overflow: auto; border-left: .2em solid var(--tracerite-exception); margin-left: 0.2em; padding-left: 0.5em;}
:root .tracerite .exctype { color: var(--tracerite-exception); }
:root .tracerite .traceback-details { font-size: 0.8em; }
:root .tracerite .traceback-details p { margin: 1em 0; }
:root .tracerite .traceback-details pre { width: 50vw; padding: .5em; font-size: 0.8em; }
:root .tracerite .traceback-details .codeline { text-indent: 4ch each-line; }
:root .tracerite .traceback-details .codeline::before {
content: attr(data-lineno);
color: var(--tracerite-lineno);
opacity: 0.0;
transition: all 0.4s;
display: inline-block;
text-align: right;
text-indent: 0;
white-space: nowrap;
word-break: keep-all;
padding-right: 1ch;
width: 4ch;
}
:root .tracerite .traceback-details pre:hover .codeline::before { opacity: 1.0; }
:root .tracerite .traceback-details mark { background: var(--tracerite-highlight); color: var(--tracerite-highlight-text); padding: 0.2em; margin: -0.1em; }
:root .tracerite .traceback-details em { font-style: normal; color: var(--tracerite-caret); }
/* Symbol element - display symbol with specific styling */
:root .tracerite .traceback-details .tracerite-symbol {
display: inline-block;
margin-left: 0.5ch;
padding: 0.1em;
font-size: 1.5em;
margin: -0.25em 0 -.25em 0.3em;
font-weight: bold;
vertical-align: middle;
font-family: var(--tracerite-ui-font);
}
/* Call symbols get special color and shadow */
:root .tracerite .traceback-call .tracerite-symbol {
color: var(--tracerite-call-symbol-color);
text-shadow: var(--tracerite-call-symbol-shadow);
}
/* Display symbol using pseudo-element and data attribute */
:root .tracerite .traceback-details .tracerite-symbol::before { content: attr(data-symbol); }
/* Tooltip text element - display text with different styling */
:root .tracerite .traceback-details .tracerite-tooltip-text {
display: inline-block;
margin-left: 0.3ch;
font-size: 0.9em;
font-weight: bold;
vertical-align: middle;
font-family: var(--tracerite-ui-font);
white-space: nowrap;
color: var(--tracerite-tooltip-text);
}
/* Display tooltip text using pseudo-element and data attribute */
:root .tracerite .traceback-details .tracerite-tooltip-text::before { content: attr(data-tooltip); }
:root .tracerite .traceback-details {
position: relative;
min-width: 20ch;
max-width: 100%;
margin: 0 .2em;
flex-shrink: 0;
border-radius: .5em;
padding: .2em;
padding-left: 0;
}
:root .tracerite .traceback-details:last-child { width: 100%; }
:root .tracerite .traceback-ellipsis { min-width: 4ch; text-align: center; }
/* Base styles for frame-function and frame-location */
:root .tracerite .frame-function { font-weight: 600; color: var(--tracerite-function); line-height: 1.2; }
:root .tracerite .frame-location { color: var(--tracerite-location); line-height: 1.2; }
:root .tracerite .frame-colon { color: var(--tracerite-code); font-weight: 700; }
:root .tracerite .frame-lineno { color: var(--tracerite-lineno); display: inline; }
:root .tracerite .frame-link {
margin-left: 0.3em;
padding: 0.1em 0.4em;
background: var(--tracerite-link-bg);
border-radius: 0.3em;
color: inherit;
text-decoration: none;
font-size: 0.85em;
}
:root .tracerite .frame-link:hover { background: var(--tracerite-link-hover); }
/* Variable inspector: grid layout with fixed first column, flexible second */
:root .tracerite dl.inspector {
display: grid;
grid-template-columns: auto 1fr;
margin: 0;
min-width: 8em;
width: 100%;
word-break: break-word;
}
:root .tracerite dl.inspector dt,
:root .tracerite dl.inspector dd {
margin: 0;
padding: 0;
width: 100%;
text-align: right;
vertical-align: top;
font-weight: normal;
float: none;
}
:root .tracerite dl.inspector dt {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
:root .tracerite .inspector .var { font-weight: bold; color: var(--tracerite-var); }
:root .tracerite .inspector .type { white-space: nowrap; color: var(--tracerite-type); }
:root .tracerite .inspector .val { white-space: pre; text-overflow: ellipsis; overflow: hidden; color: var(--tracerite-val); }
/* Block format for multi-line strings - left aligned */
:root .tracerite .inspector .val-block {
text-align: left;
white-space: pre-wrap;
overflow: visible;
max-width: none;
word-break: break-word;
}
:root .tracerite .inspector .val-block pre {
margin: 0;
padding: 0;
padding-top: 0.4em;
border-radius: 3px;
font-size: 0.8em;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
max-width: 30em;
color: inherit;
font-family: var(--tracerite-code-font);
}
/* Inline format - left aligned (default) */
:root .tracerite .inspector .val-inline {
text-align: left;
}
/* Nested table styling (for matrices) */
:root .tracerite .inspector table td {
color: var(--tracerite-val);
word-break: keep-all;
overflow: hidden;
font-size: 0.8em;
border-collapse: collapse;
text-align: right;
}
:root .tracerite .inspector tr {
background: none;
}
/* matrix value on a variable */
:root .tracerite .inspector .val-inline table td {
padding: 0 0.4em;
min-width: 2em;
}
/* Key-value dl styling (dicts, dataclasses) */
:root .tracerite .inspector dl.keyvalue-dl {
display: grid;
grid-template-columns: auto 1fr;
margin: 0;
padding-top: 0.2em;
font-size: 0.8em;
}
:root .tracerite .inspector dl.keyvalue-dl dt,
:root .tracerite .inspector dl.keyvalue-dl dd {
margin: 0;
padding: 0 0.3em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:root .tracerite .inspector dl.keyvalue-dl dt {
text-align: right;
color: var(--tracerite-var);
font-weight: bold;
width: max-content;
float: none;
}
:root .tracerite .inspector dl.keyvalue-dl dd {
text-align: left;
width: auto;
float: none;
}
/* Array with scale factor container */
:root .tracerite .inspector .array-with-scale {
display: flex;
align-items: center;
gap: 0.3em;
}
/* Scale suffix for arrays (e.g., ×10⁶) */
:root .tracerite .inspector .scale-suffix {
font-size: 1.4em;
font-weight: bold;
white-space: nowrap;
align-self: center;
}
/* ============================================
COMPACT MODE TOGGLE AND STYLING (with :has)
============================================ */
/* CSS Grid layout for compact mode */
:root .tracerite {
display: grid;
grid-template-columns: auto auto auto 1fr;
align-items: baseline;
gap: 0 0.5em;
justify-content: start;
}
/* Hidden checkbox for toggle */
:root .tracerite .frame-toggle-checkbox {
display: none;
}
/* Expand wrapper - uses grid-template-rows for height animation */
:root .tracerite .expand-wrapper {
grid-column: 1 / -1;
display: grid;
grid-template-rows: 0fr;
grid-template-columns: subgrid;
transition: grid-template-rows 0.25s ease-out;
}
:root .tracerite .expand-content {
overflow: hidden;
min-height: 0;
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
}
:root .tracerite .expand-content > pre {
grid-column: 1 / 4;
width: auto;
white-space: pre-wrap;
word-wrap: break-word;
text-overflow: clip;
overflow: hidden; /* Prevent scrollbar flash during animation */
}
:root .tracerite .expand-content > dl.inspector {
grid-column: 4;
align-self: start;
margin-top: auto;
margin-bottom: auto;
height: fit-content;
}
/* When checkbox is checked, expand the wrapper */
:root .tracerite .frame-toggle-checkbox:checked ~ .compact-call-line {
display: none;
}
:root .tracerite .frame-toggle-checkbox:checked ~ .expand-wrapper {
grid-template-rows: 1fr;
}
/* Colon always visible */
:root .tracerite .frame-colon {
color: var(--tracerite-code);
font-weight: 700;
}
/* Exception headers and messages span all columns */
:root .tracerite > h2,
:root .tracerite > h3,
:root .tracerite > pre.excmessage {
grid-column: 1 / -1;
}
/* Make intermediate containers transparent to grid */
:root .tracerite .traceback-details {
display: contents;
}
/* Grid column assignments for all frame elements */
/* Location comes first, then function */
:root .tracerite .traceback-details .frame-location {
grid-column: 1;
overflow: hidden;
text-overflow: ellipsis;
max-width: 20em;
}
:root .tracerite .traceback-details .frame-function {
grid-column: 2;
overflow: hidden;
text-overflow: ellipsis;
max-width: 20em;
}
:root .tracerite .traceback-details .compact-call-line {
grid-column: 3 / -1;
}
/* Wrapper label for clickable location+function - uses display:contents to not affect grid */
:root .tracerite .frame-label-wrapper {
display: contents;
cursor: pointer;
}
/* Ellipsis frame spans all columns */
:root .tracerite .traceback-ellipsis {
grid-column: 1 / -1;
min-width: auto;
text-align: left;
padding: 0 0.5em;
color: var(--tracerite-lineno);
}
/* Compact code line styling */
:root .tracerite .compact-call-line {
display: inline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40em;
line-height: 1;
vertical-align: baseline;
}
:root .tracerite .compact-call-line code.compact-code {
display: inline;
overflow: hidden;
text-overflow: ellipsis;
line-height: inherit;
vertical-align: baseline;
background: transparent;
}
:root .tracerite .compact-call-line .compact-symbol {
font-size: 1.1em;
vertical-align: baseline;
flex-shrink: 0;
}
/* Call symbols in compact lines get special color and shadow */
:root .tracerite .traceback-call .compact-call-line .compact-symbol {
color: var(--tracerite-call-symbol-color);
text-shadow: var(--tracerite-call-symbol-shadow);
}
:root .tracerite .compact-call-line em {
color: var(--tracerite-call-highlight);
}
/* Error/stop frames use red caret in compact mode */
:root .tracerite .traceback-error .compact-call-line em,
:root .tracerite .traceback-stop .compact-call-line em {
color: var(--tracerite-caret);
}
/* Parallel branches for ExceptionGroups - side by side layout */
:root .tracerite .parallel-branches {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
align-items: flex-start;
border-left: .2em solid var(--tracerite-exception);
margin-left: 0.2em;
padding-left: 0.5em;
}
:root .tracerite .parallel-branch {
flex: 1 1 300px;
min-width: 250px;
max-width: 100%;
/* Same grid layout as .tracerite for consistent frame alignment */
display: grid;
grid-template-columns: auto auto auto 1fr;
align-items: baseline;
gap: 0 0.5em;
justify-content: start;
}
/* Exception headers and messages inside parallel branches span all columns */
:root .tracerite .parallel-branch > h3,
:root .tracerite .parallel-branch > pre.excmessage {
grid-column: 1 / -1;
}
@@ -0,0 +1,417 @@
"""Enhanced SyntaxError position extraction.
Python's SyntaxError often provides poor position information, especially for
multi-line errors like mismatched brackets. This module parses common error
patterns and source code to provide better highlighting ranges.
"""
import re
from collections import namedtuple
# Position range: lines are 1-based inclusive, columns are 0-based exclusive
Range = namedtuple("Range", ["lfirst", "lfinal", "cbeg", "cend"])
# Patterns for extracting information from SyntaxError messages
MISMATCH_PATTERN = re.compile(
r"closing parenthesis '([)\]}])' does not match opening parenthesis '([(\[{])' on line (\d+)"
)
UNCLOSED_PATTERN = re.compile(r"'([(\[{])' was never closed")
INCOMPLETE_INPUT_PATTERN = re.compile(r"incomplete input")
# Match "unterminated string literal" and "unterminated f-string literal"
UNTERMINATED_STRING_PATTERN = re.compile(r"unterminated (?:f-)?string literal")
# Match "unterminated triple-quoted string literal" and "unterminated triple-quoted f-string literal"
UNTERMINATED_TRIPLE_PATTERN = re.compile(
r"unterminated triple-quoted (?:f-)?string literal"
)
# Pattern to clean up redundant line info from messages
DETECTED_AT_LINE_PATTERN = re.compile(r" \(detected at line \d+\)$")
ON_LINE_PATTERN = re.compile(r" on line \d+$")
FILENAME_LINE_PATTERN = re.compile(r" \([^)]+, line \d+\)$")
BRACKET_PAIRS = {")": "(", "]": "[", "}": "{"}
BRACKET_PAIRS_REV = {"(": ")", "[": "]", "{": "}"}
ALL_OPENERS = "([{"
def _iter_code_chars(source_lines, end_line=None, end_col=None):
"""Iterate over characters in source code, skipping strings and comments.
Yields (line_idx_1based, col, char) for each character that is actual code
(not inside a string literal or comment).
"""
if end_line is None:
end_line = len(source_lines)
in_string = None # None, or the quote character(s) that opened the string
for line_idx in range(min(end_line, len(source_lines))):
line = source_lines[line_idx].rstrip("\n\r")
line_num = line_idx + 1 # 1-based
# Determine where to stop on this line
line_end = len(line)
if line_num == end_line and end_col is not None:
line_end = min(line_end, end_col)
col = 0
while col < line_end:
char = line[col]
rest = line[col:]
if in_string:
# Check for end of string
if rest.startswith(in_string):
# Check it's not escaped (count preceding backslashes)
num_backslashes = 0
check_col = col - 1
while check_col >= 0 and line[check_col] == "\\":
num_backslashes += 1
check_col -= 1
if num_backslashes % 2 == 0: # Not escaped
col += len(in_string)
in_string = None
continue
col += 1
continue
# Check for start of string
if rest.startswith('"""') or rest.startswith("'''"):
in_string = rest[:3]
col += 3
continue
if char in "\"'":
in_string = char
col += 1
continue
# Check for comment
if char == "#":
break # Rest of line is comment
# This is actual code
yield line_num, col, char
col += 1
# Single-quoted strings don't span lines (would be a syntax error)
if in_string and len(in_string) == 1:
in_string = None
def clean_syntax_error_message(message):
"""Clean up redundant information from SyntaxError messages.
Removes patterns like:
- " (detected at line 1)" from unterminated strings
- " on line 2" from bracket mismatches
- " (filename.py, line N)" suffix
These are redundant since we show the line in the traceback.
"""
message = DETECTED_AT_LINE_PATTERN.sub("", message)
message = ON_LINE_PATTERN.sub("", message)
message = FILENAME_LINE_PATTERN.sub("", message)
return message
def extract_enhanced_positions(e, source_lines):
"""Extract enhanced position information for a SyntaxError.
Args:
e: The SyntaxError exception
source_lines: List of source lines (strings with newlines)
Returns:
Tuple of (mark_range, em_ranges) where:
mark_range: Range for the full highlight (e.g., from opening to closing bracket), or None
em_ranges: List of Range objects for emphasized positions (e.g., both mismatched brackets), or None
"""
message = str(e)
# Try to handle mismatched brackets: "closing parenthesis ')' does not match opening parenthesis '{' on line 1"
match = MISMATCH_PATTERN.search(message)
if match:
return _handle_mismatch(e, source_lines, match)
# Try to handle unclosed brackets: "'(' was never closed"
match = UNCLOSED_PATTERN.search(message)
if match:
return _handle_unclosed(e, source_lines, match)
# Try to handle unterminated triple-quoted string (check before single)
match = UNTERMINATED_TRIPLE_PATTERN.search(message)
if match:
return _handle_unterminated_triple_string(e, source_lines)
# Try to handle unterminated string literal
match = UNTERMINATED_STRING_PATTERN.search(message)
if match:
return _handle_unterminated_string(e, source_lines)
# Try to handle incomplete input (e.g., _IncompleteInputError)
match = INCOMPLETE_INPUT_PATTERN.search(message)
if match:
return _handle_incomplete(e, source_lines)
# Default: use Python's positions
return None, None
def _handle_mismatch(e, source_lines, match):
"""Handle mismatched bracket errors."""
opening_char = match.group(2) # The opening bracket it should match
opening_line = int(match.group(3)) # Line number of opening bracket (1-based)
closing_line = e.lineno
closing_col = (e.offset - 1) if e.offset else 0
# Find the opening bracket position on its line
opening_col = None
if 0 < opening_line <= len(source_lines):
# Find the opening bracket - search for the one that would be unmatched
opening_col = _find_unmatched_opener(
source_lines, opening_line, opening_char, closing_line, closing_col
)
if opening_col is None:
# Fallback: just find first occurrence
if 0 < opening_line <= len(source_lines):
opening_col = source_lines[opening_line - 1].find(opening_char)
if opening_col < 0:
opening_col = 0
else:
opening_col = 0
# Mark range spans from opening bracket to closing bracket
mark_range = Range(opening_line, closing_line, opening_col, closing_col + 1)
# Emphasis on both mismatched brackets
em_ranges = [
Range(opening_line, opening_line, opening_col, opening_col + 1),
Range(closing_line, closing_line, closing_col, closing_col + 1),
]
return mark_range, em_ranges
def _handle_unclosed(e, source_lines, match):
"""Handle unclosed bracket errors."""
opening_char = match.group(1)
# Python gives us the line where it detected the problem
# The opening bracket is somewhere before
error_line = e.lineno
error_col = (e.offset - 1) if e.offset else 0
# Search backwards for the unclosed opener
opening_line, opening_col = _find_unclosed_opener(
source_lines, error_line, opening_char
)
if opening_line is None or opening_col is None:
return None, None
# Mark from opener to error position
mark_range = Range(opening_line, error_line, opening_col, error_col + 1)
em_ranges = [Range(opening_line, opening_line, opening_col, opening_col + 1)]
return mark_range, em_ranges
def _handle_incomplete(e, source_lines):
"""Handle incomplete input errors (e.g., _IncompleteInputError).
These occur when code is syntactically valid but incomplete (unclosed bracket,
unterminated string, etc.). Python only gives us the final line number.
We need to find the unclosed construct and mark from there to the end.
"""
# Find the last non-empty line (trimmed, ignoring comments)
end_line = len(source_lines)
end_col = 0
for i in range(len(source_lines) - 1, -1, -1):
line = source_lines[i].rstrip("\n\r")
# Remove comments for checking if line is empty
code_part = line.split("#")[0].rstrip()
if code_part:
end_line = i + 1 # 1-based
end_col = len(line)
break
# Try to find any unclosed bracket
opening_line, opening_col, opener_char = _find_any_unclosed_opener(
source_lines, end_line
)
if opening_line is None or opening_col is None:
return None, None
# Mark from opener to end of meaningful content
mark_range = Range(opening_line, end_line, opening_col, end_col)
em_ranges = [Range(opening_line, opening_line, opening_col, opening_col + 1)]
return mark_range, em_ranges
def _find_any_unclosed_opener(source_lines, end_line):
"""Find any unclosed opening bracket by scanning the source."""
# Track all bracket types using proper tokenization
stacks = {char: [] for char in ALL_OPENERS}
for line_num, col, char in _iter_code_chars(source_lines, end_line):
if char in ALL_OPENERS:
stacks[char].append((line_num, col))
elif char in BRACKET_PAIRS:
opener = BRACKET_PAIRS[char]
if stacks[opener]:
stacks[opener].pop()
# Find the first unclosed opener (earliest in code)
first_unclosed = None
first_opener = None
for opener_char, stack in stacks.items():
if stack:
pos = stack[0] # First unclosed of this type
if first_unclosed is None or (pos[0], pos[1]) < (
first_unclosed[0],
first_unclosed[1],
):
first_unclosed = pos
first_opener = opener_char
if first_unclosed:
return first_unclosed[0], first_unclosed[1], first_opener
return None, None, None
def _find_unmatched_opener(
source_lines, opener_line, opener_char, closer_line, closer_col
):
"""Find the column of the unmatched opening bracket.
Scans from the indicated opener_line to find which opening bracket
is actually unmatched with the closer at closer_line:closer_col.
Uses proper tokenization to skip brackets inside strings and comments.
"""
closer_char = BRACKET_PAIRS_REV.get(opener_char, ")")
# Track bracket depth as we scan
# We need to find the opener that would be matched by the closer
stack = [] # Stack of (line, col) for opening brackets
# Use tokenizer, but only scan from opener_line to closer position
for line_num, col, char in _iter_code_chars(source_lines, closer_line, closer_col):
if line_num < opener_line:
continue
if char == opener_char:
stack.append((line_num, col))
elif char == closer_char and stack:
stack.pop()
# The last unmatched opener is what we want
if stack:
return stack[-1][1]
return None
def _find_unclosed_opener(source_lines, error_line, opener_char):
"""Find an unclosed opening bracket by scanning the source.
Uses proper tokenization to skip brackets inside strings and comments.
"""
closer_char = BRACKET_PAIRS_REV.get(opener_char, ")")
# Scan through code tracking bracket balance
stack = [] # Stack of (line, col) for opening brackets
for line_num, col, char in _iter_code_chars(source_lines, error_line):
if char == opener_char:
stack.append((line_num, col))
elif char == closer_char and stack:
stack.pop()
# Return the first unclosed opener
if stack:
return stack[0]
return None, None
def _get_string_opener_length(line, col):
"""Get the length of a string opener (prefix + quotes) starting at col.
Returns the length of the full opener, e.g.:
- ' or " -> 1
- ''' or \"\"\" -> 3
- f' or f" -> 2
- f''' or f\"\"\" -> 4
- rf' or fr" -> 3
- rf''' or rf\"\"\" -> 5
"""
rest = line[col:]
# Check for string prefix (case insensitive: f, r, b, u, fr, rf, br, rb)
prefix_len = 0
prefix_rest = rest.lower()
if prefix_rest[:2] in ("fr", "rf", "br", "rb"):
prefix_len = 2
elif prefix_rest[:1] in ("f", "r", "b", "u"):
prefix_len = 1
# Check for quotes after prefix
after_prefix = rest[prefix_len:]
if after_prefix.startswith('"""') or after_prefix.startswith("'''"):
return prefix_len + 3
elif after_prefix and after_prefix[0] in "\"'":
return prefix_len + 1
# Fallback: just one character
return 1
def _handle_unterminated_string(e, source_lines):
"""Handle unterminated string literal errors.
For single-line strings, mark from the opening to end of the line,
and emphasize the full opener (prefix + quote).
"""
error_line = e.lineno
error_col = (e.offset - 1) if e.offset else 0
if not source_lines or error_line < 1 or error_line > len(source_lines):
return None, None
line = source_lines[error_line - 1].rstrip("\n\r")
end_col = len(line)
# Get the full string opener length (prefix + quote)
opener_len = _get_string_opener_length(line, error_col)
# Mark from the opening to end of line
mark_range = Range(error_line, error_line, error_col, end_col)
# Emphasize the full opener (prefix + quote)
em_ranges = [Range(error_line, error_line, error_col, error_col + opener_len)]
return mark_range, em_ranges
def _handle_unterminated_triple_string(e, source_lines):
"""Handle unterminated triple-quoted string literal errors.
Mark from opening to end of line, emphasize the full opener (prefix + triple quotes).
"""
error_line = e.lineno
error_col = (e.offset - 1) if e.offset else 0
if not source_lines or error_line < 1 or error_line > len(source_lines):
return None, None
line = source_lines[error_line - 1].rstrip("\n\r")
end_col = len(line)
# Get the full string opener length (prefix + triple quotes)
opener_len = _get_string_opener_length(line, error_col)
# Mark from opening to end of line (not end of input - per user feedback)
mark_range = Range(error_line, error_line, error_col, end_col)
# Emphasize the full opener (prefix + triple quotes)
em_ranges = [Range(error_line, error_line, error_col, error_col + opener_len)]
return mark_range, em_ranges
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,408 @@
# Copied from https://github.com/python/cpython/blob/main/Lib/traceback.py
# We need to use internal functions that are not part of the public API,
# and that are not available in earlier Python versions.
# Unused functionality is removed and we run a formatter.
# One modification is made for Python 3.9 and 3.10 compatibility (see comment).
# ruff: noqa
"""Extract, format and print information about Python stack traces."""
import collections.abc
import itertools
import sys
class _Sentinel:
def __repr__(self):
return "<implicit>"
_sentinel = _Sentinel()
def _parse_value_tb(exc, value, tb):
if (value is _sentinel) != (tb is _sentinel):
raise ValueError("Both or neither of value and tb must be given")
if value is tb is _sentinel:
if exc is not None:
if isinstance(exc, BaseException):
return exc, exc.__traceback__
raise TypeError(f"Exception expected for value, {type(exc).__name__} found")
else:
return None, None
return value, tb
BUILTIN_EXCEPTION_LIMIT = object()
def _safe_string(value, what, func=str):
try:
return func(value)
except:
return f"<{what} {func.__name__}() failed>"
def _walk_tb_with_full_positions(tb):
# Internal version of walk_tb that yields full code positions including
# end line and column information.
while tb is not None:
positions = _get_code_position(tb.tb_frame.f_code, tb.tb_lasti)
# Yield tb_lineno when co_positions does not have a line number to
# maintain behavior with walk_tb.
if positions[0] is None:
yield tb.tb_frame, (tb.tb_lineno,) + positions[1:]
else:
yield tb.tb_frame, positions
tb = tb.tb_next
def _get_code_position(code, instruction_index):
if instruction_index < 0:
return (None, None, None, None)
# TRACERITE MODIFICATION: co_positions() was added in Python 3.11
# Fallback for Python 3.9 and 3.10 compatibility
if not hasattr(code, "co_positions"):
return (None, None, None, None)
positions_gen = code.co_positions()
return next(itertools.islice(positions_gen, instruction_index // 2, None))
def _byte_offset_to_character_offset(str, offset):
as_utf8 = str.encode("utf-8")
return len(as_utf8[:offset].decode("utf-8", errors="replace"))
_Anchors = collections.namedtuple(
"_Anchors",
[
"left_end_lineno",
"left_end_offset",
"right_start_lineno",
"right_start_offset",
"primary_char",
"secondary_char",
],
defaults=["~", "^"],
)
def _extract_caret_anchors_from_line_segment(segment):
"""
Given source code `segment` corresponding to a FrameSummary, determine:
- for binary ops, the location of the binary op
- for indexing and function calls, the location of the brackets.
`segment` is expected to be a valid Python expression.
"""
import ast
try:
# Without parentheses, `segment` is parsed as a statement.
# Binary ops, subscripts, and calls are expressions, so
# we can wrap them with parentheses to parse them as
# (possibly multi-line) expressions.
# e.g. if we try to highlight the addition in
# x = (
# a +
# b
# )
# then we would ast.parse
# a +
# b
# which is not a valid statement because of the newline.
# Adding brackets makes it a valid expression.
# (
# a +
# b
# )
# Line locations will be different than the original,
# which is taken into account later on.
tree = ast.parse(f"(\n{segment}\n)")
except SyntaxError:
return None
if len(tree.body) != 1:
return None
lines = segment.splitlines()
def normalize(lineno, offset):
"""Get character index given byte offset"""
return _byte_offset_to_character_offset(lines[lineno], offset)
def next_valid_char(lineno, col):
"""Gets the next valid character index in `lines`, if
the current location is not valid. Handles empty lines.
"""
while lineno < len(lines) and col >= len(lines[lineno]):
col = 0
lineno += 1
assert lineno < len(lines) and col < len(lines[lineno])
return lineno, col
def increment(lineno, col):
"""Get the next valid character index in `lines`."""
col += 1
lineno, col = next_valid_char(lineno, col)
return lineno, col
def nextline(lineno, col):
"""Get the next valid character at least on the next line"""
col = 0
lineno += 1
lineno, col = next_valid_char(lineno, col)
return lineno, col
def increment_until(lineno, col, stop):
"""Get the next valid non-"\\#" character that satisfies the `stop` predicate"""
while True:
ch = lines[lineno][col]
if ch in "\\#":
lineno, col = nextline(lineno, col)
elif not stop(ch):
lineno, col = increment(lineno, col)
else:
break
return lineno, col
def setup_positions(expr, force_valid=True):
"""Get the lineno/col position of the end of `expr`. If `force_valid` is True,
forces the position to be a valid character (e.g. if the position is beyond the
end of the line, move to the next line)
"""
# -2 since end_lineno is 1-indexed and because we added an extra
# bracket + newline to `segment` when calling ast.parse
lineno = expr.end_lineno - 2
col = normalize(lineno, expr.end_col_offset)
return next_valid_char(lineno, col) if force_valid else (lineno, col)
statement = tree.body[0]
if isinstance(statement, ast.Expr):
expr = statement.value
if isinstance(expr, ast.BinOp):
# ast gives these locations for BinOp subexpressions
# ( left_expr ) + ( right_expr )
# left^^^^^ right^^^^^
lineno, col = setup_positions(expr.left)
# First operator character is the first non-space/')' character
lineno, col = increment_until(
lineno, col, lambda x: not x.isspace() and x != ")"
)
# binary op is 1 or 2 characters long, on the same line,
# before the right subexpression
right_col = col + 1
if (
right_col < len(lines[lineno])
and (
# operator char should not be in the right subexpression
expr.right.lineno - 2 > lineno
or right_col
< normalize(expr.right.lineno - 2, expr.right.col_offset)
)
and not (ch := lines[lineno][right_col]).isspace()
and ch not in "\\#"
):
right_col += 1
# right_col can be invalid since it is exclusive
return _Anchors(lineno, col, lineno, right_col)
if isinstance(expr, ast.Subscript):
# ast gives these locations for value and slice subexpressions
# ( value_expr ) [ slice_expr ]
# value^^^^^ slice^^^^^
# subscript^^^^^^^^^^^^^^^^^^^^
# find left bracket
left_lineno, left_col = setup_positions(expr.value)
left_lineno, left_col = increment_until(
left_lineno, left_col, lambda x: x == "["
)
# find right bracket (final character of expression)
right_lineno, right_col = setup_positions(expr, force_valid=False)
return _Anchors(left_lineno, left_col, right_lineno, right_col)
if isinstance(expr, ast.Call):
# ast gives these locations for function call expressions
# ( func_expr ) (args, kwargs)
# func^^^^^
# call^^^^^^^^^^^^^^^^^^^^^^^^
# find left bracket
left_lineno, left_col = setup_positions(expr.func)
left_lineno, left_col = increment_until(
left_lineno, left_col, lambda x: x == "("
)
# find right bracket (final character of expression)
right_lineno, right_col = setup_positions(expr, force_valid=False)
return _Anchors(left_lineno, left_col, right_lineno, right_col)
return None
_MAX_CANDIDATE_ITEMS = 750
_MAX_STRING_SIZE = 40
_MOVE_COST = 2
_CASE_COST = 1
def _substitution_cost(ch_a, ch_b):
if ch_a == ch_b:
return 0
if ch_a.lower() == ch_b.lower():
return _CASE_COST
return _MOVE_COST
def _compute_suggestion_error(exc_value, tb, wrong_name):
if wrong_name is None or not isinstance(wrong_name, str):
return None
if isinstance(exc_value, AttributeError):
obj = exc_value.obj
try:
try:
d = dir(obj)
except TypeError: # Attributes are unsortable, e.g. int and str
d = list(obj.__class__.__dict__.keys()) + list(obj.__dict__.keys())
d = sorted([x for x in d if isinstance(x, str)])
hide_underscored = wrong_name[:1] != "_"
if hide_underscored and tb is not None:
while tb.tb_next is not None:
tb = tb.tb_next
frame = tb.tb_frame
if "self" in frame.f_locals and frame.f_locals["self"] is obj:
hide_underscored = False
if hide_underscored:
d = [x for x in d if x[:1] != "_"]
except Exception:
return None
elif isinstance(exc_value, ImportError):
try:
mod = __import__(exc_value.name)
try:
d = dir(mod)
except TypeError: # Attributes are unsortable, e.g. int and str
d = list(mod.__dict__.keys())
d = sorted([x for x in d if isinstance(x, str)])
if wrong_name[:1] != "_":
d = [x for x in d if x[:1] != "_"]
except Exception:
return None
else:
assert isinstance(exc_value, NameError)
# find most recent frame
if tb is None:
return None
while tb.tb_next is not None:
tb = tb.tb_next
frame = tb.tb_frame
d = list(frame.f_locals) + list(frame.f_globals) + list(frame.f_builtins)
d = [x for x in d if isinstance(x, str)]
# Check first if we are in a method and the instance
# has the wrong name as attribute
if "self" in frame.f_locals:
self = frame.f_locals["self"]
try:
has_wrong_name = hasattr(self, wrong_name)
except Exception:
has_wrong_name = False
if has_wrong_name:
return f"self.{wrong_name}"
try:
import _suggestions # type: ignore[import]
except ImportError:
pass
else:
return _suggestions._generate_suggestions(d, wrong_name)
# Compute closest match
if len(d) > _MAX_CANDIDATE_ITEMS:
return None
wrong_name_len = len(wrong_name)
if wrong_name_len > _MAX_STRING_SIZE:
return None
best_distance = wrong_name_len
suggestion = None
for possible_name in d:
if possible_name == wrong_name:
# A missing attribute is "found". Don't suggest it (see GH-88821).
continue
# No more than 1/3 of the involved characters should need changed.
max_distance = (len(possible_name) + wrong_name_len + 3) * _MOVE_COST // 6
# Don't take matches we've already beaten.
max_distance = min(max_distance, best_distance - 1)
current_distance = _levenshtein_distance(
wrong_name, possible_name, max_distance
)
if current_distance > max_distance:
continue
if not suggestion or current_distance < best_distance:
suggestion = possible_name
best_distance = current_distance
return suggestion
def _levenshtein_distance(a, b, max_cost):
# A Python implementation of Python/suggestions.c:levenshtein_distance.
# Both strings are the same
if a == b:
return 0
# Trim away common affixes
pre = 0
while a[pre:] and b[pre:] and a[pre] == b[pre]:
pre += 1
a = a[pre:]
b = b[pre:]
post = 0
while a[: post or None] and b[: post or None] and a[post - 1] == b[post - 1]:
post -= 1
a = a[: post or None]
b = b[: post or None]
if not a or not b:
return _MOVE_COST * (len(a) + len(b))
if len(a) > _MAX_STRING_SIZE or len(b) > _MAX_STRING_SIZE:
return max_cost + 1
# Prefer shorter buffer
if len(b) < len(a):
a, b = b, a
# Quick fail when a match is impossible
if (len(b) - len(a)) * _MOVE_COST > max_cost:
return max_cost + 1
# Instead of producing the whole traditional len(a)-by-len(b)
# matrix, we can update just one row in place.
# Initialize the buffer row
row = list(range(_MOVE_COST, _MOVE_COST * (len(a) + 1), _MOVE_COST))
result = 0
for bindex in range(len(b)):
bchar = b[bindex]
distance = result = bindex * _MOVE_COST
minimum = sys.maxsize
for index in range(len(a)):
# 1) Previous distance in this row is cost(b[:b_index], a[:index])
substitute = distance + _substitution_cost(bchar, a[index])
# 2) cost(b[:b_index], a[:index+1]) from previous row
distance = row[index]
# 3) existing result is cost(b[:b_index+1], a[index])
insert_delete = min(result, distance) + _MOVE_COST
result = min(insert_delete, substitute)
# cost(b[:b_index+1], a[:index+1])
row[index] = result
if result < minimum:
minimum = result
if minimum > max_cost:
# Everything in this row is too big, so bail early.
return max_cost + 1
return result
File diff suppressed because it is too large Load Diff