SXXXXXXX_PyUCC/pyucc/utils/ucc_report_generator.py

582 lines
21 KiB
Python

"""
UCC-style report generator for PyUcc.
Generates reports in UCC format for:
- Scan results
- Counting results
- Differ results
- Metrics results
"""
from pathlib import Path
from typing import Dict, List, Any, Optional
from datetime import datetime
import os
class UCCReportGenerator:
"""Generates UCC-style text reports."""
@staticmethod
def _format_header(command_description: str) -> str:
"""Generate PyUcc-style header."""
now = datetime.now()
date_str = now.strftime("%d %m %Y")
time_str = now.strftime("%H:%M:%S")
header = []
header.append("=" * 100)
header.append("")
header.append(" " * 32 + "SLOC COUNT RESULTS")
header.append(" " * 25 + f"Generated by PyUcc on {date_str} at {time_str}")
header.append(f"{command_description}")
header.append("=" * 100)
header.append("")
return "\n".join(header)
@staticmethod
def _format_counting_table_header(language: str = "ALL") -> str:
"""Generate counting table header."""
lines = []
lines.append(" " * 35 + f"RESULTS FOR {language} FILES")
lines.append("")
lines.append(
"NOTE: Total Lines = all lines in file | Blank Lines = empty lines"
)
lines.append(
" Comments (Whole) = comment-only lines | Comments (Embedded) = inline comments"
)
lines.append(
" Compiler Directives = preprocessor commands (#include, #define, etc.)"
)
lines.append(
" Data Declarations = variable/type declarations | Exec. Instructions = executable code"
)
lines.append(
" Logical SLOC = statements | Physical SLOC = lines of code (excluding blank/comments)"
)
lines.append("")
lines.append(
" Total Blank | Comments | Compiler Data Exec. | Logical Physical | File Module"
)
lines.append(
" Lines Lines | Whole Embedded | Direct. Decl. Instr. | SLOC SLOC | Type Name"
)
lines.append("-" * 100 + "-" * 25)
return "\n".join(lines)
@staticmethod
def _format_counting_row(result: Dict[str, Any], base_path: str = "") -> str:
"""Format a single counting result row in UCC style."""
# Extract values
total = result.get("physical_lines", 0)
blank = result.get("blank_lines", 0)
comment_whole = result.get("comment_whole", 0)
comment_embed = result.get("comment_embedded", 0)
directives = result.get("compiler_directives", 0)
data_decl = result.get("data_declarations", 0)
exec_inst = result.get("exec_instructions", 0)
logical = result.get("logical_sloc", 0)
physical = result.get("physical_sloc", 0)
# Get file path (relative to base if provided)
file_path = result.get("file", result.get("path", ""))
if base_path and file_path:
try:
file_path = os.path.relpath(file_path, base_path)
except:
pass
# Format: align numbers right
row = (
f"{total:8d} {blank:7d} | "
f"{comment_whole:7d} {comment_embed:8d} | "
f"{directives:7d} {data_decl:6d} {exec_inst:7d} | "
f"{logical:7d} {physical:9d} | "
f"CODE {file_path}"
)
return row
@staticmethod
def _format_summary(results: List[Dict[str, Any]]) -> str:
"""Generate summary section."""
if not results:
return ""
# Calculate totals
total_lines = sum(r.get("physical_lines", 0) for r in results)
total_blank = sum(r.get("blank_lines", 0) for r in results)
total_cmt_whole = sum(r.get("comment_whole", 0) for r in results)
total_cmt_embed = sum(r.get("comment_embedded", 0) for r in results)
total_directives = sum(r.get("compiler_directives", 0) for r in results)
total_data = sum(r.get("data_declarations", 0) for r in results)
total_exec = sum(r.get("exec_instructions", 0) for r in results)
total_logical = sum(r.get("logical_sloc", 0) for r in results)
total_physical = sum(r.get("physical_sloc", 0) for r in results)
# Calculate ratio
ratio = total_physical / total_logical if total_logical > 0 else 0.0
lines = []
lines.append("")
lines.append(" " * 40 + "RESULTS SUMMARY")
lines.append("")
lines.append(
" Total Blank | Comments | Compiler Data Exec. | | File SLOC"
)
lines.append(
" Lines Lines | Whole Embedded | Direct. Decl. Instr. | SLOC | Type Definition"
)
lines.append("-" * 100)
# Physical SLOC row
lines.append(
f"{total_lines:8d} {total_blank:7d} | "
f"{total_cmt_whole:7d} {total_cmt_embed:8d} | "
f"{total_directives:7d} {total_data:6d} {total_exec:7d} | "
f"{total_physical:7d} | CODE Physical"
)
# Logical SLOC row
lines.append(
f"{total_lines:8d} {total_blank:7d} | "
f"{total_cmt_whole:7d} {total_cmt_embed:8d} | "
f"{total_directives:7d} {total_data:6d} {total_exec:7d} | "
f"{total_logical:7d} | CODE Logical"
)
lines.append("")
lines.append(
f"Number of files successfully accessed........................ {len(results):6d} out of {len(results):6d}"
)
lines.append("")
lines.append(
f"Ratio of Physical to Logical SLOC............................ {ratio:.2f}"
)
lines.append("")
return "\n".join(lines)
@staticmethod
def generate_counting_report(
results: List[Dict[str, Any]],
output_path: Path,
command_description: str = "",
base_path: str = "",
language_filter: Optional[str] = None,
) -> None:
"""
Generate UCC-style counting report.
Args:
results: List of counting results
output_path: Path to save report
command_description: Description of command run
base_path: Base path for relative file paths
language_filter: Optional language to filter by
"""
# Filter by language if specified
if language_filter:
results = [
r
for r in results
if r.get("language", "").lower() == language_filter.lower()
]
# Group by language
by_language = {}
for r in results:
lang = r.get("language", "unknown").upper()
if lang not in by_language:
by_language[lang] = []
by_language[lang].append(r)
# Build report
report_lines = []
report_lines.append(UCCReportGenerator._format_header(command_description))
report_lines.append("")
# Add sections for each language
for lang, lang_results in sorted(by_language.items()):
report_lines.append(UCCReportGenerator._format_counting_table_header(lang))
for result in lang_results:
report_lines.append(
UCCReportGenerator._format_counting_row(result, base_path)
)
report_lines.append("")
# Add summary
report_lines.append(UCCReportGenerator._format_summary(results))
# Write to file
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(report_lines))
@staticmethod
def generate_differ_report(
diff_results: List[Dict[str, Any]],
output_path: Path,
baseline_id: str,
command_description: str = "",
) -> None:
"""
Generate UCC-style differ report showing Baseline-A vs Baseline-B.
Args:
diff_results: List of differ results
output_path: Path to save report
baseline_id: Baseline identifier
command_description: Description of command run
"""
report_lines = []
report_lines.append(UCCReportGenerator._format_header(command_description))
report_lines.append("")
report_lines.append(" " * 30 + "DIFFERENTIAL RESULTS")
report_lines.append("")
report_lines.append(
"NOTE: This report compares Baseline-A (previous) vs Baseline-B (current)"
)
report_lines.append(" MODIFIED = files changed between baselines")
report_lines.append(
" ADDED = files added in Baseline-B | DELETED = files removed from Baseline-A"
)
report_lines.append(
" Delta = change in Physical SLOC (positive = code added, negative = code removed)"
)
report_lines.append("")
report_lines.append(" " * 25 + f"Baseline-A: {baseline_id}")
report_lines.append(" " * 25 + "Baseline-B: Current")
report_lines.append("")
report_lines.append(" File Status | Baseline-A | Baseline-B | Delta Lines")
report_lines.append(
" | (Physical) | (Physical) | (Code/Cmt/Blank)"
)
report_lines.append("-" * 80)
for result in diff_results:
status = (
"MODIFIED"
if result.get("modified", 0) > 0
else (
"ADDED"
if result.get("added", 0) > 0
else "DELETED" if result.get("deleted", 0) > 0 else "UNCHANGED"
)
)
baseline_file = result.get("fileA", "")
current_file = result.get("fileB", "")
# Get metrics
baseline_counts = result.get("baseline_countings", {})
current_counts = result.get("current_countings", {})
deltas = result.get("countings_delta", {})
baseline_phys = (
baseline_counts.get("physical_lines", 0) if baseline_counts else 0
)
current_phys = (
current_counts.get("physical_lines", 0) if current_counts else 0
)
delta_code = deltas.get("code_lines", 0) if deltas else 0
delta_comment = deltas.get("comment_lines", 0) if deltas else 0
delta_blank = deltas.get("blank_lines", 0) if deltas else 0
file_name = current_file or baseline_file
report_lines.append(
f"{status:15s} | {baseline_phys:10d} | {current_phys:10d} | "
f"{delta_code:+6d}/{delta_comment:+6d}/{delta_blank:+6d} {file_name}"
)
report_lines.append("")
# Write to file
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(report_lines))
@staticmethod
def generate_duplicates_report(
duplicates: List[Dict[str, Any]],
output_path: Path,
command_description: str = "",
base_path: str = "",
params: Optional[Dict[str, Any]] = None,
) -> None:
"""
Generate a UCC-style duplicates report.
Args:
duplicates: list of dicts with keys: file_a, file_b, match_type, pct_change
output_path: path to save report
command_description: optional description line
base_path: optional base path to relativize file paths
"""
report_lines: List[str] = []
report_lines.append(UCCReportGenerator._format_header(command_description))
report_lines.append("")
report_lines.append(" " * 35 + "DUPLICATE FILES REPORT")
report_lines.append("")
report_lines.append(
"NOTE: Exact duplicates are byte-identical (after normalization)."
)
report_lines.append(
" Fuzzy duplicates are similar files within the configured threshold."
)
if params:
# include parameters used for reproducibility
report_lines.append("")
report_lines.append("Search parameters:")
thr = params.get("threshold")
exts = params.get("extensions")
k = params.get("k")
window = params.get("window")
report_lines.append(f" Threshold: {thr}")
report_lines.append(f" Extensions: {exts}")
report_lines.append(f" Fingerprint k: {k}")
report_lines.append(f" Winnowing window: {window}")
report_lines.append("")
# Separate exact and fuzzy
exact = [d for d in duplicates if d.get("match_type") == "exact"]
fuzzy = [d for d in duplicates if d.get("match_type") == "fuzzy"]
report_lines.append(f"Exact duplicates: {len(exact)}")
report_lines.append("" if exact else "No exact duplicates found.")
if exact:
report_lines.append("\nExact duplicate pairs:\n")
report_lines.append(" File A" + " " * 4 + "| File B")
report_lines.append("-" * 100)
for d in exact:
a = d.get("file_a", "")
b = d.get("file_b", "")
if base_path:
try:
a = str(Path(a).relative_to(base_path))
except Exception:
pass
try:
b = str(Path(b).relative_to(base_path))
except Exception:
pass
report_lines.append(f"{a} | {b}")
report_lines.append("")
report_lines.append(f"Fuzzy duplicates (threshold): {len(fuzzy)}")
report_lines.append("" if fuzzy else "No fuzzy duplicates found.")
if fuzzy:
report_lines.append(
"\nFuzzy duplicate pairs (pct_change = approximate % lines changed):\n"
)
report_lines.append("Pct | File A" + " " * 2 + "| File B")
report_lines.append("-" * 100)
for d in fuzzy:
pct = d.get("pct_change", "")
a = d.get("file_a", "")
b = d.get("file_b", "")
if base_path:
try:
a = str(Path(a).relative_to(base_path))
except Exception:
pass
try:
b = str(Path(b).relative_to(base_path))
except Exception:
pass
report_lines.append(f"{str(pct):>4} | {a} | {b}")
# summary
report_lines.append("")
report_lines.append(
f"Total duplicate pairs (exact + fuzzy): {len(exact) + len(fuzzy)}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(report_lines))
@staticmethod
def generate_metrics_report(
results: List[Dict[str, Any]],
output_path: Path,
command_description: str = "",
base_path: str = "",
) -> None:
"""
Generate UCC-style metrics report (Cyclomatic Complexity).
Args:
results: List of metrics results
output_path: Path to save report
command_description: Description of command run
base_path: Base path for relative file paths
"""
report_lines = []
report_lines.append(UCCReportGenerator._format_header(command_description))
report_lines.append("")
report_lines.append(" " * 35 + "CYCLOMATIC COMPLEXITY RESULTS")
report_lines.append("")
report_lines.append(
"NOTE: CC1 = McCabe Cyclomatic Complexity (Standard) - measures code complexity"
)
report_lines.append(
" Total_CC = sum of complexity for all functions in file"
)
report_lines.append(" Average_CC = average complexity per function")
report_lines.append(
" Risk: Low (≤10) | Medium (≤20) | High (≤50) | Very High (>50)"
)
report_lines.append(
" MI = Maintainability Index (0-100): higher is better"
)
report_lines.append(
" 85-100 = Excellent | 65-84 = Good | 0-64 = Needs attention"
)
report_lines.append("")
report_lines.append(" " * 40 + "RESULTS BY FILE")
report_lines.append("")
report_lines.append("Cyclomatic Complexity and Maintainability Index")
report_lines.append(" Total_CC Average_CC Risk MI | File Name")
report_lines.append("-" * 56 + "+" + "-" * 50)
total_cc = 0
total_funcs = 0
for result in results:
file_path = result.get("file", "")
if base_path:
try:
file_path = str(Path(file_path).relative_to(base_path))
except ValueError:
pass
# Get metrics
avg_cc = result.get("avg_cc", 0.0)
func_count = result.get("func_count", 0)
max_cc = result.get("max_cc", 0)
mi = result.get("mi", 0.0)
# Calculate total CC (avg * func_count)
file_total_cc = int(avg_cc * func_count) if func_count > 0 else 0
total_cc += file_total_cc
total_funcs += func_count
# Determine risk level based on average CC
if avg_cc <= 10:
risk = "Low"
elif avg_cc <= 20:
risk = "Medium"
elif avg_cc <= 50:
risk = "High"
else:
risk = "Very High"
report_lines.append(
f"{file_total_cc:10d} {avg_cc:11.2f} {risk:11s} {mi:6.2f} | {file_path}"
)
report_lines.append("-" * 56 + "+" + "-" * 50)
# Overall average
overall_avg = total_cc / total_funcs if total_funcs > 0 else 0.0
avg_funcs_per_file = total_funcs / len(results) if results else 0.0
avg_mi = (
sum(r.get("mi", 0.0) for r in results) / len(results) if results else 0.0
)
report_lines.append(
f"{total_cc:10d} {overall_avg:11.2f} {avg_mi:6.2f} Totals | {total_funcs} Functions in {len(results)} File(s)"
)
report_lines.append(
f"{'':10s} {avg_funcs_per_file:11.1f} {'':6s} Averages | {avg_funcs_per_file:.1f} Functions per File (Averages = Totals/Functions)"
)
# Add RESULTS BY FUNCTION section
report_lines.append("")
report_lines.append("")
report_lines.append(" " * 40 + "RESULTS BY FUNCTION")
report_lines.append("")
report_lines.append("Cyclomatic Complexity (CC1 = McCabe Standard)")
report_lines.append(
" CC1 Risk Function Name" + " " * 50 + "| File Name"
)
report_lines.append("-" * 98 + "+" + "-" * 23)
# Collect all functions across all files
all_functions = []
for result in results:
file_path = result.get("file", "")
if base_path:
try:
file_path = str(Path(file_path).relative_to(base_path))
except ValueError:
pass
functions = result.get("functions", [])
for func in functions:
func_name = func.get("name", "unknown")
cc = func.get("cc", 0)
# Determine risk level
if cc <= 10:
risk = "Low"
elif cc <= 20:
risk = "Medium"
elif cc <= 50:
risk = "High"
else:
risk = "Very High"
all_functions.append(
{"name": func_name, "cc": cc, "risk": risk, "file": file_path}
)
# Sort functions by CC descending (most complex first)
all_functions.sort(key=lambda x: x["cc"], reverse=True)
# Write function details
for func in all_functions:
func_name_trunc = func["name"][:60] # Limit function name length
report_lines.append(
f"{func['cc']:10d} {func['risk']:11s} {func_name_trunc:60s} | {func['file']}"
)
report_lines.append("-" * 98 + "+" + "-" * 23)
report_lines.append(
f"{total_cc:10d} Totals {total_funcs} Functions"
+ " " * 50
+ f"| {len(results)} File(s)"
)
report_lines.append(
f"{overall_avg:10.2f} Averages {avg_funcs_per_file:.1f} Functions per File (Averages = Totals/Functions)"
+ " " * 6
+ "|"
)
# If no functions were collected, add a note explaining function-level details may be missing
if total_funcs == 0:
report_lines.append("")
report_lines.append(
"NOTE: No functions were detected in the analyzed files."
)
report_lines.append(
"If you expect per-function complexity, ensure the optional dependency 'lizard' is installed in your environment."
)
report_lines.append("Install with: pip install lizard")
report_lines.append("")
report_lines.append("")
report_lines.append("")
# Write to file
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(report_lines))