Initial commit for profile ProfileAnalyzer
This commit is contained in:
parent
cb89a37590
commit
986aaa32de
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user