# 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 import subprocess import json from shlex import split as shlex_split 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 from profileanalyzer.gui.settings_window import SettingsWindow 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) 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) self.canvas.xview_moveto(0) self.canvas.yview_moveto(0) 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): 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(): self.canvas.config(width=self.interior.winfo_reqwidth()) def _configure_canvas(self, event): if self.interior.winfo_reqwidth() != self.canvas.winfo_width(): 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) self._create_widgets() self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) def _create_widgets(self): top_frame = ttk.Frame(self) top_frame.pack(fill=tk.X, pady=(0, 10)) top_frame.columnconfigure(6, 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) ttk.Button(top_frame, text="Settings", command=self._open_settings_window).grid(row=0, column=4, padx=5) loaded_file_label = ttk.Label(top_frame, text="Current Profile:") loaded_file_label.grid(row=0, column=5, 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=6, sticky="ew", padx=5) 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 _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) 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) self.tree.bind("", self._show_context_menu) col_map = { "ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120), "tottime_percent": ("% Total Time", 100), "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" 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) self.tree.tag_configure('heat_critical', background='#ffcdd2') self.tree.tag_configure('heat_high', background='#ffecb3') self.tree.tag_configure('heat_medium', background='#e8f5e9') 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 item_id = selection[0] selected_data = self.filtered_stats_data[int(item_id)] func_info = selected_data[6] 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): 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): self._cleanup_temp_files() self.master.destroy() def _cleanup_temp_files(self): 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}") 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("") self._clear_all_views() self._update_stats_display() 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): compare_win = CompareWindow(self, self.analyzer) compare_win.focus_set() def _open_settings_window(self): settings_win = SettingsWindow(self) settings_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, column_id) 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): 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[6].lower()] else: self.filtered_stats_data = self.current_stats_data self.tree.delete(*self.tree.get_children()) for i, row in enumerate(self.filtered_stats_data): ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str, _, _ = row tags = () if percentage > 10.0: tags = ('heat_critical',) elif percentage > 2.0: tags = ('heat_high',) elif percentage > 0.5: tags = ('heat_medium',) 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", iid=i, values=formatted_row, tags=tags) 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.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: 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): 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", "percentage_tottime", "percall_tottime", "cumtime", "percall_cumtime", "function_details", "filepath", "line_number"]) 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}") 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() 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) self.graph_canvas.delete("all") self.graph_canvas.create_image(0, 0, anchor=tk.NW, image=self.graph_photo_image) 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 _show_context_menu(self, event): item_id = self.tree.identify_row(event.y) if not item_id: return self.tree.selection_set(item_id) selected_data = self.filtered_stats_data[int(item_id)] filepath = selected_data[7] context_menu = tk.Menu(self.tree, tearoff=0) state = "normal" if filepath and not filepath.startswith('~') and os.path.exists(filepath) else "disabled" context_menu.add_command(label="Open in Editor", command=self._open_in_editor, state=state) context_menu.tk_popup(event.x_root, event.y_root) def _open_in_editor(self): selection = self.tree.selection() if not selection: return item_id = selection[0] selected_data = self.filtered_stats_data[int(item_id)] filepath, line_number = selected_data[7], selected_data[8] try: with open(SettingsWindow.CONFIG_FILE, 'r', encoding='utf-8') as f: config = json.load(f) editor_command = config.get("editor_command", 'code -g "{file}:{line}"') except (IOError, json.JSONDecodeError): editor_command = 'code -g "{file}:{line}"' command_to_run = editor_command.format(file=filepath, line=line_number) print(f"Executing: {command_to_run}") try: subprocess.Popen(shlex_split(command_to_run)) except FileNotFoundError: messagebox.showerror("Error", f"Could not execute command. Make sure the editor '{shlex_split(command_to_run)[0]}' is in your system's PATH.") except Exception as e: messagebox.showerror("Error", f"Failed to open editor: {e}")