Skip to content

Commit c160d8d

Browse files
committed
Implement color diffing
1 parent 1864e17 commit c160d8d

4 files changed

Lines changed: 319 additions & 7 deletions

File tree

Lib/profiling/sampling/live_collector/collector.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
)
4343
from .display import CursesDisplay
4444
from .widgets import HeaderWidget, TableWidget, FooterWidget, HelpWidget
45+
from .trend_tracker import TrendTracker
4546

4647

4748
@dataclass
@@ -179,6 +180,9 @@ def __init__(
179180
# Color mode
180181
self._can_colorize = _colorize.can_colorize()
181182

183+
# Trend tracking (initialized after colors are set up)
184+
self._trend_tracker = None
185+
182186
def _get_or_create_thread_data(self, thread_id):
183187
"""Get or create ThreadData for a thread ID."""
184188
if thread_id not in self.per_thread_data:
@@ -405,6 +409,10 @@ def _prepare_display_data(self, height):
405409
def _initialize_widgets(self, colors):
406410
"""Initialize widgets with display and colors."""
407411
if self._header_widget is None:
412+
# Initialize trend tracker with colors
413+
if self._trend_tracker is None:
414+
self._trend_tracker = TrendTracker(colors, enabled=True)
415+
408416
self._header_widget = HeaderWidget(self.display, colors, self)
409417
self._table_widget = TableWidget(self.display, colors, self)
410418
self._footer_widget = FooterWidget(self.display, colors, self)
@@ -569,6 +577,10 @@ def _setup_colors(self):
569577
"color_samples": self.display.get_color_pair(1),
570578
"color_file": self.display.get_color_pair(2),
571579
"color_func": self.display.get_color_pair(3),
580+
# Trend colors (stock-like indicators)
581+
"trend_up": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD,
582+
"trend_down": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD,
583+
"trend_stable": A_NORMAL,
572584
}
573585

574586
# Fallback to non-color attributes
@@ -584,6 +596,10 @@ def _setup_colors(self):
584596
"color_samples": A_NORMAL,
585597
"color_file": A_NORMAL,
586598
"color_func": A_NORMAL,
599+
# Trend colors (fallback to bold/normal for monochrome)
600+
"trend_up": A_BOLD,
601+
"trend_down": A_BOLD,
602+
"trend_stable": A_NORMAL,
587603
}
588604

589605
def _build_stats_list(self):
@@ -614,13 +630,32 @@ def _build_stats_list(self):
614630
total_time = direct_calls * self.sample_interval_sec
615631
cumulative_time = cumulative_calls * self.sample_interval_sec
616632

633+
# Calculate sample percentages
634+
sample_pct = (direct_calls / self.total_samples * 100) if self.total_samples > 0 else 0
635+
cumul_pct = (cumulative_calls / self.total_samples * 100) if self.total_samples > 0 else 0
636+
637+
# Calculate trends for all columns using TrendTracker
638+
trends = {}
639+
if self._trend_tracker is not None:
640+
trends = self._trend_tracker.update_metrics(
641+
func,
642+
{
643+
'nsamples': direct_calls,
644+
'tottime': total_time,
645+
'cumtime': cumulative_time,
646+
'sample_pct': sample_pct,
647+
'cumul_pct': cumul_pct,
648+
}
649+
)
650+
617651
stats_list.append(
618652
{
619653
"func": func,
620654
"direct_calls": direct_calls,
621655
"cumulative_calls": cumulative_calls,
622656
"total_time": total_time,
623657
"cumulative_time": cumulative_time,
658+
"trends": trends, # Dictionary of trends for all columns
624659
}
625660
)
626661

@@ -669,6 +704,9 @@ def reset_stats(self):
669704
"total": 0,
670705
}
671706
self._gc_frame_samples = 0
707+
# Clear trend tracking
708+
if self._trend_tracker is not None:
709+
self._trend_tracker.clear()
672710
self.start_time = time.perf_counter()
673711
self._last_display_update = self.start_time
674712

@@ -850,6 +888,11 @@ def _handle_input(self):
850888
else:
851889
self.view_mode = "ALL"
852890

