SXXXXXXX_ProfileAnalyzer/profileanalyzer/gui/gui.py
2025-06-23 15:07:01 +02:00

500 lines
22 KiB
Python

# profileAnalyzer/gui/gui.py
"""
GUI for the Profile Analyzer application.
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import csv
import os
import threading
from typing import Optional
# Correctly import from sibling directories
from profileanalyzer.core.core import (
ProfileAnalyzer, LaunchProfile, run_and_profile_script, is_graphviz_installed
)
from profileanalyzer.core.profile_manager import LaunchProfileManager
from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow
from profileanalyzer.gui.compare_window import CompareWindow
try:
from PIL import Image, ImageTk
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
class ScrolledFrame(ttk.Frame):
"""A pure Tkinter scrollable frame that actually works!"""
def __init__(self, parent, *args, **kw):
ttk.Frame.__init__(self, parent, *args, **kw)
# Create a canvas object and a vertical scrollbar for scrolling it.
vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
vscrollbar.pack(fill=tk.Y, side=tk.RIGHT, expand=tk.FALSE)
hscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL)
hscrollbar.pack(fill=tk.X, side=tk.BOTTOM, expand=tk.FALSE)
self.canvas = tk.Canvas(self, bd=0, highlightthickness=0,
yscrollcommand=vscrollbar.set,
xscrollcommand=hscrollbar.set)
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=tk.TRUE)
vscrollbar.config(command=self.canvas.yview)
hscrollbar.config(command=self.canvas.xview)
# Reset the view
self.canvas.xview_moveto(0)
self.canvas.yview_moveto(0)
# Create a frame inside the canvas which will be scrolled with it.
self.interior = ttk.Frame(self.canvas)
self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW)
self.interior.bind('<Configure>', self._configure_interior)
self.canvas.bind('<Configure>', self._configure_canvas)
def _configure_interior(self, event):
# Update the scrollbars to match the size of the inner frame.
size = (self.interior.winfo_reqwidth(), self.interior.winfo_reqheight())
self.canvas.config(scrollregion="0 0 %s %s" % size)
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
# Update the canvas's width to fit the inner frame.
self.canvas.config(width=self.interior.winfo_reqwidth())
def _configure_canvas(self, event):
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
# Update the inner frame's width to fill the canvas.
self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width())
class ProfileAnalyzerGUI(tk.Frame):
"""
The main GUI frame for the profile analyzer application.
"""
SORT_OPTIONS = {
"Total Time (tottime)": "tottime", "Cumulative Time (cumtime)": "cumulative",
"Number of Calls (ncalls)": "ncalls", "Function Name": "filename"
}
COLUMN_SORT_MAP = {
"ncalls": "ncalls", "tottime": "tottime",
"cumtime": "cumulative", "function": "filename"
}
def __init__(self, master: tk.Tk):
super().__init__(master)
self.master = master
self.master.title("Python Profile Analyzer")
self.master.geometry("1400x800")
self.master.protocol("WM_DELETE_WINDOW", self._on_closing)
self.analyzer = ProfileAnalyzer()
self.profile_manager = LaunchProfileManager("launch_profiles.json")
self.current_stats_data = []
self.filtered_stats_data = []
self.graphviz_available = is_graphviz_installed() and PIL_AVAILABLE
self.graph_image_path: Optional[str] = None
self.graph_photo_image: Optional[ImageTk.PhotoImage] = None
self.loaded_filepath_var = tk.StringVar(value="No profile loaded.")
self.sort_by_var = tk.StringVar(value=list(self.SORT_OPTIONS.keys())[0])
self.filter_var = tk.StringVar()
self.filter_var.trace_add("write", self._apply_filter_and_refresh_views)
self.graph_threshold_var = tk.DoubleVar(value=1.0) # Percentage
self._create_widgets()
self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
def _create_widgets(self):
# ... (Top frame remains the same)
top_frame = ttk.Frame(self)
top_frame.pack(fill=tk.X, pady=(0, 10))
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="Profile a Script...", command=self._open_launch_manager).grid(row=0, column=1, 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.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.grid(row=0, column=4, sticky="ew", padx=5)
top_frame.columnconfigure(4, weight=1)
main_pane = ttk.PanedWindow(self, orient=tk.VERTICAL)
main_pane.pack(fill=tk.BOTH, expand=True)
stats_view_pane = ttk.Frame(main_pane)
main_pane.add(stats_view_pane, weight=3)
self._create_main_stats_view(stats_view_pane)
call_details_pane = ttk.Frame(main_pane)
main_pane.add(call_details_pane, weight=1)
self._create_call_details_view(call_details_pane)
def _create_main_stats_view(self, parent_frame):
parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1)
self.notebook = ttk.Notebook(parent_frame)
self.notebook.grid(row=0, column=0, sticky="nsew")
table_tab = ttk.Frame(self.notebook)
text_tab = ttk.Frame(self.notebook)
self.notebook.add(table_tab, text="Table View")
self.notebook.add(text_tab, text="Text View")
if self.graphviz_available:
graph_tab = ttk.Frame(self.notebook)
self.notebook.add(graph_tab, text="Graph View")
self._create_graph_tab(graph_tab)
if not PIL_AVAILABLE:
self.notebook.tab(2, state="disabled")
messagebox.showwarning("Missing Dependency", "Pillow library not found. Graph view is disabled. Please run 'pip install Pillow'.")
self._create_table_tab(table_tab)
self._create_text_tab(text_tab)
controls_frame = ttk.Frame(parent_frame)
controls_frame.grid(row=1, column=0, sticky="ew", pady=(5,0))
controls_frame.columnconfigure(3, weight=1)
ttk.Label(controls_frame, text="Sort by:").grid(row=0, column=0, padx=(0, 5))
self.sort_combo = ttk.Combobox(controls_frame, textvariable=self.sort_by_var, values=list(self.SORT_OPTIONS.keys()), state="readonly")
self.sort_combo.grid(row=0, column=1, padx=(0, 10))
self.sort_combo.bind("<<ComboboxSelected>>", self._on_sort_change)
ttk.Label(controls_frame, text="Filter:").grid(row=0, column=2, padx=(10, 5))
filter_entry = ttk.Entry(controls_frame, textvariable=self.filter_var)
filter_entry.grid(row=0, column=3, sticky="ew")
def _create_graph_tab(self, parent_frame):
parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1)
graph_controls_frame = ttk.Frame(parent_frame, padding=5)
graph_controls_frame.grid(row=1, column=0, sticky="ew")
ttk.Label(graph_controls_frame, text="Node Threshold (%):").pack(side=tk.LEFT)
scale = ttk.Scale(graph_controls_frame, from_=0, to=5, orient=tk.HORIZONTAL, variable=self.graph_threshold_var)
scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
ttk.Button(graph_controls_frame, text="Generate Graph", command=self._generate_and_display_graph).pack(side=tk.LEFT, padx=5)
self.scrolled_canvas_frame = ScrolledFrame(parent_frame)
self.scrolled_canvas_frame.grid(row=0, column=0, sticky="nsew")
self.graph_canvas = self.scrolled_canvas_frame.canvas
def _generate_and_display_graph(self):
if not self.analyzer.stats:
messagebox.showinfo("No Data", "Please load a profile file first.", parent=self)
return
self.master.config(cursor="watch")
threshold_value = self.graph_threshold_var.get() / 100.0
thread = threading.Thread(
target=self._run_graph_generation,
args=(threshold_value,),
daemon=True
)
thread.start()
def _run_graph_generation(self, threshold: float):
image_path = self.analyzer.generate_call_graph(threshold=threshold)
self.master.after(0, self._display_graph_image, image_path)
def _display_graph_image(self, image_path: Optional[str]):
self.master.config(cursor="")
self._cleanup_temp_files() # Clean up old file before creating new one
if not image_path:
messagebox.showerror("Graph Error", "Could not generate call graph. Check console for details.", parent=self)
return
self.graph_image_path = image_path
try:
img = Image.open(self.graph_image_path)
self.graph_photo_image = ImageTk.PhotoImage(img) # Must keep a reference!
self.graph_canvas.delete("all")
self.graph_canvas.create_image(0, 0, anchor=tk.NW, image=self.graph_photo_image)
# Update the scroll region to match the new image size
self.scrolled_canvas_frame.interior.update_idletasks()
self.graph_canvas.config(scrollregion=self.graph_canvas.bbox("all"))
except Exception as e:
messagebox.showerror("Display Error", f"Failed to display graph image:\n{e}", parent=self)
def _clear_all_views(self):
# ... (clear other views)
self.tree.delete(*self.tree.get_children())
self.text_view.config(state=tk.NORMAL)
self.text_view.delete("1.0", tk.END)
self.text_view.config(state=tk.DISABLED)
self.callers_tree.delete(*self.callers_tree.get_children())
self.callees_tree.delete(*self.callees_tree.get_children())
if self.graphviz_available:
self.graph_canvas.delete("all")
self._cleanup_temp_files()
def _on_closing(self):
"""Handle window close event."""
self._cleanup_temp_files()
self.master.destroy()
def _cleanup_temp_files(self):
"""Removes the temporary graph image file if it exists."""
if self.graph_image_path and os.path.exists(self.graph_image_path):
try:
os.remove(self.graph_image_path)
self.graph_image_path = None
except OSError as e:
print(f"Error removing temp file {self.graph_image_path}: {e}")
# --- Other methods remain largely the same, only adding cleanup calls where needed ---
# The following are just stubs for brevity, copy the full methods from previous version
def _create_call_details_view(self, parent_frame):
parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1)
pane = ttk.PanedWindow(parent_frame, orient=tk.HORIZONTAL)
pane.grid(row=0, column=0, sticky="nsew", pady=(5,0))
callers_frame = ttk.LabelFrame(pane, text="Callers (who called the selected function)")
callees_frame = ttk.LabelFrame(pane, text="Callees (what the selected function called)")
pane.add(callers_frame, weight=1)
pane.add(callees_frame, weight=1)
self.callers_tree = self._create_call_tree(callers_frame)
self.callees_tree = self._create_call_tree(callees_frame)
def _create_call_tree(self, parent) -> ttk.Treeview:
parent.rowconfigure(0, weight=1)
parent.columnconfigure(0, weight=1)
tree = ttk.Treeview(parent, columns=("info", "function"), show="headings")
tree.heading("info", text="Call Info")
tree.heading("function", text="Function")
tree.column("info", width=200, stretch=False)
tree.column("function", width=400, stretch=True)
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
return tree
def _create_table_tab(self, parent_frame):
parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1)
# 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.bind("<<TreeviewSelect>>", self._on_function_select)
# Define column properties
col_map = {
"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_cumtime": ("Per Call (s)", 100), "function": ("Function", 500)
}
for col_id, (text, width) in col_map.items():
anchor = "w" if col_id == "function" else "e"
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)
# 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)
hsb = ttk.Scrollbar(parent_frame, 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 _create_text_tab(self, parent_frame):
parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1)
self.text_view = scrolledtext.ScrolledText(parent_frame, wrap=tk.NONE, state=tk.DISABLED, font=("Courier New", 9))
self.text_view.pack(fill=tk.BOTH, expand=True)
def _on_function_select(self, event=None):
selection = self.tree.selection()
if not selection: return
selected_item = self.tree.item(selection[0])
func_info = selected_item['values'][-1]
self.callers_tree.delete(*self.callers_tree.get_children())
callers_data = self.analyzer.get_callers(func_info)
for timing_info, function_info in callers_data:
self.callers_tree.insert("", "end", values=(timing_info, function_info))
self.callees_tree.delete(*self.callees_tree.get_children())
callees_data = self.analyzer.get_callees(func_info)
for timing_info, function_info in callees_data:
self.callees_tree.insert("", "end", values=(timing_info, function_info))
def _load_profile(self, filepath=None):
if not filepath:
filepath = filedialog.askopenfilename(
title="Select a .prof file",
filetypes=[("Profile files", "*.prof"), ("All files", "*.*")]
)
if not filepath:
return
if self.analyzer.load_profile(filepath):
self.loaded_filepath_var.set(os.path.basename(filepath))
self.filter_var.set("") # Reset filter
self._clear_all_views() # Clear previous data from all views
self._update_stats_display() # Populate table/text views
# --- NUOVA RIGA AGGIUNTA ---
# Automatically trigger graph generation if available
if self.graphviz_available:
self._generate_and_display_graph()
# --------------------------
else:
self.loaded_filepath_var.set("Failed to load profile.")
self._clear_all_views()
messagebox.showerror("Error", f"Could not load or parse profile file:\n{filepath}")
def _open_launch_manager(self):
manager_window = LaunchManagerWindow(self, self.profile_manager)
self.wait_window(manager_window)
if 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):
self.master.config(cursor="watch")
self.loaded_filepath_var.set(f"Profiling '{profile.name}' in progress...")
thread = threading.Thread(target=lambda: self._run_profiling_and_callback(profile), daemon=True)
thread.start()
def _run_profiling_and_callback(self, profile: LaunchProfile):
output_path = run_and_profile_script(profile)
self.master.after(0, self._on_profiling_complete, output_path)
def _on_profiling_complete(self, profile_path: Optional[str]):
self.master.config(cursor="")
if profile_path and os.path.exists(profile_path):
self._load_profile(filepath=profile_path)
else:
self.loaded_filepath_var.set("Profiling failed. Check console for errors.")
messagebox.showerror("Profiling Failed", "The script did not generate a valid profile file.")
def _on_header_click(self, column_id):
sort_key = self.COLUMN_SORT_MAP.get(column_id)
if not sort_key: return
for display_name, key in self.SORT_OPTIONS.items():
if key == sort_key:
self.sort_by_var.set(display_name)
break
self._update_stats_display()
def _on_sort_change(self, event=None):
self._update_stats_display()
def _update_stats_display(self):
sort_key = self.SORT_OPTIONS.get(self.sort_by_var.get())
if not sort_key or not self.analyzer.stats:
self.current_stats_data = []
else:
self.current_stats_data = self.analyzer.get_stats(sort_by=sort_key, limit=500)
self._apply_filter_and_refresh_views()
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()
if filter_term:
# The last element (index 6) is the function string
self.filtered_stats_data = [
row for row in self.current_stats_data
if filter_term in row[6].lower()
]
else:
self.filtered_stats_data = self.current_stats_data
# Refresh Treeview
self.tree.delete(*self.tree.get_children())
for row in self.filtered_stats_data:
# Unpack data including the new percentage
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()
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.delete("1.0", tk.END)
if not self.filtered_stats_data:
self.text_view.config(state=tk.DISABLED)
return
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, separator)
for row in self.filtered_stats_data:
# 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.config(state=tk.DISABLED)
def _export_to_csv(self):
"""Exports the currently visible (filtered) data to a CSV file."""
if not self.filtered_stats_data:
messagebox.showwarning("No Data", "No data to export.")
return
filepath = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
title="Save Profile Stats as CSV"
)
if not filepath: return
try:
with open(filepath, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
# Update header for CSV export
writer.writerow([
"ncalls", "tottime", "percentage_tottime",
"percall_tottime", "cumtime", "percall_cumtime", "function_details"
])
writer.writerows(self.filtered_stats_data)
messagebox.showinfo("Export Successful", f"Stats successfully exported to:\n{filepath}")
except IOError as e:
messagebox.showerror("Export Error", f"Failed to save CSV file:\n{e}")