SXXXXXXX_PyUCC/pyucc/gui/action_handlers.py

1162 lines
42 KiB
Python

"""
Action handlers for PyUCC GUI.
Separates business logic from GUI construction.
"""
import os
import time
import types
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
self._configure_tree_tags()
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()
tooltips = {
"name": "File name",
"path": "Relative path from project root"
}
self.app._set_results_columns(("name", "path"), tooltips=tooltips)
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()
tooltips = {
"name": "File name",
"path": "Relative path from project root",
"code": "Code lines: executable lines excluding comments and blanks",
"comment": "Comment lines: lines containing only comments",
"blank": "Blank lines: empty lines",
"total": "Total physical lines: sum of code + comment + blank",
"language": "Detected programming language"
}
self.app._set_results_columns(("name", "path", "code", "comment", "blank", "total", "language"), tooltips=tooltips)
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()
tooltips = {
"name": "File name",
"path": "Relative path from project root",
"avg_cc": "Avg Cyclomatic Complexity: average complexity across all functions",
"max_cc": "Max Cyclomatic Complexity: highest complexity found in a single function",
"func_count": "Function Count: total number of functions/methods",
"mi": "Maintainability Index: 0-100 scale (higher is better)",
"language": "Detected programming language"
}
self.app._set_results_columns(("name", "path", "avg_cc", "max_cc", "func_count", "mi", "language"), tooltips=tooltips)
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...")
# Show columns as Current (codebase) and Baseline
tooltips = {
"Current": "Current file path",
"Baseline": "Baseline file path",
"added": "Added files: present in current but not in baseline",
"deleted": "Deleted files: present in baseline but not in current",
"modified": "Modified files: content changed between baseline and current",
"unmodified": "Unmodified files: identical in both versions",
"Δ code_lines": "Delta Code Lines: change in executable lines (+positive / -negative)",
"Δ comment_lines": "Delta Comment Lines: change in comment lines",
"Δ blank_lines": "Delta Blank Lines: change in empty lines",
"Δ func_count": "Current Function Count (delta vs baseline)\nShows absolute value with change in parentheses",
"Δ avg_cc": "Current Avg Cyclomatic Complexity (delta vs baseline)\nShows absolute value with change in parentheses",
"Δ mi": "Current Maintainability Index (delta vs baseline)\nShows absolute value with change in parentheses"
}
self.app._set_results_columns(("Current", "Baseline", "added", "deleted", "modified", "unmodified", "Δ code_lines", "Δ comment_lines", "Δ blank_lines", "Δ func_count", "Δ avg_cc", "Δ mi"), tooltips=tooltips)
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()
# After baseline is created, export the differ `result` (if available) to CSV and text report inside baseline folder
try:
from ..utils.csv_exporter import export_rows_to_csv
from ..utils.report_generator import generate_differ_report
bdir = bm._baseline_dir(baseline_id)
csv_path = os.path.join(bdir, "diff.csv")
report_path = os.path.join(bdir, "diff_report.txt")
result = getattr(self.app, '_current_results', None)
if result:
headers = [c for c in self.app.results_tree['columns']]
# Build rows from result['pairs'] to ensure consistency with computed data
def _row_from_pair(p):
baseline_name = p.get('fileA') or ''
current_name = p.get('fileB') or ''
counts = p.get('counts') or {}
cd = p.get('countings_delta')
md = p.get('metrics_delta')
# ensure simple deltas when missing
if cd is None:
base = p.get('baseline_countings')
cur = p.get('current_countings')
if base is None and cur is not None:
cd = {
'physical_lines': cur.get('physical_lines', 0),
'code_lines': cur.get('code_lines', 0),
'comment_lines': cur.get('comment_lines', 0),
'blank_lines': cur.get('blank_lines', 0),
}
elif cur is None and base is not None:
cd = {
'physical_lines': 0 - base.get('physical_lines', 0),
'code_lines': 0 - base.get('code_lines', 0),
'comment_lines': 0 - base.get('comment_lines', 0),
'blank_lines': 0 - base.get('blank_lines', 0),
}
# Format numeric values similarly to UI (no sign formatting in CSV values)
return (
current_name,
baseline_name,
counts.get('added',0),
counts.get('deleted',0),
counts.get('modified',0),
counts.get('unmodified',0),
(cd.get('code_lines') if cd else ''),
(cd.get('comment_lines') if cd else ''),
(cd.get('blank_lines') if cd else ''),
(md.get('func_count') if md else ''),
(md.get('avg_cc') if md else ''),
(md.get('mi') if md else ''),
)
rows = (_row_from_pair(p) for p in result.get('pairs', []))
export_rows_to_csv(csv_path, headers, rows)
# Generate text report
profile_config = {
'name': profile_name,
'root': project,
'paths': paths if paths else [project],
'languages': allowed_exts if allowed_exts else [],
'exclude_patterns': ignore_patterns
}
generate_differ_report(result, profile_config, baseline_id, report_path)
self.app.log(f"Differ report saved to: {report_path}", level='INFO')
except Exception as e:
# non-fatal: continue even if export fails
self.app.log(f"Failed to export differ results: {e}", level='WARNING')
pass # Show summary dialog
self._show_differ_summary_dialog(result, baseline_id, bdir)
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()
# If no baseline exists, treat baseline as empty (new project) and run differ
if not baselines:
self.app.log("No baseline found: treating baseline as empty (new project)", level='INFO')
# Create an in-memory minimal baseline metadata object
meta = types.SimpleNamespace(baseline_id="__empty__", project_root=project, files=[], profile=profile_name)
# Setup differ roots: baseline root empty string, current root is project
self.app._differ_baseline_root = ''
self.app._differ_current_root = project
# Build differ and pairs using empty baseline
differ = Differ(meta, project, ignore_patterns=ignore_patterns, baseline_files_dir=meta.project_root)
current_files = differ.build_current_file_list()
pairs = differ.match_files(differ.baseline.files, current_files)
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 (reuse existing worker function)
self._run_differ_worker(differ, pairs, project, meta, _on_diff_done)
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
baseline_files_dir = bm.get_baseline_files_dir(chosen)
differ = Differ(meta, project, ignore_patterns=ignore_patterns, baseline_files_dir=baseline_files_dir)
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,
}
# Determine file status: added, deleted, or modified
# Check if both files exist and have same SHA1 hash
if a is None and b is not None:
# File added
res['counts'] = {'added': 1, 'deleted': 0, 'modified': 0, 'unmodified': 0}
elif a is not None and b is None:
# File deleted
res['counts'] = {'added': 0, 'deleted': 1, 'modified': 0, 'unmodified': 0}
elif a is not None and b is not None:
# Both exist: check if modified (compare SHA1 hash)
# Compute current file SHA1 if not already present
if not hasattr(b, 'sha1') or b.sha1 is None:
try:
import hashlib
file_path = Path(os.path.join(project, b.path))
h = hashlib.sha1()
with file_path.open("rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
b.sha1 = h.hexdigest()
except Exception:
b.sha1 = None
# Compare hashes
if hasattr(a, 'sha1') and a.sha1 and b.sha1 and a.sha1 == b.sha1:
# Identical files
res['counts'] = {'added': 0, 'deleted': 0, 'modified': 0, 'unmodified': 1}
else:
# Modified file
res['counts'] = {'added': 0, 'deleted': 0, 'modified': 1, 'unmodified': 0}
else:
# Both None (shouldn't happen)
res['counts'] = {'added': 0, 'deleted': 0, 'modified': 0, 'unmodified': 0}
# 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:
# Compute on-the-fly if missing
need_countings = not (hasattr(b, 'countings') and b.countings)
need_metrics = not (hasattr(b, 'metrics') and b.metrics)
if need_countings:
try:
counts_result = analyze_file_counts(Path(os.path.join(project, b.path)))
b.countings = {
'physical_lines': counts_result.get('physical_lines', 0),
'code_lines': counts_result.get('code_lines', 0),
'comment_lines': counts_result.get('comment_lines', 0),
'blank_lines': counts_result.get('blank_lines', 0),
}
except Exception:
b.countings = None
if need_metrics:
try:
metrics_result = analyze_file_metrics(Path(os.path.join(project, b.path)))
b.metrics = {
'func_count': metrics_result.get('func_count', 0),
'avg_cc': metrics_result.get('avg_cc', 0.0),
'max_cc': metrics_result.get('max_cc', 0),
'mi': metrics_result.get('mi', 0.0),
}
except Exception:
b.metrics = 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),
}
# Compute metrics delta when one side missing (treat missing as zero)
if res.get('metrics_delta') is None:
base_m = res.get('baseline_metrics')
cur_m = res.get('current_metrics')
if base_m is None and cur_m is not None:
# additions: delta == current
res['metrics_delta'] = {
'func_count': cur_m.get('func_count', 0),
'avg_cc': cur_m.get('avg_cc', 0.0),
'max_cc': cur_m.get('max_cc', 0),
'mi': cur_m.get('mi', 0.0),
}
elif cur_m is None and base_m is not None:
# deletions: delta == 0 - baseline
res['metrics_delta'] = {
'func_count': 0 - base_m.get('func_count', 0),
'avg_cc': 0.0 - base_m.get('avg_cc', 0.0),
'max_cc': 0 - base_m.get('max_cc', 0),
'mi': 0.0 - base_m.get('mi', 0.0),
}
return res
# Batch buffer for GUI updates
_batch_buffer = []
_batch_size = 50
# Progress handler
def _on_progress(item_res):
try:
# Columns: Current (codebase) and Baseline
baseline = item_res.get('fileA') or ''
current = 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')
# If deltas are not provided, compute simple deltas when one side is missing
if countings_delta is None:
base = item_res.get('baseline_countings')
cur = item_res.get('current_countings')
if base is None and cur is not None:
# additions: delta == current
countings_delta = {
'physical_lines': cur.get('physical_lines', 0),
'code_lines': cur.get('code_lines', 0),
'comment_lines': cur.get('comment_lines', 0),
'blank_lines': cur.get('blank_lines', 0),
}
elif cur is None and base is not None:
# deletions: delta == 0 - baseline
countings_delta = {
'physical_lines': 0 - base.get('physical_lines', 0),
'code_lines': 0 - base.get('code_lines', 0),
'comment_lines': 0 - base.get('comment_lines', 0),
'blank_lines': 0 - base.get('blank_lines', 0),
}
# Format delta values with explicit sign (include + for zero/positive)
def format_delta(value, is_float=False):
if value is None:
return ''
if is_float:
formatted = f"{abs(value):.2f}"
sign = '+' if value >= 0 else '-'
return f"{sign}{formatted}"
else:
ival = int(value)
formatted = str(abs(ival))
sign = '+' if ival >= 0 else '-'
return f"{sign}{formatted}"
# Format metrics: show absolute current value with delta in parentheses (only when baseline exists)
def format_metric(current_val, delta_val, baseline_exists, is_float=False):
if current_val is None:
return ''
# Show delta in parentheses when baseline exists (even if delta is zero)
if baseline_exists and delta_val is not None:
# Show absolute value with delta in parentheses
if is_float:
abs_str = f"{current_val:.2f}"
delta_str = format_delta(delta_val, is_float=True)
else:
abs_str = str(int(current_val))
delta_str = format_delta(delta_val, is_float=False)
return f"{abs_str} ({delta_str})"
else:
# No baseline: show only absolute value
if is_float:
return f"{current_val:.2f}"
else:
return str(int(current_val))
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)
# Metrics: show absolute current value with delta in parentheses (only when baseline exists)
# Check if baseline exists by verifying baseline_id is not "__empty__"
baseline_exists = meta.baseline_id != "__empty__"
cur_metrics = item_res.get('current_metrics')
delta_func = format_metric(
cur_metrics.get('func_count') if cur_metrics else None,
metrics_delta.get('func_count') if metrics_delta else None,
baseline_exists
)
delta_cc = format_metric(
cur_metrics.get('avg_cc') if cur_metrics else None,
metrics_delta.get('avg_cc') if metrics_delta else None,
baseline_exists,
is_float=True
)
delta_mi = format_metric(
cur_metrics.get('mi') if cur_metrics else None,
metrics_delta.get('mi') if metrics_delta else None,
baseline_exists,
is_float=True
)
# Add to batch buffer with metadata for tag application
row_data = {
'values': (current, baseline, 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),
'countings_delta': countings_delta,
'metrics_delta': metrics_delta
}
_batch_buffer.append(row_data)
# Insert batch when buffer is full
if len(_batch_buffer) >= _batch_size:
for row_data in _batch_buffer:
item_id = self.app.results_tree.insert('', 'end', values=row_data['values'])
# Apply color tags based on deltas
self._apply_delta_tags(item_id, row_data['countings_delta'], row_data['metrics_delta'])
_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_data in _batch_buffer:
item_id = self.app.results_tree.insert('', 'end', values=row_data['values'])
# Apply color tags based on deltas
self._apply_delta_tags(item_id, row_data['countings_delta'], row_data['metrics_delta'])
_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 _configure_tree_tags(self):
"""Configure Treeview tags for coloring delta values."""
# Positive changes (increases) - green tones
self.app.results_tree.tag_configure('positive', foreground='#007700', font=('TkDefaultFont', 9, 'bold'))
# Negative changes (decreases) - red tones
self.app.results_tree.tag_configure('negative', foreground='#cc0000', font=('TkDefaultFont', 9, 'bold'))
# Zero/neutral - default
self.app.results_tree.tag_configure('neutral', foreground='#666666')
def _apply_delta_tags(self, item_id, countings_delta, metrics_delta):
"""
Apply color tags to tree item based on delta values.
Args:
item_id: Treeview item identifier
countings_delta: dict with code/comment/blank line deltas
metrics_delta: dict with func_count/avg_cc/mi deltas
"""
# Determine overall change direction for visual feedback
tags = []
# Check countings deltas
if countings_delta:
code_delta = countings_delta.get('code_lines', 0)
if code_delta > 0:
tags.append('positive')
elif code_delta < 0:
tags.append('negative')
# Check metrics deltas (complexity increase is "negative", MI decrease is "negative")
if metrics_delta:
cc_delta = metrics_delta.get('avg_cc', 0)
mi_delta = metrics_delta.get('mi', 0)
# Complexity increase is bad (red)
if cc_delta > 0.5:
if 'negative' not in tags:
tags.append('negative')
# MI decrease is bad (red)
elif mi_delta < -5:
if 'negative' not in tags:
tags.append('negative')
if tags:
self.app.results_tree.item(item_id, tags=tags)
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)
def _show_differ_summary_dialog(self, result, baseline_id, baseline_dir):
"""Show summary dialog with differ results."""
import subprocess
dlg = tk.Toplevel(self.app)
dlg.title("Differ Summary")
dlg.geometry("700x600")
dlg.transient(self.app)
# 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 = 700
dh = 600
x = px + (pw - dw) // 2
y = py + (ph - dh) // 2
dlg.geometry(f"{dw}x{dh}+{x}+{y}")
# Title
title_frame = ttk.Frame(dlg)
title_frame.pack(fill='x', padx=10, pady=10)
ttk.Label(title_frame, text=f"Differ Summary - {baseline_id}",
font=('Arial', 12, 'bold')).pack()
# Text widget with scrollbar for summary
text_frame = ttk.Frame(dlg)
text_frame.pack(fill='both', expand=True, padx=10, pady=(0, 10))
scrollbar = ttk.Scrollbar(text_frame)
scrollbar.pack(side='right', fill='y')
summary_text = tk.Text(text_frame, wrap='none', font=('Courier', 9),
yscrollcommand=scrollbar.set, height=30, width=80)
summary_text.pack(side='left', fill='both', expand=True)
scrollbar.config(command=summary_text.yview)
# Generate summary content
summary_lines = self._generate_summary_text(result, baseline_id)
summary_text.insert('1.0', '\n'.join(summary_lines))
summary_text.config(state='disabled')
# Store summary for clipboard
summary_content = '\n'.join(summary_lines)
# Buttons frame
btn_frame = ttk.Frame(dlg)
btn_frame.pack(fill='x', padx=10, pady=(0, 10))
def copy_to_clipboard():
self.app.clipboard_clear()
self.app.clipboard_append(summary_content)
messagebox.showinfo("Copied", "Summary copied to clipboard!", parent=dlg)
def open_baseline_folder():
try:
if os.path.exists(baseline_dir):
subprocess.Popen(['explorer', baseline_dir])
else:
messagebox.showwarning("Not Found", f"Baseline folder not found:\n{baseline_dir}", parent=dlg)
except Exception as e:
messagebox.showerror("Error", f"Failed to open folder:\n{e}", parent=dlg)
ttk.Button(btn_frame, text="Copy to Clipboard", command=copy_to_clipboard).pack(side='left', padx=5)
ttk.Button(btn_frame, text="Open Baseline Folder", command=open_baseline_folder).pack(side='left', padx=5)
ttk.Button(btn_frame, text="Close", command=dlg.destroy).pack(side='right', padx=5)
def _generate_summary_text(self, result, baseline_id):
"""Generate summary text lines for differ results."""
import datetime
lines = []
lines.append("=" * 80)
lines.append("PyUcc - Differ Summary".center(80))
lines.append("=" * 80)
lines.append("")
lines.append(f"Baseline ID: {baseline_id}")
lines.append(f"Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
# Summary Statistics
lines.append("-" * 80)
lines.append("Summary Statistics")
lines.append("-" * 80)
total = result.get('total', {})
lines.append(f"Files Added: {total.get('added', 0):>8}")
lines.append(f"Files Deleted: {total.get('deleted', 0):>8}")
lines.append(f"Files Modified: {total.get('modified', 0):>8}")
lines.append(f"Files Unmodified: {total.get('unmodified', 0):>8}")
lines.append("" * 30)
total_files = sum([total.get('added', 0), total.get('deleted', 0),
total.get('modified', 0), total.get('unmodified', 0)])
lines.append(f"Total Files: {total_files:>8}")
lines.append("")
# Code Metrics Comparison
summary = result.get('summary', {})
if summary:
baseline_counts = summary.get('baseline', {}).get('countings', {})
current_counts = summary.get('current', {}).get('countings', {})
baseline_metrics = summary.get('baseline', {}).get('metrics', {})
current_metrics = summary.get('current', {}).get('metrics', {})
lines.append("-" * 80)
lines.append("Code Metrics Comparison")
lines.append("-" * 80)
lines.append(f"{'Metric':<30} {'Baseline':>15} {'Current':>15} {'Delta':>15}")
lines.append("" * 80)
def _delta_str(baseline_val, current_val):
delta = current_val - baseline_val
sign = "+" if delta >= 0 else ""
return f"{sign}{delta}"
bc_code = baseline_counts.get('code_lines', 0)
cc_code = current_counts.get('code_lines', 0)
lines.append(f"{'Code Lines':<30} {bc_code:>15,} {cc_code:>15,} {_delta_str(bc_code, cc_code):>15}")
bc_comment = baseline_counts.get('comment_lines', 0)
cc_comment = current_counts.get('comment_lines', 0)
lines.append(f"{'Comment Lines':<30} {bc_comment:>15,} {cc_comment:>15,} {_delta_str(bc_comment, cc_comment):>15}")
bc_blank = baseline_counts.get('blank_lines', 0)
cc_blank = current_counts.get('blank_lines', 0)
lines.append(f"{'Blank Lines':<30} {bc_blank:>15,} {cc_blank:>15,} {_delta_str(bc_blank, cc_blank):>15}")
bc_physical = baseline_counts.get('physical_lines', 0)
cc_physical = current_counts.get('physical_lines', 0)
lines.append(f"{'Physical Lines':<30} {bc_physical:>15,} {cc_physical:>15,} {_delta_str(bc_physical, cc_physical):>15}")
lines.append("")
bm_func = baseline_metrics.get('total_func_count', 0)
cm_func = current_metrics.get('total_func_count', 0)
lines.append(f"{'Function Count':<30} {bm_func:>15,} {cm_func:>15,} {_delta_str(bm_func, cm_func):>15}")
bm_avgcc = baseline_metrics.get('avg_avg_cc', 0.0)
cm_avgcc = current_metrics.get('avg_avg_cc', 0.0)
delta_avgcc = cm_avgcc - bm_avgcc
sign_cc = "+" if delta_avgcc >= 0 else ""
lines.append(f"{'Avg Cyclomatic Complexity':<30} {bm_avgcc:>15.2f} {cm_avgcc:>15.2f} {sign_cc}{delta_avgcc:>14.2f}")
bm_mi = baseline_metrics.get('avg_mi', 0.0)
cm_mi = current_metrics.get('avg_mi', 0.0)
delta_mi = cm_mi - bm_mi
sign_mi = "+" if delta_mi >= 0 else ""
lines.append(f"{'Maintainability Index':<30} {bm_mi:>15.2f} {cm_mi:>15.2f} {sign_mi}{delta_mi:>14.2f}")
lines.append("")
lines.append("=" * 80)
lines.append("End of Summary".center(80))
lines.append("=" * 80)
return lines