# 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("<>", 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("<>", 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}")