SXXXXXXX_PyUCC/pyucc/gui/action_handlers.py
2025-11-26 13:50:07 +01:00

700 lines
23 KiB
Python

"""
Action handlers for PyUCC GUI.
Separates business logic from GUI construction.
"""
import os
import time
from pathlib import Path
from tkinter import messagebox
import tkinter as tk
from tkinter import ttk
from ..core.scanner import find_source_files
from ..core.differ import Differ, BaselineManager
from ..config import settings as app_settings
class ActionHandlers:
"""Handles all main actions (scan, countings, metrics, differ) for the GUI."""
def __init__(self, gui_app):
"""
Args:
gui_app: Reference to the main GUI application instance
"""
self.app = gui_app
def handle_scan(self):
"""Execute scan action: find source files matching profile filters."""
self.app.log("Action: Scan started", level='INFO')
self.app._set_phase("Scanning...")
self.app._current_mode = 'scan'
# Disable action buttons during operation
self.app._disable_action_buttons()
# Reset UI
self._reset_progress_ui()
self.app._set_results_columns(("name", "path"))
self._clear_results()
self.app.update_idletasks()
# Get paths and filters
paths, allowed_exts, ignore_patterns, pr = self.app._resolve_profile_and_filters()
if not paths:
messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.")
return
# Submit scan task
def _scan_task(paths):
files = []
for p in paths:
pth = Path(p)
try:
if pth.is_dir():
files.extend(find_source_files(pth, allowed_extensions=allowed_exts, ignore_patterns=ignore_patterns))
elif pth.is_file():
files.append(pth)
except Exception:
continue
# Deduplicate
seen = set()
unique = []
for f in files:
s = str(f)
if s not in seen:
seen.add(s)
unique.append(f)
return unique
self.app._current_task_id = self.app.worker.submit(_scan_task, paths, kind='thread', on_done=self.app._scan_done)
self.app.cancel_btn.config(state='normal')
def handle_countings(self):
"""Execute countings action: analyze code lines (code/comment/blank)."""
self.app.log("Action: Countings started", level='INFO')
self.app._set_phase("Counting...")
self.app._current_mode = 'countings'
# Disable action buttons during operation
self.app._disable_action_buttons()
# Reset UI
self._reset_progress_ui()
self.app._set_results_columns(("name", "path", "code", "comment", "blank", "total", "language"))
self._clear_results()
self.app.update_idletasks()
paths, allowed_exts, ignore_patterns, pr = self.app._resolve_profile_and_filters()
if not paths:
messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.")
return
# Gather files first, then analyze
def _gather_files(paths):
return self._gather_source_files(paths, allowed_exts, ignore_patterns)
def _on_gather_done(results):
files = results or []
if not files:
messagebox.showinfo("Countings", "No files found to analyze.")
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
from ..core.countings_impl import analyze_file_counts
self.app._total_files = len(files)
self.app._processed_files = 0
try:
self.app.progress['maximum'] = max(1, self.app._total_files)
self.app.progress['value'] = 0
self.app._lbl_files.config(text=f"Files: 0/{self.app._total_files}")
except Exception:
pass
self.app._set_phase(f"Analyzing {self.app._total_files} files...")
self.app.update_idletasks()
self.app._current_task_id = self.app.worker.map_iterable(
func=analyze_file_counts,
items=files,
kind='thread',
on_progress=self.app._on_count_progress,
on_done=self.app._on_count_done
)
self.app._set_phase("Gathering files...")
self.app.update_idletasks()
self.app._current_task_id = self.app.worker.submit(_gather_files, paths, kind='thread', on_done=_on_gather_done)
self.app.cancel_btn.config(state='normal')
def handle_metrics(self):
"""Execute metrics action: compute complexity metrics (CC, MI)."""
self.app.log("Action: Metrics started", level='INFO')
self.app._set_phase("Computing metrics...")
self.app._current_mode = 'metrics'
# Disable action buttons during operation
self.app._disable_action_buttons()
# Reset UI
self._reset_progress_ui()
self.app._set_results_columns(("name", "path", "avg_cc", "max_cc", "func_count", "mi", "language"))
self._clear_results()
self.app.update_idletasks()
paths, allowed_exts, ignore_patterns, pr = self.app._resolve_profile_and_filters()
if not paths:
messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.")
return
# Gather files first, then analyze
def _gather_files(paths):
return self._gather_source_files(paths, allowed_exts, ignore_patterns)
def _on_gather_done(results):
files = results or []
if not files:
messagebox.showinfo("Metrics", "No files found to analyze.")
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
try:
from ..core.metrics import analyze_file_metrics as analyzer
except Exception:
messagebox.showerror("Metrics", "Metrics analyzer not available")
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
self.app._total_files = len(files)
self.app._processed_files = 0
try:
self.app.progress['maximum'] = max(1, self.app._total_files)
self.app.progress['value'] = 0
self.app._lbl_files.config(text=f"Files: 0/{self.app._total_files}")
except Exception:
pass
self.app._set_phase(f"Computing metrics for {self.app._total_files} files...")
self.app.update_idletasks()
self.app._current_task_id = self.app.worker.map_iterable(
func=analyzer,
items=files,
kind='thread',
on_progress=self.app._on_metrics_progress,
on_done=self.app._on_metrics_done
)
self.app._set_phase("Gathering files...")
self.app.update_idletasks()
self.app._current_task_id = self.app.worker.submit(_gather_files, paths, kind='thread', on_done=_on_gather_done)
self.app.cancel_btn.config(state='normal')
def handle_differ(self):
"""Execute differ action: compare current code with baseline."""
self.app.log("Action: Differing started", level='INFO')
self.app._set_phase("Differing...")
self.app._set_results_columns(("fileA", "fileB", "added", "deleted", "modified", "unmodified", "Δ code_lines", "Δ comment_lines", "Δ blank_lines", "Δ func_count", "Δ avg_cc", "Δ mi"))
self._clear_results()
self.app._current_mode = 'differ'
# Disable action buttons during operation
self.app._disable_action_buttons()
try:
self.app._lbl_files.config(text="Files: 0/0")
self.app.progress['maximum'] = 1
self.app.progress['value'] = 0
except Exception:
pass
self.app.update_idletasks()
paths, allowed_exts, ignore_patterns, pr = self.app._resolve_profile_and_filters()
if not paths:
messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.")
return
project = paths[0]
self.app._set_phase("Loading baselines...")
self.app.update_idletasks()
bm = BaselineManager(project)
all_baselines = bm.list_baselines()
profile_name = pr.get('name') if pr else None
max_keep = app_settings.get_max_keep()
# Filter baselines by current profile
baselines = []
for baseline_id in all_baselines:
try:
meta = bm.load_metadata(baseline_id)
baseline_profile = meta.profile if hasattr(meta, 'profile') else None
# Match baselines with same profile (or both None)
if baseline_profile == profile_name:
baselines.append(baseline_id)
except Exception:
# Skip baselines that can't be loaded
pass
# Callback handlers
def _on_create_done(baseline_id):
try:
self.app._set_phase('Idle')
self.app._current_task_id = None
self.app._enable_action_buttons()
messagebox.showinfo("Differing", f"Baseline created: {baseline_id}")
except Exception:
self.app._set_phase('Idle')
self.app._current_task_id = None
self.app._enable_action_buttons()
def _on_diff_done(result):
try:
total = result.get('total', {})
self.app.log(f"Differ finished: added={total.get('added',0)} deleted={total.get('deleted',0)} modified={total.get('modified',0)}", level='INFO')
self.app.export_btn.config(state='normal')
# Create new baseline after successful diff
try:
self.app._set_phase('Creating baseline...')
self.app._current_task_id = self.app.worker.submit(
bm.create_baseline_from_dir,
project, None, True, True, ignore_patterns, profile_name, max_keep,
kind='thread',
on_done=_on_create_done
)
self.app.cancel_btn.config(state='normal')
except Exception:
self.app._set_phase('Idle')
self.app._current_task_id = None
self.app._enable_action_buttons()
except Exception as e:
messagebox.showerror('Differ Error', str(e))
self.app._enable_action_buttons()
# No baseline exists - create one
if not baselines:
create = messagebox.askyesno("Differing", "No baseline found for this project. Create baseline now?")
if not create:
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
self.app._set_phase("Creating baseline...")
self.app.update_idletasks()
self.app._current_task_id = self.app.worker.submit(
bm.create_baseline_from_dir,
project, None, True, True, ignore_patterns, profile_name, 5,
kind='thread',
on_done=_on_create_done
)
self.app.cancel_btn.config(state='normal')
return
# Select baseline to compare with
chosen = self._select_baseline_dialog(baselines)
if not chosen:
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
try:
meta = bm.load_metadata(chosen)
self.app.log(f"Loaded baseline: {chosen}", level='INFO')
except Exception as e:
messagebox.showerror("Differing", f"Failed to load baseline metadata: {e}")
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
# Setup baseline and current roots for diff viewer
self._setup_differ_roots(bm, chosen, meta, project)
# Build file lists and start diff
differ = Differ(meta, project, ignore_patterns=ignore_patterns)
self.app._set_phase("Scanning current files...")
self.app.update_idletasks()
try:
current_files = differ.build_current_file_list()
except Exception:
messagebox.showerror('Differing', 'Failed to scan current project files')
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
self.app._set_phase("Building pairs...")
self.app.update_idletasks()
try:
pairs = differ.match_files(differ.baseline.files, current_files)
except Exception:
messagebox.showerror('Differing', 'Failed to build file pairs')
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
if not pairs:
messagebox.showinfo("Differing", "No files to compare")
self.app._set_phase('Idle')
self.app._enable_action_buttons()
return
# Process pairs in parallel
self._run_differ_worker(differ, pairs, project, meta, _on_diff_done)
def _run_differ_worker(self, differ, pairs, project, meta, on_diff_done):
"""Start background worker to process differ pairs."""
from ..core.countings_impl import analyze_file_counts
from ..core.metrics import analyze_file_metrics
self.app._processed_files = 0
try:
self.app.progress['maximum'] = max(1, len(pairs))
self.app.progress['value'] = 0
self.app._lbl_files.config(text=f"Files: 0/{len(pairs)}")
except Exception:
pass
self.app._set_phase(f"Comparing {len(pairs)} file pairs...")
self.app.update_idletasks()
# Process pair function
def _process_pair(pair):
a, b = pair
res = {
'fileA': a.path if a is not None else None,
'fileB': b.path if b is not None else None,
'counts': None,
'file_meta': None,
'baseline_countings': None,
'current_countings': None,
'countings_delta': None,
'baseline_metrics': None,
'current_metrics': None,
'metrics_delta': None,
}
# Diff counts
fa = os.path.join(meta.project_root, a.path) if a is not None else None
fb = os.path.join(project, b.path) if b is not None else None
res['counts'] = Differ._diff_file_pair(fa, fb)
# Extract baseline countings and metrics
if a is not None:
if hasattr(a, 'countings') and a.countings:
res['baseline_countings'] = a.countings
if hasattr(a, 'metrics') and a.metrics:
res['baseline_metrics'] = a.metrics
# Extract current countings and metrics
if b is not None:
file_entry = {
'path': b.path,
'size': b.size,
'mtime': b.mtime,
'sha1': b.sha1,
}
if hasattr(b, 'countings') and b.countings:
file_entry['countings'] = b.countings
res['current_countings'] = b.countings
else:
file_entry['countings'] = None
if hasattr(b, 'metrics') and b.metrics:
file_entry['metrics'] = b.metrics
res['current_metrics'] = b.metrics
else:
file_entry['metrics'] = None
res['file_meta'] = file_entry
# Compute deltas
if res['baseline_countings'] and res['current_countings']:
res['countings_delta'] = {
'physical_lines': res['current_countings']['physical_lines'] - res['baseline_countings'].get('physical_lines', 0),
'code_lines': res['current_countings']['code_lines'] - res['baseline_countings'].get('code_lines', 0),
'comment_lines': res['current_countings']['comment_lines'] - res['baseline_countings'].get('comment_lines', 0),
'blank_lines': res['current_countings']['blank_lines'] - res['baseline_countings'].get('blank_lines', 0),
}
if res['baseline_metrics'] and res['current_metrics']:
res['metrics_delta'] = {
'func_count': res['current_metrics']['func_count'] - res['baseline_metrics'].get('func_count', 0),
'avg_cc': res['current_metrics']['avg_cc'] - res['baseline_metrics'].get('avg_cc', 0.0),
'max_cc': res['current_metrics']['max_cc'] - res['baseline_metrics'].get('max_cc', 0),
'mi': res['current_metrics']['mi'] - res['baseline_metrics'].get('mi', 0.0),
}
return res
# Batch buffer for GUI updates
_batch_buffer = []
_batch_size = 50
# Progress handler
def _on_progress(item_res):
try:
fileA = item_res.get('fileA') or ''
fileB = item_res.get('fileB') or ''
counts = item_res.get('counts') or {}
countings_delta = item_res.get('countings_delta')
metrics_delta = item_res.get('metrics_delta')
# Format delta values
def format_delta(value, is_float=False):
if value is None:
return ''
if is_float:
formatted = f"{value:.2f}"
if value > 0:
return f"+{formatted}"
return formatted
else:
if value > 0:
return f"+{value}"
return str(value)
delta_code = format_delta(countings_delta.get('code_lines') if countings_delta else None)
delta_comment = format_delta(countings_delta.get('comment_lines') if countings_delta else None)
delta_blank = format_delta(countings_delta.get('blank_lines') if countings_delta else None)
delta_func = format_delta(metrics_delta.get('func_count') if metrics_delta else None)
delta_cc = format_delta(metrics_delta.get('avg_cc') if metrics_delta else None, is_float=True)
delta_mi = format_delta(metrics_delta.get('mi') if metrics_delta else None, is_float=True)
# Add to batch buffer
_batch_buffer.append((fileA, fileB, counts.get('added',0), counts.get('deleted',0), counts.get('modified',0), counts.get('unmodified',0), delta_code, delta_comment, delta_blank, delta_func, delta_cc, delta_mi))
# Insert batch when buffer is full
if len(_batch_buffer) >= _batch_size:
for row_values in _batch_buffer:
self.app.results_tree.insert('', 'end', values=row_values)
_batch_buffer.clear()
# Update progress (less frequent)
try:
self.app._processed_files = getattr(self.app, '_processed_files', 0) + 1
if self.app._processed_files % 10 == 0 or self.app._processed_files == len(pairs):
self.app._lbl_files.config(text=f"Files: {self.app._processed_files}/{len(pairs)}")
self.app.progress['maximum'] = max(1, len(pairs))
self.app.progress['value'] = self.app._processed_files
except Exception:
pass
except Exception:
pass
# Done handler
def _on_done(all_results):
# Flush remaining batch buffer
if _batch_buffer:
for row_values in _batch_buffer:
self.app.results_tree.insert('', 'end', values=row_values)
_batch_buffer.clear()
# Compute totals
total = {"added":0, "deleted":0, "modified":0, "unmodified":0}
current_files_list = []
for it in all_results:
c = it.get('counts', {})
total['added'] += c.get('added',0)
total['deleted'] += c.get('deleted',0)
total['modified'] += c.get('modified',0)
total['unmodified'] += c.get('unmodified',0)
fm = it.get('file_meta')
if fm:
current_files_list.append(fm)
result = {"baseline_id": meta.baseline_id, "compared_at": time.time(), "total": total, "pairs": all_results}
# Build summary statistics
try:
baseline_counts = {"physical_lines": 0, "code_lines": 0, "comment_lines": 0, "blank_lines": 0, "file_count": 0}
baseline_metrics = {"file_count": 0, "total_func_count": 0, "avg_avg_cc": 0.0, "avg_mi": 0.0}
baseline_metrics_count = 0
for fm in meta.files:
if hasattr(fm, 'countings') and fm.countings:
baseline_counts["physical_lines"] += fm.countings.get("physical_lines", 0)
baseline_counts["code_lines"] += fm.countings.get("code_lines", 0)
baseline_counts["comment_lines"] += fm.countings.get("comment_lines", 0)
baseline_counts["blank_lines"] += fm.countings.get("blank_lines", 0)
baseline_counts["file_count"] += 1
if hasattr(fm, 'metrics') and fm.metrics:
baseline_metrics["total_func_count"] += fm.metrics.get("func_count", 0)
baseline_metrics["avg_avg_cc"] += fm.metrics.get("avg_cc", 0.0)
baseline_metrics["avg_mi"] += fm.metrics.get("mi", 0.0)
baseline_metrics["file_count"] += 1
baseline_metrics_count += 1
if baseline_metrics_count > 0:
baseline_metrics["avg_avg_cc"] /= baseline_metrics_count
baseline_metrics["avg_mi"] /= baseline_metrics_count
current_counts = {"physical_lines": 0, "code_lines": 0, "comment_lines": 0, "blank_lines": 0, "file_count": 0}
current_metrics = {"file_count": 0, "total_func_count": 0, "avg_avg_cc": 0.0, "avg_mi": 0.0}
current_metrics_count = 0
for fm in current_files_list:
c = fm.get('countings')
if c:
current_counts["physical_lines"] += c.get("physical_lines", 0)
current_counts["code_lines"] += c.get("code_lines", 0)
current_counts["comment_lines"] += c.get("comment_lines", 0)
current_counts["blank_lines"] += c.get("blank_lines", 0)
current_counts["file_count"] += 1
m = fm.get('metrics')
if m:
current_metrics["total_func_count"] += m.get("func_count", 0)
current_metrics["avg_avg_cc"] += m.get("avg_cc", 0.0)
current_metrics["avg_mi"] += m.get("mi", 0.0)
current_metrics["file_count"] += 1
current_metrics_count += 1
if current_metrics_count > 0:
current_metrics["avg_avg_cc"] /= current_metrics_count
current_metrics["avg_mi"] /= current_metrics_count
result["summary"] = {
"baseline": {"countings": baseline_counts, "metrics": baseline_metrics},
"current": {"countings": current_counts, "metrics": current_metrics}
}
except Exception:
pass
self.app._current_results = result
on_diff_done(result)
self.app._current_task_id = self.app.worker.map_iterable(_process_pair, pairs, kind='thread', on_progress=_on_progress, on_done=_on_done)
self.app.cancel_btn.config(state='normal')
def _setup_differ_roots(self, bm, chosen, meta, project):
"""Setup baseline and current roots for diff viewer."""
import zipfile
baseline_dir = bm._baseline_dir(chosen)
baseline_snapshot_dir = os.path.join(baseline_dir, "files")
baseline_zip = os.path.join(baseline_dir, "files.zip")
if os.path.isdir(baseline_snapshot_dir):
self.app._differ_baseline_root = baseline_snapshot_dir
elif os.path.exists(baseline_zip):
try:
with zipfile.ZipFile(baseline_zip, 'r') as zip_ref:
zip_ref.extractall(baseline_snapshot_dir)
self.app._differ_baseline_root = baseline_snapshot_dir
except Exception as e:
self.app.log(f"Failed to extract baseline snapshot: {e}", level='WARNING')
self.app._differ_baseline_root = meta.project_root
else:
self.app.log("No baseline snapshot found, using project_root (may be inaccurate)", level='WARNING')
self.app._differ_baseline_root = meta.project_root
self.app._differ_current_root = project
def _select_baseline_dialog(self, baselines):
"""Show modal dialog to select a baseline."""
sorted_b = sorted(baselines)
dlg = tk.Toplevel(self.app)
dlg.title("Select baseline to compare")
dlg.transient(self.app)
dlg.grab_set()
# Center dialog
dlg.update_idletasks()
pw = self.app.winfo_width()
ph = self.app.winfo_height()
px = self.app.winfo_rootx()
py = self.app.winfo_rooty()
dw = dlg.winfo_reqwidth()
dh = dlg.winfo_reqheight()
x = px + (pw - dw) // 2
y = py + (ph - dh) // 2
dlg.geometry(f"+{x}+{y}")
ttk.Label(dlg, text="Select a baseline:").grid(row=0, column=0, padx=12, pady=(12,4), sticky='w')
listbox = tk.Listbox(dlg, height=min(10, len(sorted_b)), width=60, exportselection=False)
for item in sorted_b:
listbox.insert('end', item)
listbox.grid(row=1, column=0, padx=12, pady=(0,12))
listbox.select_set(len(sorted_b)-1) # Select latest
selected = {'id': None}
def _on_ok():
sel = listbox.curselection()
if not sel:
messagebox.showwarning('Select baseline', 'Please select a baseline to compare')
return
selected['id'] = listbox.get(sel[0])
dlg.destroy()
def _on_cancel():
selected['id'] = None
dlg.destroy()
btn_frame = ttk.Frame(dlg)
btn_frame.grid(row=2, column=0, pady=(0,12), sticky='e', padx=12)
ok_btn = ttk.Button(btn_frame, text='OK', command=_on_ok)
ok_btn.grid(row=0, column=0, padx=(0,8))
cancel_btn = ttk.Button(btn_frame, text='Cancel', command=_on_cancel)
cancel_btn.grid(row=0, column=1)
def _on_dbl(evt):
idx = listbox.curselection()
if idx:
selected['id'] = listbox.get(idx[0])
dlg.destroy()
listbox.bind('<Double-Button-1>', _on_dbl)
self.app.wait_window(dlg)
return selected.get('id')
def _gather_source_files(self, paths, allowed_exts, ignore_patterns):
"""Gather source files from given paths."""
files = []
for p in paths:
pth = Path(p)
try:
if pth.is_dir():
files.extend(find_source_files(pth, allowed_extensions=allowed_exts, ignore_patterns=ignore_patterns))
elif pth.is_file():
files.append(pth)
except Exception:
continue
# Deduplicate
seen = set()
unique = []
for f in files:
s = str(f)
if s not in seen:
seen.add(s)
unique.append(f)
return unique
def _reset_progress_ui(self):
"""Reset progress counters and UI elements."""
self.app._processed_files = 0
self.app._total_files = 0
try:
self.app.progress['maximum'] = 1
self.app.progress['value'] = 0
self.app._lbl_files.config(text="Files: 0/0")
except Exception:
pass
def _clear_results(self):
"""Clear results tree."""
for c in self.app.results_tree.get_children(""):
self.app.results_tree.delete(c)