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:
@@ -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
Reference in New Issue
Block a user