From 986aaa32de805f69820e977a2758f4b6f49cd57b Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Mon, 23 Jun 2025 13:02:08 +0200 Subject: [PATCH] Initial commit for profile ProfileAnalyzer --- .vscode/launch.json | 15 +++ profileanalyzer/__main__.py | 38 ++++-- profileanalyzer/core/core.py | 106 +++++++++++++++++ profileanalyzer/gui/gui.py | 223 +++++++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a37f131 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Module", + "type": "debugpy", + "request": "launch", + "module": "profileanalyzer" + } + ] +} \ No newline at end of file diff --git a/profileanalyzer/__main__.py b/profileanalyzer/__main__.py index 0c4daf2..8834696 100644 --- a/profileanalyzer/__main__.py +++ b/profileanalyzer/__main__.py @@ -1,17 +1,31 @@ -# profileanalyzer/__main__.py +# profileAnalyzer/__main__.py -# Example import assuming your main logic is in a 'main' function -# within a 'app' module in your 'profileanalyzer.core' package. -# from profileanalyzer.core.app import main as start_application -# -# Or, if you have a function in profileanalyzer.core.core: -# from profileanalyzer.core.core import main_function +""" +Main entry point for the Profile Analyzer application. +""" + +import tkinter as tk +import sys +import os + +# This block ensures that the 'core' and 'gui' modules can be found +# when running the script directly. It adds the parent directory of +# 'profileAnalyzer' to the Python path. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from profileanalyzer.gui.gui import ProfileAnalyzerGUI +from profileanalyzer.core.core import ProfileAnalyzer def main(): - print(f"Running ProfileAnalyzer...") - # Placeholder: Replace with your application's entry point - # Example: start_application() - print("To customize, edit 'profileanalyzer/__main__.py' and your core modules.") + """Initializes and runs the Tkinter application.""" + root = tk.Tk() + + # The ProfileAnalyzerGUI is a tk.Frame, so it needs a master window. + # It will automatically pack itself into the root window. + app = ProfileAnalyzerGUI(master=root) + + # Start the Tkinter event loop + root.mainloop() if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/profileanalyzer/core/core.py b/profileanalyzer/core/core.py index e69de29..964c50c 100644 --- a/profileanalyzer/core/core.py +++ b/profileanalyzer/core/core.py @@ -0,0 +1,106 @@ +# profileAnalyzer/core/core.py + +""" +Core logic for loading and analyzing cProfile .prof files. +""" +import pstats +from io import StringIO + +class ProfileAnalyzer: + """ + Handles loading a profile data file and extracting statistics. + """ + def __init__(self): + self.stats = None + self.profile_path = None + + def load_profile(self, filepath: str) -> bool: + """ + Loads a .prof file into a pstats.Stats object. + + Args: + filepath (str): The path to the .prof file. + + Returns: + bool: True if loading was successful, False otherwise. + """ + try: + self.stats = pstats.Stats(filepath) + self.stats.strip_dirs() # Clean up filenames for readability + self.profile_path = filepath + return True + except (FileNotFoundError, TypeError, OSError) as e: + # Handle cases where the file doesn't exist or is not a valid stats file + print(f"Error loading profile file '{filepath}': {e}") + self.stats = None + self.profile_path = None + return False + + def get_stats(self, sort_by: str, limit: int = 50) -> list: + """ + Gets a list of formatted statistics sorted by a given key. + + Args: + sort_by (str): The key to sort statistics by. Valid keys include: + 'calls', 'ncalls', 'tottime', 'cumulative', 'cumtime'. + limit (int): The maximum number of rows to return. + + Returns: + list: A list of tuples, where each tuple represents a function's + profile data: (ncalls, tottime, percall_tottime, cumtime, + percall_cumtime, filename:lineno(function)). + Returns an empty list if no stats are loaded. + """ + if not self.stats: + return [] + + # Redirect stdout to capture the output of print_stats + s = StringIO() + # The 'pstats.Stats' constructor can take a stream argument. + # We re-create the object to direct its output to our StringIO stream. + # This is a standard pattern for capturing pstats output. + stats_to_print = pstats.Stats(self.profile_path, stream=s) + stats_to_print.strip_dirs() + stats_to_print.sort_stats(sort_by) + + # print_stats(limit) prints the top 'limit' entries + stats_to_print.print_stats(limit) + + s.seek(0) # Rewind the stream to the beginning + + # --- Parse the captured string output into a structured list --- + lines = s.getvalue().splitlines() + + results = [] + # Find the start of the data table (after the header lines) + data_started = False + for line in lines: + if not line.strip(): + continue + if 'ncalls' in line and 'tottime' in line: + data_started = True + continue + + if data_started: + # pstats output is space-separated, but function names can have spaces. + # A robust way is to split by whitespace a fixed number of times. + parts = line.strip().split(maxsplit=5) + if len(parts) == 6: + try: + # Convert numeric parts to float, leave function name as string + ncalls = parts[0] # ncalls can be "x/y", so keep as string + tottime = float(parts[1]) + percall_tottime = float(parts[2]) + cumtime = float(parts[3]) + percall_cumtime = float(parts[4]) + func_info = parts[5] + + results.append(( + ncalls, tottime, percall_tottime, cumtime, + percall_cumtime, func_info + )) + except (ValueError, IndexError): + # Skip lines that don't parse correctly + continue + + return results \ No newline at end of file diff --git a/profileanalyzer/gui/gui.py b/profileanalyzer/gui/gui.py index e69de29..7fb9171 100644 --- a/profileanalyzer/gui/gui.py +++ b/profileanalyzer/gui/gui.py @@ -0,0 +1,223 @@ +# profileAnalyzer/gui/gui.py + +""" +GUI for the Profile Analyzer application. +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +from profileanalyzer.core.core import ProfileAnalyzer +import csv + +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" + } + + # Mapping from Treeview column ID to pstats sort key + 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.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(2, weight=1) + + load_button = ttk.Button(top_frame, text="Load Profile File...", command=self._load_profile) + load_button.grid(row=0, column=0, padx=(0, 10)) + + export_button = ttk.Button(top_frame, text="Export to CSV...", command=self._export_to_csv) + export_button.grid(row=0, column=1, padx=(0, 10)) + + loaded_file_label = ttk.Label(top_frame, text="Current Profile:") + loaded_file_label.grid(row=0, column=2, sticky="w") + + loaded_file_display = ttk.Label(top_frame, textvariable=self.loaded_filepath_var, anchor="w", relief="sunken") + loaded_file_display.grid(row=0, column=3, sticky="ew", padx=5) + top_frame.columnconfigure(3, weight=1) + + # --- Notebook for different views --- + 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)) + + sort_label = ttk.Label(bottom_frame, text="Sort by:") + sort_label.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_table_tab(self, parent_frame): + """Creates the Treeview widget for displaying stats.""" + 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(): + anchor = "w" if col_id == "function" else "e" + stretch = True if col_id == "function" else False + 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=stretch, 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") + + parent_frame.columnconfigure(0, weight=1) + parent_frame.rowconfigure(0, weight=1) + + def _create_text_tab(self, parent_frame): + """Creates the ScrolledText widget for raw text view.""" + self.text_view = scrolledtext.ScrolledText(parent_frame, wrap=tk.WORD, state=tk.DISABLED, font=("Courier New", 9)) + self.text_view.pack(fill=tk.BOTH, expand=True) + + def _load_profile(self): + 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 _on_header_click(self, column_id): + """Handles sorting when a treeview header is clicked.""" + sort_key = self.COLUMN_SORT_MAP.get(column_id) + if not sort_key: return + + # Find the display name corresponding to the sort key to update the combobox + 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) + + # Populate Table View + 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) + + # Populate Text View + self._update_text_view() + + def _update_text_view(self): + """Formats and displays the current stats data in the text widget.""" + 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 + + # Create header + header = f"{'N-Calls':>10s} {'Total Time':>15s} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function':<}\n" + separator = f"{'-'*10} {'-'*15} {'-'*12} {'-'*15} {'-'*12} {'-'*8}\n" + self.text_view.insert(tk.END, header) + self.text_view.insert(tk.END, separator) + + # Create data rows + for row in self.current_stats_data: + line = f"{str(row[0]):>10s} {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): + """Exports the currently displayed statistics to a CSV file.""" + if not self.current_stats_data: + messagebox.showwarning("No Data", "No profile data to export. Please load a profile first.") + 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) + # Write header + writer.writerow(["ncalls", "tottime", "percall_tottime", "cumtime", "percall_cumtime", "function_details"]) + # Write data rows + 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): + """Clears both the tree and text views.""" + 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) \ No newline at end of file