SXXXXXXX_ProfileAnalyzer/profileanalyzer/gui/gui.py
2025-06-23 15:31:29 +02:00

483 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
import subprocess
import json
from shlex import split as shlex_split
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
from profileanalyzer.gui.settings_window import SettingsWindow
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)
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)
self.canvas.xview_moveto(0)
self.canvas.yview_moveto(0)
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):
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():
self.canvas.config(width=self.interior.winfo_reqwidth())
def _configure_canvas(self, event):
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
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)
self._create_widgets()
self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
def _create_widgets(self):
top_frame = ttk.Frame(self)
top_frame.pack(fill=tk.X, pady=(0, 10))
top_frame.columnconfigure(6, 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)
ttk.Button(top_frame, text="Settings", command=self._open_settings_window).grid(row=0, column=4, padx=5)
loaded_file_label = ttk.Label(top_frame, text="Current Profile:")
loaded_file_label.grid(row=0, column=5, 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=6, sticky="ew", padx=5)
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 _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)
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)
self.tree.bind("<Button-3>", self._show_context_menu)
col_map = {
"ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120),
"tottime_percent": ("% Total Time", 100),
"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"
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.tag_configure('heat_critical', background='#ffcdd2')
self.tree.tag_configure('heat_high', background='#ffecb3')
self.tree.tag_configure('heat_medium', background='#e8f5e9')
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
item_id = selection[0]
selected_data = self.filtered_stats_data[int(item_id)]
func_info = selected_data[6]
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 _clear_all_views(self):
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):
self._cleanup_temp_files()
self.master.destroy()
def _cleanup_temp_files(self):
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}")
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("")
self._clear_all_views()
self._update_stats_display()
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):
compare_win = CompareWindow(self, self.analyzer)
compare_win.focus_set()
def _open_settings_window(self):
settings_win = SettingsWindow(self)
settings_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, column_id)
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):
filter_term = self.filter_var.get().lower()
if filter_term:
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
self.tree.delete(*self.tree.get_children())
for i, row in enumerate(self.filtered_stats_data):
ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str, _, _ = row
tags = ()
if percentage > 10.0: tags = ('heat_critical',)
elif percentage > 2.0: tags = ('heat_high',)
elif percentage > 0.5: tags = ('heat_medium',)
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", iid=i, values=formatted_row, tags=tags)
self._update_text_view()
def _update_text_view(self):
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:
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):
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)
writer.writerow(["ncalls", "tottime", "percentage_tottime", "percall_tottime", "cumtime", "percall_cumtime", "function_details", "filepath", "line_number"])
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}")
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()
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)
self.graph_canvas.delete("all")
self.graph_canvas.create_image(0, 0, anchor=tk.NW, image=self.graph_photo_image)
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 _show_context_menu(self, event):
item_id = self.tree.identify_row(event.y)
if not item_id: return
self.tree.selection_set(item_id)
selected_data = self.filtered_stats_data[int(item_id)]
filepath = selected_data[7]
context_menu = tk.Menu(self.tree, tearoff=0)
state = "normal" if filepath and not filepath.startswith('~') and os.path.exists(filepath) else "disabled"
context_menu.add_command(label="Open in Editor", command=self._open_in_editor, state=state)
context_menu.tk_popup(event.x_root, event.y_root)
def _open_in_editor(self):
selection = self.tree.selection()
if not selection: return
item_id = selection[0]
selected_data = self.filtered_stats_data[int(item_id)]
filepath, line_number = selected_data[7], selected_data[8]
try:
with open(SettingsWindow.CONFIG_FILE, 'r', encoding='utf-8') as f:
config = json.load(f)
editor_command = config.get("editor_command", 'code -g "{file}:{line}"')
except (IOError, json.JSONDecodeError):
editor_command = 'code -g "{file}:{line}"'
command_to_run = editor_command.format(file=filepath, line=line_number)
print(f"Executing: {command_to_run}")
try:
subprocess.Popen(shlex_split(command_to_run))
except FileNotFoundError:
messagebox.showerror("Error", f"Could not execute command. Make sure the editor '{shlex_split(command_to_run)[0]}' is in your system's PATH.")
except Exception as e:
messagebox.showerror("Error", f"Failed to open editor: {e}")