891+
elif ch == ord("x") or ch == ord("X"):
892+
# Toggle trend colors on/off
893+
if self._trend_tracker is not None:
894+
self._trend_tracker.toggle()
895+
853896
elif ch == curses.KEY_LEFT or ch == curses.KEY_UP:
854897
# Navigate to previous thread in PER_THREAD mode
855898
if self.view_mode == "PER_THREAD" and len(self.thread_ids) > 0:
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""TrendTracker - Encapsulated trend tracking for live profiling metrics.
2+
3+
This module provides trend tracking functionality for profiling metrics,
4+
calculating direction indicators (up/down/stable) and managing associated
5+
visual attributes like colors.
6+
"""
7+
8+
import curses
9+
from typing import Dict, Literal, Any
10+
11+
TrendDirection = Literal["up", "down", "stable"]
12+
13+
14+
class TrendTracker:
15+
"""
16+
Tracks metric trends over time and provides visual indicators.
17+
18+
This class encapsulates all logic for:
19+
- Tracking previous values of metrics
20+
- Calculating trend directions (up/down/stable)
21+
- Determining visual attributes (colors) for trends
22+
- Managing enable/disable state
23+
24+
Example:
25+
tracker = TrendTracker(colors_dict)
26+
tracker.update("func1", "nsamples", 10)
27+
trend = tracker.get_trend("func1", "nsamples")
28+
color = tracker.get_color("func1", "nsamples")
29+
"""
30+
31+
# Threshold for determining if a value has changed significantly
32+
CHANGE_THRESHOLD = 0.001
33+
34+
def __init__(self, colors: Dict[str, int], enabled: bool = True):
35+
"""
36+
Initialize the trend tracker.
37+
38+
Args:
39+
colors: Dictionary containing color attributes including
40+
'trend_up', 'trend_down', 'trend_stable'
41+
enabled: Whether trend tracking is initially enabled
42+
"""
43+
self._previous_values: Dict[Any, Dict[str, float]] = {}
44+
self._enabled = enabled
45+
self._colors = colors
46+
47+
@property
48+
def enabled(self) -> bool:
49+
"""Whether trend tracking is enabled."""
50+
return self._enabled
51+
52+
def toggle(self) -> bool:
53+
"""
54+
Toggle trend tracking on/off.
55+
56+
Returns:
57+
New enabled state
58+
"""
59+
self._enabled = not self._enabled
60+
return self._enabled
61+
62+
def set_enabled(self, enabled: bool) -> None:
63+
"""Set trend tracking enabled state."""
64+
self._enabled = enabled
65+
66+
def update(self, key: Any, metric: str, value: float) -> TrendDirection:
67+
"""
68+
Update a metric value and calculate its trend.
69+
70+
Args:
71+
key: Identifier for the entity (e.g., function)
72+
metric: Name of the metric (e.g., 'nsamples', 'tottime')
73+
value: Current value of the metric
74+
75+
Returns:
76+
Trend direction: 'up', 'down', or 'stable'
77+
"""
78+
# Initialize storage for this key if needed
79+
if key not in self._previous_values:
80+
self._previous_values[key] = {}
81+
82+
# Get previous value, defaulting to current if not tracked yet
83+
prev_value = self._previous_values[key].get(metric, value)
84+
85+
# Calculate trend
86+
if value > prev_value + self.CHANGE_THRESHOLD:
87+
trend = "up"
88+
elif value < prev_value - self.CHANGE_THRESHOLD:
89+
trend = "down"
90+
else:
91+
trend = "stable"
92+
93+
# Update previous value for next iteration
94+
self._previous_values[key][metric] = value
95+
96+
return trend
97+
98+
def get_trend(self, key: Any, metric: str) -> TrendDirection:
99+
"""
100+
Get the current trend for a metric without updating.
101+
102+
Args:
103+
key: Identifier for the entity
104+
metric: Name of the metric
105+
106+
Returns:
107+
Trend direction, or 'stable' if not tracked
108+
"""
109+
# This would require storing trends separately, which we don't do
110+
# For now, return stable if not found
111+
return "stable"
112+
113+
def get_color(self, trend: TrendDirection) -> int:
114+
"""
115+
Get the color attribute for a trend direction.
116+
117+
Args:
118+
trend: The trend direction
119+
120+
Returns:
121+
Curses color attribute (or A_NORMAL if disabled)
122+
"""
123+
if not self._enabled:
124+
return curses.A_NORMAL
125+
126+
if trend == "up":
127+
return self._colors.get("trend_up", curses.A_BOLD)
128+
elif trend == "down":
129+
return self._colors.get("trend_down", curses.A_BOLD)
130+
else: # stable
131+
return self._colors.get("trend_stable", curses.A_NORMAL)
132+
133+
def update_metrics(self, key: Any, metrics: Dict[str, float]) -> Dict[str, TrendDirection]:
134+
"""
135+
Update multiple metrics at once and get their trends.
136+
137+
Args:
138+
key: Identifier for the entity
139+
metrics: Dictionary of metric_name -> value
140+
141+
Returns:
142+
Dictionary of metric_name -> trend_direction
143+
"""
144+
trends = {}
145+
for metric, value in metrics.items():
146+
trends[metric] = self.update(key, metric, value)
147+
return trends
148+
149+
def clear(self) -> None:
150+
"""Clear all tracked values (useful on stats reset)."""
151+
self._previous_values.clear()
152+
153+
def __repr__(self) -> str:
154+
"""String representation for debugging."""
155+
status = "enabled" if self._enabled else "disabled"
156+
tracked = len(self._previous_values)
157+
return f"TrendTracker({status}, tracking {tracked} entities)"

Lib/profiling/sampling/live_collector/widgets.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,9 @@ def draw_stats_rows(self, line, height, width, stats_list, column_flags):
727727
color_file = self.colors.get("color_file", curses.A_NORMAL)
728728
color_func = self.colors.get("color_func", curses.A_NORMAL)
729729

730+
# Get trend tracker for color decisions
731+
trend_tracker = self.collector._trend_tracker
732+
730733
for stat in stats_list:
731734
if line >= height - FOOTER_LINES:
732735
break
@@ -736,6 +739,7 @@ def draw_stats_rows(self, line, height, width, stats_list, column_flags):
736739
cumulative_calls = stat["cumulative_calls"]
737740
total_time = stat["total_time"]
738741
cumulative_time = stat["cumulative_time"]
742+
trends = stat.get("trends", {})
739743

740744
sample_pct = (
741745
(direct_calls / self.collector.total_samples * 100)
@@ -748,32 +752,44 @@ def draw_stats_rows(self, line, height, width, stats_list, column_flags):
748752
else 0
749753
)
750754

755+
# Helper function to get trend color for a specific column
756+
def get_trend_color(column_name):
757+
trend = trends.get(column_name, "stable")
758+
if trend_tracker is not None:
759+
return trend_tracker.get_color(trend)
760+
return curses.A_NORMAL
761+
751762
filename, lineno, funcname = func[0], func[1], func[2]
752763
samples_str = f"{direct_calls}/{cumulative_calls}"
753764
col = 0
754765

755-
# Samples column
756-
self.add_str(line, col, f"{samples_str:>13}", color_samples)
766+
# Samples column - apply trend color based on nsamples trend
767+
nsamples_color = get_trend_color("nsamples")
768+
self.add_str(line, col, f"{samples_str:>13}", nsamples_color)
757769
col += 15
758770

759771
# Sample % column
760772
if show_sample_pct:
761-
self.add_str(line, col, f"{sample_pct:>5.1f}")
773+
sample_pct_color = get_trend_color("sample_pct")
774+
self.add_str(line, col, f"{sample_pct:>5.1f}", sample_pct_color)
762775
col += 7
763776

764777
# Total time column
765778
if show_tottime:
766-
self.add_str(line, col, f"{total_time:>10.3f}")
779+
tottime_color = get_trend_color("tottime")
780+
self.add_str(line, col, f"{total_time:>10.3f}", tottime_color)
767781
col += 12
768782

769783
# Cumul % column
770784
if show_cumul_pct:
771-
self.add_str(line, col, f"{cum_pct:>5.1f}")
785+
cumul_pct_color = get_trend_color("cumul_pct")
786+
self.add_str(line, col, f"{cum_pct:>5.1f}", cumul_pct_color)
772787
col += 7
773788

774789
# Cumul time column
775790
if show_cumtime:
776-
self.add_str(line, col, f"{cumulative_time:>10.3f}")
791+
cumtime_color = get_trend_color("cumtime")
792+
self.add_str(line, col, f"{cumulative_time:>10.3f}", cumtime_color)
777793
col += 12
778794

779795
# Function name column
@@ -861,12 +877,15 @@ def render(self, line, width, **kwargs):
861877
status.append(
862878
f"[Filter: {self.collector.filter_pattern} (c to clear)]"
863879
)
880+
# Show trend colors status if disabled
881+
if self.collector._trend_tracker is not None and not self.collector._trend_tracker.enabled:
882+
status.append("[Trend colors: OFF]")
864883
status_str = " ".join(status) + " " if status else ""
865884

866885
if self.collector.finished:
867886
footer = f"{status_str}"
868887
else:
869-
footer = f"{status_str}Sort: {sort_display} | 't':mode ←→:thread 'h':help 'q':quit"
888+
footer = f"{status_str}Sort: {sort_display} | 't':mode 'x':trends ←→:thread 'h':help 'q':quit"
870889
self.add_str(
871890
line,
872891
0,
@@ -914,6 +933,7 @@ def render(self, line, width, **kwargs):
914933
(" s - Cycle through sort modes (forward)", A_NORMAL),
915934
(" S - Cycle through sort modes (backward)", A_NORMAL),
916935
(" t - Toggle view mode (ALL / per-thread)", A_NORMAL),
936+
(" x - Toggle trend colors (on/off)", A_NORMAL),
917937
(" ← → ↑ ↓ - Navigate threads (in per-thread mode)", A_NORMAL),
918938
(" + - Faster display refresh rate", A_NORMAL),
919939
(" - - Slower display refresh rate", A_NORMAL),

0 commit comments

Comments
 (0)