Initial commit for profile ProfileAnalyzer

This commit is contained in:
VALLONGOL 2025-06-23 13:02:08 +02:00
parent cb89a37590
commit 986aaa32de
4 changed files with 370 additions and 12 deletions

15
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

View File

@ -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()

View File

@ -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

View File

@ -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("<<ComboboxSelected>>", 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)