"""Left-pane span tree. Root is the current `Trace`, children are its `root_spans` and their nested `children`.""" from __future__ import annotations from typing import Callable, List, Optional from rich.style import Style from rich.text import Text from textual.widgets import Tree from textual.widgets.tree import TreeNode from deepeval.inspect.types import ( BaseSpan, Trace, TraceOrSpan, duration_ms, format_duration, has_failure, metric_counts, ) from deepeval.inspect.widgets._styling import ( PILL_FAIL, PILL_PASS, type_prefix, ) # Minimum gap (in cells) between the left content (name + metric badge + # optional ERRORED pill) and the right-aligned duration. Below this the # right column gives up trying to right-align and just leaves the # duration adjacent to the badge — better than overlapping in narrow # panes. _MIN_DURATION_GAP = 2 def _node_depth(node: TreeNode) -> int: """Number of ancestors above ``node`` (root = 0). Some Textual releases expose ``TreeNode.depth`` directly, others don't. Walking ``parent`` is O(depth) and stable across versions — trace trees are typically <10 deep so the cost is negligible. """ depth = 0 current = getattr(node, "parent", None) while current is not None: depth += 1 current = getattr(current, "parent", None) return depth def _metric_badge(node: TraceOrSpan) -> Optional[Text]: counts = metric_counts(node.metrics_data) if counts is None: return None passed, failed = counts badge = Text() if passed: badge.append(f" ✓ {passed} ", style=PILL_PASS) if failed: if passed: badge.append(" ") badge.append(f" ✗ {failed} ", style=PILL_FAIL) return badge def _label_for(node: TraceOrSpan) -> Text: """` ?` Duration is intentionally not baked in here — `SpanTree.render_label` appends it right-aligned to the pane width using the per-render viewport size, so we can't know the gap until paint time. """ label = Text() fail = has_failure(node) name_style = "bold red" if fail else "bold" label.append_text(type_prefix(node)) name = node.name or ("trace" if isinstance(node, Trace) else "") label.append(name, style=name_style) badge = _metric_badge(node) if badge is not None: label.append(" ") label.append_text(badge) if not isinstance(node, Trace) and (node.status or "").upper() == "ERRORED": label.append(" ") label.append(" ERRORED ", style=PILL_FAIL) return label SpanFilter = Callable[[BaseSpan], bool] class SpanTree(Tree[TraceOrSpan]): DEFAULT_CSS = """ SpanTree { width: 30%; min-width: 28; max-width: 60; background: $surface; border-right: solid $boost; padding: 0 1; } SpanTree > .tree--cursor { background: $boost; } """ def __init__(self, *args, **kwargs): # `populate(...)` replaces this bootstrap label before first paint. super().__init__("trace", *args, **kwargs) self.show_root = True self.guide_depth = 3 def render_label( self, node: TreeNode[TraceOrSpan], base_style: Style, style: Style, ) -> Text: """Compose `