Untracked files matching the following rules: - Rule "!.vscode/launch.json": 1 file - Rule "*.prof": 6 files
234 lines
10 KiB
Python
234 lines
10 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 threading
|
|
import os
|
|
from typing import 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("1200x700")
|
|
|
|
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)
|
|
|
|
notebook = ttk.Notebook(self)
|
|
notebook.pack(fill=tk.BOTH, expand=True)
|
|
|
|
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(self)
|
|
bottom_frame.pack(fill=tk.X, pady=(10, 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_table_tab(self, parent_frame):
|
|
columns = ("ncalls", "tottime", "percall_tottime", "cumtime", "percall_cumtime", "function")
|
|
self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings")
|
|
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", 400)
|
|
}
|
|
for col_id, (text, width) in col_map.items():
|
|
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="w" if col_id == "function" else "e")
|
|
|
|
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")
|
|
parent_frame.columnconfigure(0, weight=1)
|
|
parent_frame.rowconfigure(0, weight=1)
|
|
|
|
def _create_text_tab(self, parent_frame):
|
|
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 _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):
|
|
"""Opens the Launch Profile Manager window."""
|
|
manager_window = LaunchManagerWindow(self, self.profile_manager)
|
|
self.wait_window(manager_window) # Wait for the manager window to close
|
|
|
|
if manager_window.selected_profile_to_run:
|
|
profile_to_run = manager_window.selected_profile_to_run
|
|
self._start_profiling_thread(profile_to_run)
|
|
|
|
def _start_profiling_thread(self, profile: LaunchProfile):
|
|
"""Runs the profiling in a separate thread to avoid blocking the GUI."""
|
|
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):
|
|
"""Target for the thread: runs profiling and schedules the callback."""
|
|
output_path = run_and_profile_script(profile)
|
|
# Schedule the completion handler to run in the main GUI thread
|
|
self.master.after(0, self._on_profiling_complete, output_path)
|
|
|
|
def _on_profiling_complete(self, profile_path: Optional[str]):
|
|
"""Callback executed in the main thread after profiling is done."""
|
|
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 or no output file generated.")
|
|
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}")
|
|
|
|
def _clear_views(self):
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
self.text_view.config(state=tk.NORMAL)
|
|
self.text_view.delete("1.0", tk.END)
|
|
self.text_view.config(state=tk.DISABLED) |