306 lines
14 KiB
Python
306 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.loaded_filepath_var = tk.StringVar(value="No profile loaded.")
|
|
self.sort_by_var = tk.StringVar(value=list(self.SORT_OPTIONS.keys())[0])
|
|
|
|
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 layout with PanedWindow
|
|
main_pane = ttk.PanedWindow(self, orient=tk.VERTICAL)
|
|
main_pane.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Top pane for the main stats view
|
|
stats_view_pane = ttk.Frame(main_pane)
|
|
main_pane.add(stats_view_pane, weight=3)
|
|
self._create_main_stats_view(stats_view_pane)
|
|
|
|
# Bottom pane for callers and callees
|
|
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 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)
|
|
|
|
bottom_frame = ttk.Frame(parent_frame)
|
|
bottom_frame.grid(row=1, column=0, sticky="ew", pady=(5,0))
|
|
|
|
ttk.Label(bottom_frame, text="Sort by:").pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
self.sort_combo = ttk.Combobox(bottom_frame, textvariable=self.sort_by_var, values=list(self.SORT_OPTIONS.keys()), state="readonly")
|
|
self.sort_combo.pack(side=tk.LEFT)
|
|
self.sort_combo.bind("<<ComboboxSelected>>", self._on_sort_change)
|
|
|
|
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):
|
|
# ... (metodo esistente, nessuna modifica necessaria)
|
|
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) # Bind selection event
|
|
|
|
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):
|
|
# ... (metodo esistente, nessuna modifica necessaria)
|
|
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])
|
|
# The function info is the last value in the list of values
|
|
func_info = selected_item['values'][-1]
|
|
|
|
# Populate callers
|
|
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))
|
|
|
|
# Populate callees
|
|
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))
|
|
|
|
# --- Tutti gli altri metodi da _load_profile in poi rimangono quasi identici,
|
|
# con l'aggiunta di _clear_views che ora pulisce anche i nuovi treeview ---
|
|
|
|
def _clear_views(self):
|
|
"""Clears all data views."""
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
|
|
if hasattr(self, 'text_view'):
|
|
self.text_view.config(state=tk.NORMAL)
|
|
self.text_view.delete("1.0", tk.END)
|
|
self.text_view.config(state=tk.DISABLED)
|
|
|
|
if hasattr(self, 'callers_tree'):
|
|
self.callers_tree.delete(*self.callers_tree.get_children())
|
|
|
|
if hasattr(self, 'callees_tree'):
|
|
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(filepath)
|
|
self._update_stats_display()
|
|
else:
|
|
self.loaded_filepath_var.set("Failed to load profile.")
|
|
self._clear_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. See the console output for details.")
|
|
|
|
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):
|
|
self._clear_views()
|
|
sort_key = self.SORT_OPTIONS.get(self.sort_by_var.get())
|
|
if not sort_key or not self.analyzer.stats: return
|
|
self.current_stats_data = self.analyzer.get_stats(sort_by=sort_key, limit=200)
|
|
for row in self.current_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)
|
|
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.current_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.current_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):
|
|
if not self.current_stats_data:
|
|
messagebox.showwarning("No Data", "No profile 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.current_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}") |