add heat map and compare window
This commit is contained in:
parent
b0936b3c26
commit
5bc081508a
4
app_config.json
Normal file
4
app_config.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"last_base_profile": "C:/src/____GitProjects/ProfileAnalyzer/execution_profiles/20250623_142502_test_overlay.prof",
|
||||||
|
"last_comp_profile": "C:/src/____GitProjects/ProfileAnalyzer/execution_profiles/20250623_143417_test_overlay.prof"
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional, Tuple, Dict
|
from typing import List, Optional, Tuple, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pstats import Stats
|
from pstats import Stats
|
||||||
import tempfile
|
import tempfile
|
||||||
@ -18,29 +18,21 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
GRAPHVIZ_AVAILABLE = False
|
GRAPHVIZ_AVAILABLE = False
|
||||||
|
|
||||||
|
# --- Utility Function (unchanged) ---
|
||||||
# --- New Utility Function ---
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def is_graphviz_installed() -> bool:
|
def is_graphviz_installed() -> bool:
|
||||||
"""
|
|
||||||
Checks if the Graphviz executable is installed and in the system's PATH.
|
|
||||||
Returns True if available, False otherwise. Caches the result.
|
|
||||||
"""
|
|
||||||
if not GRAPHVIZ_AVAILABLE:
|
if not GRAPHVIZ_AVAILABLE:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
# This command is lightweight and fails if 'dot' is not found.
|
|
||||||
graphviz.Digraph().pipe()
|
graphviz.Digraph().pipe()
|
||||||
return True
|
return True
|
||||||
except graphviz.backend.ExecutableNotFound:
|
except graphviz.backend.ExecutableNotFound:
|
||||||
print("INFO: Graphviz executable not found. Graph generation will be disabled.")
|
print("INFO: Graphviz executable not found. Graph generation will be disabled.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# --- Dataclass (unchanged) ---
|
# --- Dataclass (unchanged) ---
|
||||||
@dataclass
|
@dataclass
|
||||||
class LaunchProfile:
|
class LaunchProfile:
|
||||||
# ... (il codice rimane identico)
|
|
||||||
name: str
|
name: str
|
||||||
run_as_module: bool = False
|
run_as_module: bool = False
|
||||||
target_path: str = ""
|
target_path: str = ""
|
||||||
@ -50,30 +42,88 @@ class LaunchProfile:
|
|||||||
|
|
||||||
# --- ProfileAnalyzer Class (Updated) ---
|
# --- ProfileAnalyzer Class (Updated) ---
|
||||||
class ProfileAnalyzer:
|
class ProfileAnalyzer:
|
||||||
# ... (tutta la classe ProfileAnalyzer, incluso generate_call_graph, rimane identica alla versione precedente)
|
|
||||||
"""
|
"""
|
||||||
Handles loading a profile data file and extracting statistics
|
Handles loading, analyzing, and comparing profile data.
|
||||||
by directly accessing the pstats data structures.
|
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.stats: Optional[Stats] = None
|
self.stats: Optional[Stats] = None
|
||||||
self.profile_path: Optional[str] = None
|
self.profile_path: Optional[str] = None
|
||||||
self._func_info_map: Optional[Dict[str, Tuple]] = None
|
self._func_info_map: Optional[Dict[str, Tuple]] = None
|
||||||
|
|
||||||
def load_profile(self, filepath: str) -> bool:
|
def load_profile(self, filepath: str) -> Optional[Stats]:
|
||||||
|
"""
|
||||||
|
Loads a profile file and returns the Stats object.
|
||||||
|
If this instance is used for single analysis, it also sets self.stats.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.stats = pstats.Stats(filepath)
|
stats_obj = pstats.Stats(filepath)
|
||||||
self.stats.strip_dirs()
|
stats_obj.strip_dirs()
|
||||||
|
|
||||||
|
# For single analysis mode, update instance state
|
||||||
|
self.stats = stats_obj
|
||||||
self.profile_path = filepath
|
self.profile_path = filepath
|
||||||
self._func_info_map = None
|
self._func_info_map = None # Invalidate cache
|
||||||
return True
|
|
||||||
|
return stats_obj
|
||||||
except (FileNotFoundError, TypeError, OSError) as e:
|
except (FileNotFoundError, TypeError, OSError) as e:
|
||||||
print(f"Error loading profile file '{filepath}': {e}")
|
print(f"Error loading profile file '{filepath}': {e}")
|
||||||
self.stats = None
|
self.stats = None
|
||||||
self.profile_path = None
|
self.profile_path = None
|
||||||
self._func_info_map = None
|
self._func_info_map = None
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
def compare_stats(self, stats_base: Stats, stats_comparison: Stats) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Compares two pstats.Stats objects and returns the differences.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stats_base: The baseline stats object.
|
||||||
|
stats_comparison: The comparison stats object to check against the baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of dictionaries, where each dictionary contains stats
|
||||||
|
for a function from the comparison profile plus the deltas.
|
||||||
|
"""
|
||||||
|
base = stats_base.stats
|
||||||
|
comp = stats_comparison.stats
|
||||||
|
|
||||||
|
# Get a set of all function keys from both profiles
|
||||||
|
all_funcs = set(base.keys()) | set(comp.keys())
|
||||||
|
|
||||||
|
comparison_results = []
|
||||||
|
|
||||||
|
for func_tuple in all_funcs:
|
||||||
|
# Get stats, defaulting to zero-tuple if function not in profile
|
||||||
|
base_stats = base.get(func_tuple, (0, 0, 0, 0, {}))
|
||||||
|
comp_stats = comp.get(func_tuple, (0, 0, 0, 0, {}))
|
||||||
|
|
||||||
|
# Unpack stats for comparison profile
|
||||||
|
cc, nc, tt, ct, _ = comp_stats
|
||||||
|
|
||||||
|
# Calculate deltas
|
||||||
|
delta_nc = nc - base_stats[1]
|
||||||
|
delta_tt = tt - base_stats[2]
|
||||||
|
delta_ct = ct - base_stats[3]
|
||||||
|
|
||||||
|
# Calculate per-call stats for the comparison profile
|
||||||
|
percall_tottime = tt / nc if nc > 0 else 0
|
||||||
|
percall_cumtime = ct / nc if nc > 0 else 0
|
||||||
|
|
||||||
|
comparison_results.append({
|
||||||
|
'func_str': pstats.func_std_string(func_tuple),
|
||||||
|
'ncalls': nc,
|
||||||
|
'tottime': tt,
|
||||||
|
'percall_tottime': percall_tottime,
|
||||||
|
'cumtime': ct,
|
||||||
|
'percall_cumtime': percall_cumtime,
|
||||||
|
'delta_ncalls': delta_nc,
|
||||||
|
'delta_tottime': delta_tt,
|
||||||
|
'delta_cumtime': delta_ct
|
||||||
|
})
|
||||||
|
|
||||||
|
return comparison_results
|
||||||
|
|
||||||
|
# --- All other methods remain the same ---
|
||||||
def _get_func_info_map(self) -> Dict[str, Tuple]:
|
def _get_func_info_map(self) -> Dict[str, Tuple]:
|
||||||
if self._func_info_map is None and self.stats:
|
if self._func_info_map is None and self.stats:
|
||||||
self._func_info_map = {
|
self._func_info_map = {
|
||||||
@ -86,32 +136,59 @@ class ProfileAnalyzer:
|
|||||||
return self._get_func_info_map().get(func_info_str)
|
return self._get_func_info_map().get(func_info_str)
|
||||||
|
|
||||||
def get_stats(self, sort_by: str, limit: int = 500) -> List[Tuple]:
|
def get_stats(self, sort_by: str, limit: int = 500) -> List[Tuple]:
|
||||||
|
"""
|
||||||
|
Extracts statistics, now including the percentage of total time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of tuples in the format:
|
||||||
|
(ncalls, tottime, percentage_tottime, percall_tottime, cumtime, percall_cumtime, function_details_str)
|
||||||
|
"""
|
||||||
if not self.stats:
|
if not self.stats:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Map frontend sort keys to internal pstats keys or custom keys
|
||||||
sort_map = {
|
sort_map = {
|
||||||
"cumulative": 3, "tottime": 2, "ncalls": 1, "filename": 4
|
"cumulative": "cumtime",
|
||||||
|
"tottime": "tottime",
|
||||||
|
"ncalls": "ncalls",
|
||||||
|
"filename": "func_str"
|
||||||
}
|
}
|
||||||
sort_key_index = sort_map.get(sort_by, 3)
|
sort_key = sort_map.get(sort_by, "cumtime")
|
||||||
|
|
||||||
all_stats = []
|
all_stats = []
|
||||||
|
total_tt = self.stats.total_tt or 1 # Avoid division by zero
|
||||||
|
|
||||||
for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items():
|
for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items():
|
||||||
func_str = pstats.func_std_string(func_tuple)
|
|
||||||
percall_tottime = tt / nc if nc > 0 else 0
|
percall_tottime = tt / nc if nc > 0 else 0
|
||||||
percall_cumtime = ct / nc if nc > 0 else 0
|
percall_cumtime = ct / nc if nc > 0 else 0
|
||||||
|
percentage_tottime = (tt / total_tt) * 100
|
||||||
|
|
||||||
all_stats.append({
|
all_stats.append({
|
||||||
'func_str': func_str, 'func_tuple': func_tuple,
|
'func_str': pstats.func_std_string(func_tuple),
|
||||||
'raw_stats': (cc, nc, tt, ct, percall_tottime, percall_cumtime)
|
'ncalls': nc,
|
||||||
|
'tottime': tt,
|
||||||
|
'cumtime': ct,
|
||||||
|
'percall_tottime': percall_tottime,
|
||||||
|
'percall_cumtime': percall_cumtime,
|
||||||
|
'percentage_tottime': percentage_tottime
|
||||||
})
|
})
|
||||||
|
|
||||||
if sort_by == 'filename':
|
# Perform sorting based on the dictionary keys
|
||||||
all_stats.sort(key=lambda x: x['func_str'])
|
all_stats.sort(key=lambda x: x[sort_key], reverse=(sort_key != 'func_str'))
|
||||||
else:
|
|
||||||
all_stats.sort(key=lambda x: x['raw_stats'][sort_key_index], reverse=True)
|
|
||||||
|
|
||||||
|
# Format final output list
|
||||||
results = []
|
results = []
|
||||||
for stat_item in all_stats[:limit]:
|
for stat_item in all_stats[:limit]:
|
||||||
s = stat_item['raw_stats']
|
results.append((
|
||||||
results.append((s[1], s[2], s[4], s[3], s[5], stat_item['func_str']))
|
stat_item['ncalls'],
|
||||||
|
stat_item['tottime'],
|
||||||
|
stat_item['percentage_tottime'],
|
||||||
|
stat_item['percall_tottime'],
|
||||||
|
stat_item['cumtime'],
|
||||||
|
stat_item['percall_cumtime'],
|
||||||
|
stat_item['func_str']
|
||||||
|
))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_callers(self, func_info: str) -> List[Tuple[str, str]]:
|
def get_callers(self, func_info: str) -> List[Tuple[str, str]]:
|
||||||
@ -145,7 +222,7 @@ class ProfileAnalyzer:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
def _get_color_for_time(self, time: float, max_time: float) -> str:
|
def _get_color_for_time(self, time: float, max_time: float) -> str:
|
||||||
if max_time == 0: return "#90EE90"
|
if max_time == 0: return "#90EE90"
|
||||||
ratio = min(time / max_time, 1.0)
|
ratio = min(time / max_time, 1.0)
|
||||||
red = int(255 * min(ratio * 2, 1.0))
|
red = int(255 * min(ratio * 2, 1.0))
|
||||||
green = int(255 * min((1 - ratio) * 2, 1.0))
|
green = int(255 * min((1 - ratio) * 2, 1.0))
|
||||||
@ -155,28 +232,21 @@ class ProfileAnalyzer:
|
|||||||
if not self.stats or not self.stats.stats or not is_graphviz_installed(): return None
|
if not self.stats or not self.stats.stats or not is_graphviz_installed(): return None
|
||||||
total_time = self.stats.total_tt
|
total_time = self.stats.total_tt
|
||||||
if total_time == 0: return None
|
if total_time == 0: return None
|
||||||
|
|
||||||
max_tottime = max(s[2] for s in self.stats.stats.values()) if self.stats.stats else 0
|
max_tottime = max(s[2] for s in self.stats.stats.values()) if self.stats.stats else 0
|
||||||
graph = graphviz.Digraph(comment='Call Graph',
|
graph = graphviz.Digraph(comment='Call Graph', graph_attr={'rankdir': 'LR', 'splines': 'true', 'overlap': 'false'}, node_attr={'shape': 'box', 'style': 'filled'})
|
||||||
graph_attr={'rankdir': 'LR', 'splines': 'true', 'overlap': 'false'},
|
|
||||||
node_attr={'shape': 'box', 'style': 'filled'})
|
|
||||||
|
|
||||||
nodes_in_graph = set()
|
nodes_in_graph = set()
|
||||||
for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items():
|
for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items():
|
||||||
if tt < total_time * threshold:
|
if tt < total_time * threshold: continue
|
||||||
continue
|
|
||||||
func_str = pstats.func_std_string(func_tuple)
|
func_str = pstats.func_std_string(func_tuple)
|
||||||
nodes_in_graph.add(func_tuple)
|
nodes_in_graph.add(func_tuple)
|
||||||
node_label = f"{func_str}\n(tottime: {tt:.4f}s)"
|
node_label = f"{func_str}\n(tottime: {tt:.4f}s)"
|
||||||
node_color = self._get_color_for_time(tt, max_tottime)
|
node_color = self._get_color_for_time(tt, max_tottime)
|
||||||
graph.node(name=func_str, label=node_label, fillcolor=node_color)
|
graph.node(name=func_str, label=node_label, fillcolor=node_color)
|
||||||
|
|
||||||
for caller_tuple, call_stats in callers.items():
|
for caller_tuple, call_stats in callers.items():
|
||||||
caller_tottime = self.stats.stats.get(caller_tuple, (0,0,0,0))[2]
|
caller_tottime = self.stats.stats.get(caller_tuple, (0,0,0,0))[2]
|
||||||
if caller_tottime >= total_time * threshold:
|
if caller_tottime >= total_time * threshold:
|
||||||
caller_str = pstats.func_std_string(caller_tuple)
|
caller_str = pstats.func_std_string(caller_tuple)
|
||||||
graph.edge(caller_str, func_str, label=f"{call_stats[1]} calls")
|
graph.edge(caller_str, func_str, label=f"{call_stats[1]} calls")
|
||||||
|
|
||||||
if not nodes_in_graph:
|
if not nodes_in_graph:
|
||||||
print("No significant functions to graph based on the threshold.")
|
print("No significant functions to graph based on the threshold.")
|
||||||
return None
|
return None
|
||||||
@ -190,16 +260,14 @@ class ProfileAnalyzer:
|
|||||||
|
|
||||||
# --- run_and_profile_script function (unchanged) ---
|
# --- run_and_profile_script function (unchanged) ---
|
||||||
def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
|
def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
|
||||||
# ... (il codice rimane identico)
|
# ... (code for this function remains identical)
|
||||||
output_dir = os.path.join(os.getcwd(), "execution_profiles")
|
output_dir = os.path.join(os.getcwd(), "execution_profiles")
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
profile_name_sanitized = profile.name.replace(' ', '_').replace('.', '_')
|
profile_name_sanitized = profile.name.replace(' ', '_').replace('.', '_')
|
||||||
profile_output_path = os.path.abspath(os.path.join(output_dir, f"{timestamp}_{profile_name_sanitized}.prof"))
|
profile_output_path = os.path.abspath(os.path.join(output_dir, f"{timestamp}_{profile_name_sanitized}.prof"))
|
||||||
|
|
||||||
command = [profile.python_interpreter, "-m", "cProfile", "-o", profile_output_path]
|
command = [profile.python_interpreter, "-m", "cProfile", "-o", profile_output_path]
|
||||||
working_directory = None
|
working_directory = None
|
||||||
|
|
||||||
if profile.run_as_module:
|
if profile.run_as_module:
|
||||||
if not profile.module_name:
|
if not profile.module_name:
|
||||||
print("Error: Module name is required.")
|
print("Error: Module name is required.")
|
||||||
@ -207,7 +275,6 @@ def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
|
|||||||
if not profile.target_path or not os.path.isdir(profile.target_path):
|
if not profile.target_path or not os.path.isdir(profile.target_path):
|
||||||
print(f"Error: Project Root Folder is not a valid directory: {profile.target_path}")
|
print(f"Error: Project Root Folder is not a valid directory: {profile.target_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
command.extend(["-m", profile.module_name])
|
command.extend(["-m", profile.module_name])
|
||||||
working_directory = profile.target_path
|
working_directory = profile.target_path
|
||||||
else:
|
else:
|
||||||
@ -216,36 +283,22 @@ def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
command.append(profile.target_path)
|
command.append(profile.target_path)
|
||||||
working_directory = os.path.dirname(profile.target_path)
|
working_directory = os.path.dirname(profile.target_path)
|
||||||
|
|
||||||
if profile.script_args:
|
if profile.script_args:
|
||||||
command.extend(profile.script_args.split())
|
command.extend(profile.script_args.split())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Executing profiling command: {' '.join(command)}")
|
print(f"Executing profiling command: {' '.join(command)}")
|
||||||
print(f"Working Directory for subprocess: {working_directory}")
|
print(f"Working Directory for subprocess: {working_directory}")
|
||||||
|
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=working_directory)
|
||||||
process = subprocess.Popen(
|
|
||||||
command,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
cwd=working_directory
|
|
||||||
)
|
|
||||||
stdout, stderr = process.communicate()
|
stdout, stderr = process.communicate()
|
||||||
|
|
||||||
if stdout: print(f"--- STDOUT ---\n{stdout}")
|
if stdout: print(f"--- STDOUT ---\n{stdout}")
|
||||||
if stderr: print(f"--- STDERR ---\n{stderr}")
|
if stderr: print(f"--- STDERR ---\n{stderr}")
|
||||||
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
print(f"Warning: Profiled script exited with non-zero status: {process.returncode}")
|
print(f"Warning: Profiled script exited with non-zero status: {process.returncode}")
|
||||||
|
|
||||||
if not os.path.exists(profile_output_path) or os.path.getsize(profile_output_path) == 0:
|
if not os.path.exists(profile_output_path) or os.path.getsize(profile_output_path) == 0:
|
||||||
print("Error: Profiling failed and no output file was generated.")
|
print("Error: Profiling failed and no output file was generated.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
print(f"Profiling data saved to: {profile_output_path}")
|
print(f"Profiling data saved to: {profile_output_path}")
|
||||||
return profile_output_path
|
return profile_output_path
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to run and profile script: {e}")
|
print(f"Failed to run and profile script: {e}")
|
||||||
return None
|
return None
|
||||||
183
profileanalyzer/gui/compare_window.py
Normal file
183
profileanalyzer/gui/compare_window.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
# profileAnalyzer/gui/compare_window.py
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, filedialog, messagebox
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pstats import Stats
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from ..core.core import ProfileAnalyzer
|
||||||
|
|
||||||
|
class CompareWindow(tk.Toplevel):
|
||||||
|
"""A Toplevel window for comparing two profile data files."""
|
||||||
|
|
||||||
|
CONFIG_FILE = "app_config.json"
|
||||||
|
SIGNIFICANT_CHANGE_THRESHOLD = 0.000001 # 1 microsecond
|
||||||
|
|
||||||
|
def __init__(self, master, analyzer: ProfileAnalyzer):
|
||||||
|
super().__init__(master)
|
||||||
|
self.analyzer = analyzer
|
||||||
|
|
||||||
|
self.base_stats: Optional[Stats] = None
|
||||||
|
self.comp_stats: Optional[Stats] = None
|
||||||
|
|
||||||
|
self._init_window()
|
||||||
|
self._init_vars()
|
||||||
|
self._load_config()
|
||||||
|
self._create_widgets()
|
||||||
|
self._configure_treeview_tags()
|
||||||
|
|
||||||
|
def _init_window(self):
|
||||||
|
self.title("Compare Profile Data")
|
||||||
|
self.geometry("1200x700")
|
||||||
|
self.transient(self.master)
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
def _init_vars(self):
|
||||||
|
self.base_path_var = tk.StringVar()
|
||||||
|
self.comp_path_var = tk.StringVar()
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.CONFIG_FILE):
|
||||||
|
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
self.base_path_var.set(config.get("last_base_profile", ""))
|
||||||
|
self.comp_path_var.set(config.get("last_comp_profile", ""))
|
||||||
|
except (IOError, json.JSONDecodeError) as e:
|
||||||
|
print(f"Could not load config file: {e}")
|
||||||
|
|
||||||
|
def _save_config(self):
|
||||||
|
config = {
|
||||||
|
"last_base_profile": self.base_path_var.get(),
|
||||||
|
"last_comp_profile": self.comp_path_var.get()
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(config, f, indent=4)
|
||||||
|
except IOError as e:
|
||||||
|
print(f"Could not save config file: {e}")
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
main_frame = ttk.Frame(self, padding="10")
|
||||||
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
main_frame.rowconfigure(1, weight=1)
|
||||||
|
main_frame.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# --- Top frame for file selection ---
|
||||||
|
selection_frame = ttk.LabelFrame(main_frame, text="Select Profiles", padding="10")
|
||||||
|
selection_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10))
|
||||||
|
selection_frame.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# Baseline profile
|
||||||
|
ttk.Label(selection_frame, text="Baseline Profile:").grid(row=0, column=0, sticky="w", padx=5, pady=2)
|
||||||
|
ttk.Entry(selection_frame, textvariable=self.base_path_var).grid(row=0, column=1, sticky="ew", padx=5)
|
||||||
|
ttk.Button(selection_frame, text="...", width=3, command=lambda: self._browse_file(self.base_path_var)).grid(row=0, column=2)
|
||||||
|
|
||||||
|
# Comparison profile
|
||||||
|
ttk.Label(selection_frame, text="Comparison Profile:").grid(row=1, column=0, sticky="w", padx=5, pady=2)
|
||||||
|
ttk.Entry(selection_frame, textvariable=self.comp_path_var).grid(row=1, column=1, sticky="ew", padx=5)
|
||||||
|
ttk.Button(selection_frame, text="...", width=3, command=lambda: self._browse_file(self.comp_path_var)).grid(row=1, column=2)
|
||||||
|
|
||||||
|
ttk.Button(selection_frame, text="Compare", command=self._run_comparison).grid(row=2, column=1, columnspan=2, sticky="e", pady=10)
|
||||||
|
|
||||||
|
# --- Results frame ---
|
||||||
|
results_frame = ttk.LabelFrame(main_frame, text="Comparison Results", padding="10")
|
||||||
|
results_frame.grid(row=1, column=0, sticky="nsew")
|
||||||
|
results_frame.rowconfigure(0, weight=1)
|
||||||
|
results_frame.columnconfigure(0, weight=1)
|
||||||
|
self._create_results_tree(results_frame)
|
||||||
|
|
||||||
|
def _browse_file(self, path_var: tk.StringVar):
|
||||||
|
current_path = path_var.get()
|
||||||
|
initial_dir = os.path.dirname(current_path) if current_path and os.path.exists(os.path.dirname(current_path)) else os.getcwd()
|
||||||
|
|
||||||
|
filepath = filedialog.askopenfilename(
|
||||||
|
title="Select a .prof file",
|
||||||
|
filetypes=[("Profile files", "*.prof"), ("All files", "*.*")],
|
||||||
|
initialdir=initial_dir
|
||||||
|
)
|
||||||
|
if filepath:
|
||||||
|
path_var.set(filepath)
|
||||||
|
|
||||||
|
def _create_results_tree(self, parent):
|
||||||
|
columns = ("func", "ncalls", "d_ncalls", "tottime", "d_tottime", "cumtime", "d_cumtime")
|
||||||
|
self.tree = ttk.Treeview(parent, columns=columns, show="headings")
|
||||||
|
|
||||||
|
col_map = {
|
||||||
|
"func": ("Function", 400), "ncalls": ("N-Calls", 80), "d_ncalls": ("Δ N-Calls", 80),
|
||||||
|
"tottime": ("Total Time (s)", 110), "d_tottime": ("Δ Total Time", 110),
|
||||||
|
"cumtime": ("Cum. Time (s)", 110), "d_cumtime": ("Δ Cum. Time", 110)
|
||||||
|
}
|
||||||
|
for col_id, (text, width) in col_map.items():
|
||||||
|
# --- INIZIO DELLA CORREZIONE ---
|
||||||
|
sort_key = ""
|
||||||
|
if col_id == "func":
|
||||||
|
sort_key = "func_str" # Usa la chiave corretta per il nome della funzione
|
||||||
|
else:
|
||||||
|
sort_key = col_id.replace('d_', 'delta_')
|
||||||
|
# --- FINE DELLA CORREZIONE ---
|
||||||
|
|
||||||
|
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=sort_key: self._sort_tree(c))
|
||||||
|
self.tree.column(col_id, width=width, stretch=(col_id == "func"), anchor="w" if col_id == "func" else "e")
|
||||||
|
|
||||||
|
vsb = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
|
||||||
|
hsb = ttk.Scrollbar(parent, orient="horizontal", command=self.tree.xview)
|
||||||
|
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||||
|
|
||||||
|
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||||
|
vsb.grid(row=0, column=1, sticky="ns")
|
||||||
|
hsb.grid(row=1, column=0, sticky="ew")
|
||||||
|
|
||||||
|
def _configure_treeview_tags(self):
|
||||||
|
self.tree.tag_configure('better', background='#c8e6c9') # Light Green
|
||||||
|
self.tree.tag_configure('worse', background='#ffcdd2') # Light Red
|
||||||
|
|
||||||
|
def _run_comparison(self):
|
||||||
|
base_path = self.base_path_var.get()
|
||||||
|
comp_path = self.comp_path_var.get()
|
||||||
|
|
||||||
|
if not base_path or not comp_path:
|
||||||
|
messagebox.showerror("Error", "Please select both a baseline and a comparison profile.", parent=self)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use a separate analyzer instance to avoid state conflicts
|
||||||
|
loader = ProfileAnalyzer()
|
||||||
|
base_stats = loader.load_profile(base_path)
|
||||||
|
comp_stats = loader.load_profile(comp_path)
|
||||||
|
|
||||||
|
if not base_stats or not comp_stats:
|
||||||
|
messagebox.showerror("Error", "One or both profile files could not be loaded. Check console for details.", parent=self)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.master.config(cursor="watch")
|
||||||
|
self.comparison_data = self.analyzer.compare_stats(base_stats, comp_stats)
|
||||||
|
self._populate_tree()
|
||||||
|
self.master.config(cursor="")
|
||||||
|
self._save_config()
|
||||||
|
|
||||||
|
def _populate_tree(self):
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
|
||||||
|
for row in self.comparison_data:
|
||||||
|
d_tt = row['delta_tottime']
|
||||||
|
tags = ()
|
||||||
|
if d_tt > self.SIGNIFICANT_CHANGE_THRESHOLD:
|
||||||
|
tags = ('worse',)
|
||||||
|
elif d_tt < -self.SIGNIFICANT_CHANGE_THRESHOLD:
|
||||||
|
tags = ('better',)
|
||||||
|
|
||||||
|
formatted_row = (
|
||||||
|
row['func_str'],
|
||||||
|
row['ncalls'], f"{row['delta_ncalls']:+d}",
|
||||||
|
f"{row['tottime']:.6f}", f"{d_tt:+.6f}",
|
||||||
|
f"{row['cumtime']:.6f}", f"{row['delta_cumtime']:+.6f}"
|
||||||
|
)
|
||||||
|
self.tree.insert("", "end", values=formatted_row, tags=tags)
|
||||||
|
|
||||||
|
def _sort_tree(self, col: str):
|
||||||
|
# Sort internal data
|
||||||
|
self.comparison_data.sort(key=lambda x: x[col], reverse=True)
|
||||||
|
# Repopulate tree with sorted data
|
||||||
|
self._populate_tree()
|
||||||
@ -17,6 +17,7 @@ from profileanalyzer.core.core import (
|
|||||||
)
|
)
|
||||||
from profileanalyzer.core.profile_manager import LaunchProfileManager
|
from profileanalyzer.core.profile_manager import LaunchProfileManager
|
||||||
from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow
|
from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow
|
||||||
|
from profileanalyzer.gui.compare_window import CompareWindow
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
@ -114,7 +115,8 @@ class ProfileAnalyzerGUI(tk.Frame):
|
|||||||
top_frame.columnconfigure(3, weight=1)
|
top_frame.columnconfigure(3, weight=1)
|
||||||
ttk.Button(top_frame, text="Load Profile File...", command=self._load_profile).grid(row=0, column=0, padx=(0, 5))
|
ttk.Button(top_frame, text="Load Profile File...", command=self._load_profile).grid(row=0, column=0, padx=(0, 5))
|
||||||
ttk.Button(top_frame, text="Profile a Script...", command=self._open_launch_manager).grid(row=0, column=1, padx=5)
|
ttk.Button(top_frame, text="Profile a Script...", command=self._open_launch_manager).grid(row=0, column=1, padx=5)
|
||||||
ttk.Button(top_frame, text="Export to CSV...", command=self._export_to_csv).grid(row=0, column=2, padx=5)
|
ttk.Button(top_frame, text="Compare Profiles...", command=self._open_compare_window).grid(row=0, column=2, padx=5)
|
||||||
|
ttk.Button(top_frame, text="Export to CSV...", command=self._export_to_csv).grid(row=0, column=3, padx=5)
|
||||||
loaded_file_label = ttk.Label(top_frame, text="Current Profile:")
|
loaded_file_label = ttk.Label(top_frame, text="Current Profile:")
|
||||||
loaded_file_label.grid(row=0, column=3, sticky="w", padx=(10, 0))
|
loaded_file_label.grid(row=0, column=3, sticky="w", padx=(10, 0))
|
||||||
loaded_file_display = ttk.Label(top_frame, textvariable=self.loaded_filepath_var, anchor="w", relief="sunken")
|
loaded_file_display = ttk.Label(top_frame, textvariable=self.loaded_filepath_var, anchor="w", relief="sunken")
|
||||||
@ -283,18 +285,30 @@ class ProfileAnalyzerGUI(tk.Frame):
|
|||||||
def _create_table_tab(self, parent_frame):
|
def _create_table_tab(self, parent_frame):
|
||||||
parent_frame.rowconfigure(0, weight=1)
|
parent_frame.rowconfigure(0, weight=1)
|
||||||
parent_frame.columnconfigure(0, weight=1)
|
parent_frame.columnconfigure(0, weight=1)
|
||||||
columns = ("ncalls", "tottime", "percall_tottime", "cumtime", "percall_cumtime", "function")
|
|
||||||
|
# Define columns, including the new percentage column
|
||||||
|
columns = ("ncalls", "tottime", "tottime_percent", "percall_tottime", "cumtime", "percall_cumtime", "function")
|
||||||
self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings")
|
self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings")
|
||||||
self.tree.bind("<<TreeviewSelect>>", self._on_function_select)
|
self.tree.bind("<<TreeviewSelect>>", self._on_function_select)
|
||||||
|
|
||||||
|
# Define column properties
|
||||||
col_map = {
|
col_map = {
|
||||||
"ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120),
|
"ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120),
|
||||||
|
"tottime_percent": ("% Total Time", 100), # New column
|
||||||
"percall_tottime": ("Per Call (s)", 100), "cumtime": ("Cum. Time (s)", 120),
|
"percall_tottime": ("Per Call (s)", 100), "cumtime": ("Cum. Time (s)", 120),
|
||||||
"percall_cumtime": ("Per Call (s)", 100), "function": ("Function", 500)
|
"percall_cumtime": ("Per Call (s)", 100), "function": ("Function", 500)
|
||||||
}
|
}
|
||||||
for col_id, (text, width) in col_map.items():
|
for col_id, (text, width) in col_map.items():
|
||||||
anchor = "w" if col_id == "function" else "e"
|
anchor = "w" if col_id == "function" else "e"
|
||||||
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=col_id: self._on_header_click(c))
|
sort_key = col_id if col_id != "tottime_percent" else "tottime" # Sort by tottime value
|
||||||
|
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=sort_key: self._on_header_click(c))
|
||||||
self.tree.column(col_id, width=width, stretch=(col_id == "function"), anchor=anchor)
|
self.tree.column(col_id, width=width, stretch=(col_id == "function"), anchor=anchor)
|
||||||
|
|
||||||
|
# Configure tags for heat-mapping
|
||||||
|
self.tree.tag_configure('heat_critical', background='#ffcdd2') # Red
|
||||||
|
self.tree.tag_configure('heat_high', background='#ffecb3') # Yellow
|
||||||
|
self.tree.tag_configure('heat_medium', background='#e8f5e9') # Green
|
||||||
|
|
||||||
vsb = ttk.Scrollbar(parent_frame, orient="vertical", command=self.tree.yview)
|
vsb = ttk.Scrollbar(parent_frame, orient="vertical", command=self.tree.yview)
|
||||||
hsb = ttk.Scrollbar(parent_frame, orient="horizontal", command=self.tree.xview)
|
hsb = ttk.Scrollbar(parent_frame, orient="horizontal", command=self.tree.xview)
|
||||||
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||||
@ -353,6 +367,11 @@ class ProfileAnalyzerGUI(tk.Frame):
|
|||||||
self.wait_window(manager_window)
|
self.wait_window(manager_window)
|
||||||
if manager_window.selected_profile_to_run:
|
if manager_window.selected_profile_to_run:
|
||||||
self._start_profiling_thread(manager_window.selected_profile_to_run)
|
self._start_profiling_thread(manager_window.selected_profile_to_run)
|
||||||
|
|
||||||
|
def _open_compare_window(self):
|
||||||
|
"""Opens the dedicated window for profile comparison."""
|
||||||
|
compare_win = CompareWindow(self, self.analyzer)
|
||||||
|
compare_win.focus_set()
|
||||||
|
|
||||||
def _start_profiling_thread(self, profile: LaunchProfile):
|
def _start_profiling_thread(self, profile: LaunchProfile):
|
||||||
self.master.config(cursor="watch")
|
self.master.config(cursor="watch")
|
||||||
@ -393,49 +412,88 @@ class ProfileAnalyzerGUI(tk.Frame):
|
|||||||
self._apply_filter_and_refresh_views()
|
self._apply_filter_and_refresh_views()
|
||||||
|
|
||||||
def _apply_filter_and_refresh_views(self, *args):
|
def _apply_filter_and_refresh_views(self, *args):
|
||||||
|
"""Filters data, refreshes Treeview with heat-mapping, and refreshes Text view."""
|
||||||
filter_term = self.filter_var.get().lower()
|
filter_term = self.filter_var.get().lower()
|
||||||
|
|
||||||
if filter_term:
|
if filter_term:
|
||||||
|
# The last element (index 6) is the function string
|
||||||
self.filtered_stats_data = [
|
self.filtered_stats_data = [
|
||||||
row for row in self.current_stats_data
|
row for row in self.current_stats_data
|
||||||
if filter_term in row[5].lower()
|
if filter_term in row[6].lower()
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
self.filtered_stats_data = self.current_stats_data
|
self.filtered_stats_data = self.current_stats_data
|
||||||
|
|
||||||
|
# Refresh Treeview
|
||||||
self.tree.delete(*self.tree.get_children())
|
self.tree.delete(*self.tree.get_children())
|
||||||
for row in self.filtered_stats_data:
|
for row in self.filtered_stats_data:
|
||||||
formatted_row = (row[0], f"{row[1]:.6f}", f"{row[2]:.6f}", f"{row[3]:.6f}", f"{row[4]:.6f}", row[5])
|
# Unpack data including the new percentage
|
||||||
self.tree.insert("", "end", values=formatted_row)
|
ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str = row
|
||||||
|
|
||||||
|
# Determine tag for heat-mapping
|
||||||
|
tags = ()
|
||||||
|
if percentage > 10.0:
|
||||||
|
tags = ('heat_critical',)
|
||||||
|
elif percentage > 2.0:
|
||||||
|
tags = ('heat_high',)
|
||||||
|
elif percentage > 0.5:
|
||||||
|
tags = ('heat_medium',)
|
||||||
|
|
||||||
|
# Format row for display
|
||||||
|
formatted_row = (
|
||||||
|
ncalls,
|
||||||
|
f"{tottime:.6f}",
|
||||||
|
f"{percentage:.2f}%",
|
||||||
|
f"{percall_tottime:.6f}",
|
||||||
|
f"{cumtime:.6f}",
|
||||||
|
f"{percall_cumtime:.6f}",
|
||||||
|
func_str
|
||||||
|
)
|
||||||
|
self.tree.insert("", "end", values=formatted_row, tags=tags)
|
||||||
|
|
||||||
|
# Refresh Text view
|
||||||
self._update_text_view()
|
self._update_text_view()
|
||||||
|
|
||||||
def _update_text_view(self):
|
def _update_text_view(self):
|
||||||
|
"""Updates the text view with the currently filtered and sorted data."""
|
||||||
self.text_view.config(state=tk.NORMAL)
|
self.text_view.config(state=tk.NORMAL)
|
||||||
self.text_view.delete("1.0", tk.END)
|
self.text_view.delete("1.0", tk.END)
|
||||||
if not self.filtered_stats_data:
|
if not self.filtered_stats_data:
|
||||||
self.text_view.config(state=tk.DISABLED)
|
self.text_view.config(state=tk.DISABLED)
|
||||||
return
|
return
|
||||||
header = f"{'N-Calls':>12s} {'Total Time':>15s} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function'}\n"
|
|
||||||
separator = f"{'-'*12} {'-'*15} {'-'*12} {'-'*15} {'-'*12} {'-'*40}\n"
|
header = f"{'N-Calls':>12s} {'Total Time':>15s} {'% Time':>8s} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function'}\n"
|
||||||
|
separator = f"{'-'*12} {'-'*15} {'-'*8} {'-'*12} {'-'*15} {'-'*12} {'-'*40}\n"
|
||||||
self.text_view.insert(tk.END, header)
|
self.text_view.insert(tk.END, header)
|
||||||
self.text_view.insert(tk.END, separator)
|
self.text_view.insert(tk.END, separator)
|
||||||
|
|
||||||
for row in self.filtered_stats_data:
|
for row in self.filtered_stats_data:
|
||||||
line = f"{str(row[0]):>12s} {row[1]:15.6f} {row[2]:12.6f} {row[3]:15.6f} {row[4]:12.6f} {row[5]}\n"
|
# Unpack the full tuple
|
||||||
|
ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str = row
|
||||||
|
line = f"{str(ncalls):>12s} {tottime:15.6f} {percentage:7.2f}% {percall_tottime:12.6f} {cumtime:15.6f} {percall_cumtime:12.6f} {func_str}\n"
|
||||||
self.text_view.insert(tk.END, line)
|
self.text_view.insert(tk.END, line)
|
||||||
|
|
||||||
self.text_view.config(state=tk.DISABLED)
|
self.text_view.config(state=tk.DISABLED)
|
||||||
|
|
||||||
def _export_to_csv(self):
|
def _export_to_csv(self):
|
||||||
|
"""Exports the currently visible (filtered) data to a CSV file."""
|
||||||
if not self.filtered_stats_data:
|
if not self.filtered_stats_data:
|
||||||
messagebox.showwarning("No Data", "No data to export.")
|
messagebox.showwarning("No Data", "No data to export.")
|
||||||
return
|
return
|
||||||
filepath = filedialog.asksaveasfilename(
|
filepath = filedialog.asksaveasfilename(
|
||||||
defaultextension=".csv",
|
defaultextension=".csv",
|
||||||
filetypes=[("CSV files", "*.csv"), ("All files", "*,*")],
|
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||||||
title="Save Profile Stats as CSV"
|
title="Save Profile Stats as CSV"
|
||||||
)
|
)
|
||||||
if not filepath: return
|
if not filepath: return
|
||||||
try:
|
try:
|
||||||
with open(filepath, 'w', newline='', encoding='utf-8') as f:
|
with open(filepath, 'w', newline='', encoding='utf-8') as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
writer.writerow(["ncalls", "tottime", "percall_tottime", "cumtime", "percall_cumtime", "function_details"])
|
# Update header for CSV export
|
||||||
|
writer.writerow([
|
||||||
|
"ncalls", "tottime", "percentage_tottime",
|
||||||
|
"percall_tottime", "cumtime", "percall_cumtime", "function_details"
|
||||||
|
])
|
||||||
writer.writerows(self.filtered_stats_data)
|
writer.writerows(self.filtered_stats_data)
|
||||||
messagebox.showinfo("Export Successful", f"Stats successfully exported to:\n{filepath}")
|
messagebox.showinfo("Export Successful", f"Stats successfully exported to:\n{filepath}")
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
|
|||||||
66
todo.md
66
todo.md
@ -1,59 +1,27 @@
|
|||||||
Certamente. Ora che abbiamo una base solida, possiamo pensare a funzionalità più avanzate che trasformerebbero questo semplice visualizzatore in un potente strumento di analisi, utile anche per il progetto principale.
|
### 1. Miglioramenti dell'Analisi e della User Experience (Alto Impatto)
|
||||||
|
|
||||||
Ecco alcune idee, dalle più semplici alle più complesse, che potremmo implementare:
|
Queste modifiche non aggiungono dati nuovi, ma rendono quelli esistenti molto più facili da interpretare.
|
||||||
|
|
||||||
### 1. Visualizzazione Grafica dei "Callers" e "Callees"
|
|
||||||
|
|
||||||
**Idea**: Quando l'utente seleziona una riga nel `Treeview`, potremmo mostrare in due pannelli separati:
|
* **Percentuali e Heat-Mapping nella Tabella:**
|
||||||
* **Callers**: Quali funzioni hanno chiamato la funzione selezionata e quante volte.
|
* **Perché è utile:** I numeri assoluti (es. `0.015s`) sono meno intuitivi di quelli relativi. Sapere che una funzione occupa il `45%` del tempo totale è molto più eloquente.
|
||||||
* **Callees**: Quali funzioni sono state chiamate dalla funzione selezionata.
|
* **Come potremmo farlo:** Aggiungere una colonna "% Tempo Totale" alla tabella principale. Inoltre, potremmo applicare alla tabella lo stesso principio di "heat-mapping" del grafo: colorare lo sfondo delle righe da verde a rosso in base al loro impatto sul tempo totale. L'occhio verrebbe immediatamente attirato dalle righe più "calde".
|
||||||
|
|
||||||
**Perché è utile**: Questa è la funzionalità più potente di un profiler. Ti permette di "navigare" nel grafo delle chiamate per capire non solo *quale* funzione è lenta, ma *perché* viene chiamata così spesso o da quale percorso critico. Aiuta a rispondere a domande come: "Questa funzione lenta è chiamata da una sola parte del codice o da molte? Qual è il suo impatto a cascata?".
|
### 2. Integrazione nel Flusso di Lavoro dello Sviluppatore
|
||||||
|
|
||||||
**Come implementarlo**:
|
Queste modifiche collegano l'analizzatore agli altri strumenti che usi quotidianamente.
|
||||||
1. Modificheremo `core.py`. La classe `pstats.Stats` ha già i metodi `print_callers()` e `print_callees()`. Possiamo creare due nuovi metodi in `ProfileAnalyzer` (es. `get_callers_for_func()` e `get_callees_for_func()`) che catturano e parsano l'output di questi comandi per una funzione specifica.
|
|
||||||
2. Modificheremo `gui.py` per aggiungere due nuovi `Treeview` (o `Listbox`) in un'area inferiore della finestra, che verranno popolati quando l'utente clicca su una riga della tabella principale.
|
|
||||||
|
|
||||||
### 2. Filtraggio per Nome di Funzione o File
|
* **Integrazione con l'Editor di Codice ("Open in Editor"):**
|
||||||
|
* **Perché è utile:** Hai trovato la funzione lenta. Ora devi aprire il tuo editor, cercare il file e andare alla riga giusta. Possiamo automatizzarlo.
|
||||||
|
* **Come potremmo farlo:** Aggiungere un menu contestuale (tasto destro del mouse) nella tabella delle funzioni. Una delle opzioni sarebbe "Open in Editor". L'applicazione conosce già il percorso del file e il numero di riga della funzione. Basterebbe una piccola finestra di impostazioni dove l'utente può inserire il comando per il suo editor preferito (es. per VS Code: `code -g {file}:{line}`). Questo renderebbe il ciclo "analizza -> correggi" incredibilmente veloce.
|
||||||
|
|
||||||
**Idea**: Aggiungere una casella di testo (un campo di ricerca) che permetta di filtrare la tabella per mostrare solo le funzioni che contengono una certa stringa (es. `_stream_srio_blocks`, `file_reader.py`).
|
* **Migliorare il Launch Profile Manager:**
|
||||||
|
* **Perché è utile:** Al momento l'interprete Python non è configurabile dalla UI. I progetti reali usano quasi sempre ambienti virtuali (`venv`) specifici.
|
||||||
|
* **Come potremmo farlo:** Aggiungere un campo di testo e un pulsante "Sfoglia" nel `LaunchManagerWindow` per permettere di specificare il percorso dell'eseguibile Python per *ciascun profilo*. La `dataclass` `LaunchProfile` lo supporta già, dobbiamo solo esporlo nella UI.
|
||||||
|
|
||||||
**Perché è utile**: Quando i risultati sono centinaia, scorrere la lista è difficile. Un filtro ti permette di concentrarti immediatamente su un modulo o una funzione specifica di cui sospetti, rendendo l'analisi molto più rapida.
|
### 3. Funzionalità Avanzate di Analisi
|
||||||
|
|
||||||
**Come implementarlo**:
|
* **Vista Gerarchica (Flame Graph Style):**
|
||||||
1. In `gui.py`, aggiungeremo un `ttk.Entry` per la ricerca.
|
* **Perché è utile:** Una tabella è piatta. Un Flame Graph (o una vista ad albero) mostra la gerarchia delle chiamate e dove si accumula il tempo all'interno dello stack di chiamate.
|
||||||
2. Collegheremo l'evento di modifica del testo (`<KeyRelease>`) a una funzione di aggiornamento.
|
* **Come potremmo farlo:** Potremmo modificare la `Table View` per usare il `ttk.Treeview` in modalità gerarchica. Ogni riga potrebbe essere espandibile per mostrare le sue `callees`, con i loro tempi ricalcolati in proporzione al chiamante. È una modifica complessa, ma offrirebbe una profondità di analisi eccezionale.
|
||||||
3. Il metodo `_update_stats_display` verrà modificato per passare il termine di ricerca al `ProfileAnalyzer`.
|
|
||||||
4. In `core.py`, il metodo `get_stats` accetterà un nuovo argomento opzionale `filter_term`. Prima di restituire i risultati, filtrerà la lista per includere solo le righe in cui il nome della funzione (`func_info`) contiene il termine di ricerca.
|
|
||||||
|
|
||||||
### 3. "Flame Graph" Semplificato (Avanzato)
|
|
||||||
|
|
||||||
**Idea**: Un Flame Graph è una visualizzazione gerarchica che mostra lo stack di chiamate e quanto tempo viene speso in ogni funzione. Quelli completi sono complessi, ma possiamo crearne una versione testuale semplificata. Quando l'utente clicca su una funzione, potremmo mostrare un albero di testo che rappresenta il suo stack di chiamate e il tempo cumulativo.
|
|
||||||
|
|
||||||
**Perché è utile**: Fornisce una comprensione visiva e immediata di dove il tempo viene "consumato" all'interno di una singola operazione. È lo strumento definitivo per l'analisi delle performance.
|
|
||||||
|
|
||||||
**Come implementarlo**:
|
|
||||||
* Questa è la funzionalità più complessa. Richiede di analizzare in modo ricorsivo le relazioni "caller-callee" fornite da `pstats` per costruire la gerarchia.
|
|
||||||
* Potremmo usare un `ttk.Treeview` per visualizzare il grafo, dove ogni nodo è una funzione e può essere espanso per vedere le sue sotto-chiamate.
|
|
||||||
|
|
||||||
### 4. Confronto tra Due Profili (Avanzato)
|
|
||||||
|
|
||||||
**Idea**: Permettere all'utente di caricare due file `.prof` (es. "prima" e "dopo" un'ottimizzazione) e mostrare una vista comparativa che evidenzi le differenze:
|
|
||||||
* Funzioni che sono diventate più veloci (regressioni positive).
|
|
||||||
* Funzioni che sono diventate più lente (regressioni negative).
|
|
||||||
* Funzioni che sono apparse o scomparse.
|
|
||||||
|
|
||||||
**Perché è utile**: È essenziale per verificare che una modifica abbia effettivamente migliorato le performance e non abbia introdotto nuovi colli di bottiglia.
|
|
||||||
|
|
||||||
**Come implementarlo**:
|
|
||||||
1. La GUI dovrebbe avere due pulsanti "Load Profile A" e "Load Profile B".
|
|
||||||
2. Il `core.py` avrebbe bisogno di una logica per "unire" i dati dei due profili, calcolare i delta percentuali per `tottime` e `cumtime`, e restituire una struttura dati comparativa.
|
|
||||||
3. Il `Treeview` nella GUI mostrerebbe colonne aggiuntive come "Delta Time", "Delta Calls", etc., magari usando colori (verde/rosso) per evidenziare i miglioramenti e i peggioramenti.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**La mia raccomandazione su cosa fare ora:**
|
|
||||||
|
|
||||||
La **funzionalità #1 (Visualizzazione Callers/Callees)** è il passo successivo più logico e utile. È relativamente semplice da implementare usando le capacità intrinseche di `pstats` e aggiunge un valore enorme all'analisi. Ti permette di "scavare" nei dati invece di guardarli solo in superficie.
|
|
||||||
|
|
||||||
Se sei d'accordo, possiamo procedere con l'implementazione di questa funzionalità.
|
|
||||||
Loading…
Reference in New Issue
Block a user