# 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 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 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) # Create a canvas object and a vertical scrollbar for scrolling it. 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) # Reset the view self.canvas.xview_moveto(0) self.canvas.yview_moveto(0) # Create a frame inside the canvas which will be scrolled with it. self.interior = ttk.Frame(self.canvas) self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW) self.interior.bind('', self._configure_interior) self.canvas.bind('', self._configure_canvas) def _configure_interior(self, event): # Update the scrollbars to match the size of the inner frame. 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(): # Update the canvas's width to fit the inner frame. self.canvas.config(width=self.interior.winfo_reqwidth()) def _configure_canvas(self, event): if self.interior.winfo_reqwidth() != self.canvas.winfo_width(): # Update the inner frame's width to fill the canvas. 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) # Percentage self._create_widgets() self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) def _create_widgets(self): # ... (Top frame remains the same) 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="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) 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): 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("<>", 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 _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() # Clean up old file before creating new one 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) # Must keep a reference! self.graph_canvas.delete("all") self.graph_canvas.create_image(0, 0, anchor=tk.NW, image=self.graph_photo_image) # Update the scroll region to match the new image size 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 _clear_all_views(self): # ... (clear other 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()) if self.graphviz_available: self.graph_canvas.delete("all") self._cleanup_temp_files() def _on_closing(self): """Handle window close event.""" self._cleanup_temp_files() self.master.destroy() def _cleanup_temp_files(self): """Removes the temporary graph image file if it exists.""" 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}") # --- Other methods remain largely the same, only adding cleanup calls where needed --- # The following are just stubs for brevity, copy the full methods from previous version 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) # Define columns, including the new percentage column columns = ("ncalls", "tottime", "tottime_percent", "percall_tottime", "cumtime", "percall_cumtime", "function") self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings") self.tree.bind("<>", self._on_function_select) # Define column properties col_map = { "ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120), "tottime_percent": ("% Total Time", 100), # New column "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" # Sort by tottime value 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) # Configure tags for heat-mapping self.tree.tag_configure('heat_critical', background='#ffcdd2') # Red self.tree.tag_configure('heat_high', background='#ffecb3') # Yellow self.tree.tag_configure('heat_medium', background='#e8f5e9') # Green 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 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 _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 self._clear_all_views() # Clear previous data from all views self._update_stats_display() # Populate table/text views # --- NUOVA RIGA AGGIUNTA --- # Automatically trigger graph generation if available 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): """Opens the dedicated window for profile comparison.""" compare_win = CompareWindow(self, self.analyzer) compare_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) 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): 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 data, refreshes Treeview with heat-mapping, and refreshes Text view.""" filter_term = self.filter_var.get().lower() if filter_term: # The last element (index 6) is the function string 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 # Refresh Treeview self.tree.delete(*self.tree.get_children()) for row in self.filtered_stats_data: # Unpack data including the new percentage ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str = row # Determine tag for heat-mapping tags = () if percentage > 10.0: tags = ('heat_critical',) elif percentage > 2.0: tags = ('heat_high',) elif percentage > 0.5: tags = ('heat_medium',) # Format row for display 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", values=formatted_row, tags=tags) # 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} {'% 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: # Unpack the full tuple 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): """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) # Update header for CSV export writer.writerow([ "ncalls", "tottime", "percentage_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}")