SXXXXXXX_ProfileAnalyzer/profileanalyzer/gui/gui.py
2025-06-23 14:25:58 +02:00

315 lines
14 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 List, Tuple, Optional
# Correctly import from sibling directories
from profileanalyzer.core.core import ProfileAnalyzer, LaunchProfile, run_and_profile_script
from profileanalyzer.core.profile_manager import LaunchProfileManager
from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow
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.analyzer = ProfileAnalyzer()
self.profile_manager = LaunchProfileManager("launch_profiles.json")
self.current_stats_data = []
self.filtered_stats_data = []
# --- Tkinter Variables ---
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._create_widgets()
self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
def _create_widgets(self):
"""Create and lay out the main widgets."""
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="Export to CSV...", command=self._export_to_csv).grid(row=0, column=2, 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):
"""Creates the main view with tabs and sorting/filtering controls."""
parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1)
notebook = ttk.Notebook(parent_frame)
notebook.grid(row=0, column=0, sticky="nsew")
table_tab = ttk.Frame(notebook)
text_tab = ttk.Frame(notebook)
notebook.add(table_tab, text="Table View")
notebook.add(text_tab, text="Text View")
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) # Make filter entry expand
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_call_details_view(self, parent_frame):
"""Creates the bottom section for callers and callees."""
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:
"""Helper to create a configured treeview for callers/callees."""
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", "percall_tottime", "cumtime", "percall_cumtime", "function")
self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings")
self.tree.bind("<<TreeviewSelect>>", self._on_function_select)
col_map = {
"ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120),
"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"
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=col_id: self._on_header_click(c))
self.tree.column(col_id, width=width, stretch=(col_id == "function"), anchor=anchor)
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):
"""Called when a function is selected in the main treeview."""
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 _clear_all_views(self):
"""Clears all data from all 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())
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 on new file load
self._update_stats_display()
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 _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):
"""Fetches and sorts the full dataset, then triggers a view refresh."""
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 the current data and refreshes the Treeview and Text view."""
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[5].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:
formatted_row = (row[0], f"{row[1]:.6f}", f"{row[2]:.6f}", f"{row[3]:.6f}", f"{row[4]:.6f}", row[5])
self.tree.insert("", "end", values=formatted_row)
# 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} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function'}\n"
separator = f"{'-'*12} {'-'*15} {'-'*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:
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"
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)
writer.writerow(["ncalls", "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}")