Coverage for class_generator/core/coverage.py: 87%
110 statements
« prev ^ index » next coverage.py v7.10.1, created at 2025-07-29 12:31 +0300
« prev ^ index » next coverage.py v7.10.1, created at 2025-07-29 12:31 +0300
1"""Coverage analysis for generated resources vs available cluster resources."""
3import ast
4import json
5import os
6from typing import Any
8from pyhelper_utils.shell import run_command
9from rich.console import Console
10from rich.panel import Panel
11from rich.table import Table
12from simple_logger.logger import get_logger
14from class_generator.constants import GENERATED_USING_MARKER
15from class_generator.core.schema import read_resources_mapping_file
17LOGGER = get_logger(name=__name__)
20def analyze_coverage(
21 resources_dir: str = "ocp_resources",
22) -> dict[str, Any]:
23 """Analyze resource coverage by comparing schema-mapped resources vs implemented resources."""
24 # Get all resources from the schema mapping
25 resources_mapping = read_resources_mapping_file()
26 all_mapped_resources: set[str] = set()
28 # Convert schema keys to proper case (they're lowercase in schema)
29 for kind in resources_mapping:
30 # Schema keys are lowercase, need to convert to PascalCase
31 # e.g., "aaq" -> "AAQ", "pod" -> "Pod", "virtualservice" -> "VirtualService"
32 if kind.isupper():
33 # Already uppercase
34 all_mapped_resources.add(kind)
35 else:
36 # Convert to PascalCase - handle special cases
37 if "_" in kind:
38 # Handle snake_case
39 pascal_case = "".join(word.capitalize() for word in kind.split("_"))
40 else:
41 # Simple lowercase to PascalCase
42 pascal_case = kind[0].upper() + kind[1:] if kind else ""
43 all_mapped_resources.add(pascal_case)
45 # Scan implemented resources
46 generated_resources = [] # Auto-generated resources
47 manual_resources = [] # Manually created resources
49 files = []
50 try:
51 if os.path.exists(resources_dir):
52 files = os.listdir(resources_dir)
53 except Exception as e:
54 LOGGER.error(f"Failed to scan resources directory: {e}")
55 files = []
57 # Parse each Python file to find implemented resources
58 for filename in files:
59 # Skip non-Python files and special files
60 if not filename.endswith(".py") or filename == "__init__.py":
61 continue
63 # Skip utility/helper files
64 if filename in ["utils.py", "constants.py", "exceptions.py", "resource.py"]:
65 continue
67 filepath = os.path.join(resources_dir, filename)
68 if not os.path.isfile(filepath):
69 continue
71 try:
72 with open(filepath, "r") as f:
73 content = f.read()
75 # Check if file is auto-generated
76 is_generated = GENERATED_USING_MARKER in content
78 # Parse the file to find class definitions
79 tree = ast.parse(content)
81 for node in ast.walk(tree):
82 if isinstance(node, ast.ClassDef):
83 # Check if this is a resource class (not a helper/base class)
84 # Look for classes that inherit from Resource, NamespacedResource, etc.
85 for base in node.bases:
86 base_name = ""
87 if isinstance(base, ast.Name):
88 base_name = base.id
89 elif isinstance(base, ast.Attribute):
90 # Handle cases like resources.Resource
91 base_name = base.attr
93 if base_name in ["Resource", "NamespacedResource"]:
94 # Found a resource class
95 if is_generated:
96 generated_resources.append(node.name)
97 else:
98 manual_resources.append(node.name)
99 break
101 except Exception as e:
102 LOGGER.debug(f"Failed to parse {filename}: {e}")
104 # Calculate coverage based on schema mapping
105 generated_set = set(generated_resources)
106 manual_set = set(manual_resources)
108 # Create lowercase mapping for comparison
109 generated_lower = {r.lower(): r for r in generated_set}
110 all_mapped_lower = {r.lower(): r for r in all_mapped_resources}
112 # Find matches (case-insensitive)
113 matched_generated = []
114 not_generated = []
116 for mapped_lower, mapped_original in all_mapped_lower.items():
117 if mapped_lower in generated_lower:
118 matched_generated.append(generated_lower[mapped_lower])
119 else:
120 not_generated.append(mapped_original)
122 not_generated.sort()
124 # Calculate coverage statistics
125 total_in_mapping = len(all_mapped_resources)
126 total_generated = len(matched_generated)
127 coverage_percentage = (total_generated / total_in_mapping * 100) if total_in_mapping > 0 else 0
129 return {
130 "generated_resources": sorted(generated_set),
131 "manual_resources": sorted(manual_set),
132 "missing_resources": not_generated, # Resources in mapping but not generated
133 "coverage_stats": {
134 "total_in_mapping": total_in_mapping,
135 "total_generated": total_generated,
136 "total_manual": len(manual_set),
137 "coverage_percentage": coverage_percentage,
138 "missing_count": len(not_generated),
139 },
140 }
143def generate_report(coverage_data: dict[str, Any], output_format: str | None = None) -> str | None:
144 """Generate a coverage report in the specified format."""
145 stats = coverage_data["coverage_stats"]
147 if output_format == "json":
148 return json.dumps(coverage_data, indent=2)
150 # Default behavior - console output
151 console = Console()
153 # Create summary table
154 summary_table = Table(title="Resource Coverage Summary", show_header=False)
155 summary_table.add_column("Metric", style="bold")
156 summary_table.add_column("Value", justify="right")
158 summary_table.add_row("Total Resources in Schema", str(stats["total_in_mapping"]))
159 summary_table.add_row("Auto-Generated Resources", str(stats["total_generated"]))
160 summary_table.add_row("Coverage", f"{stats['coverage_percentage']:.1f}%")
161 summary_table.add_row("", "") # Empty row for separation
162 summary_table.add_row("Missing (Not Generated)", str(stats["missing_count"]))
163 summary_table.add_row("Manual Implementations", str(stats["total_manual"]))
165 console.print(Panel(summary_table, title="Coverage Analysis", border_style="green"))
167 # Show missing resources if any
168 if coverage_data["missing_resources"]:
169 missing_table = Table(title="Resources Not Yet Generated")
170 missing_table.add_column("Resource Kind", style="red")
172 for resource in coverage_data["missing_resources"][:20]: # Show first 20
173 # Handle both string and dict formats
174 if isinstance(resource, dict):
175 kind = resource.get("kind", str(resource))
176 else:
177 kind = str(resource)
178 missing_table.add_row(kind)
180 if len(coverage_data["missing_resources"]) > 20:
181 missing_table.add_row(f"... and {len(coverage_data['missing_resources']) - 20} more")
183 console.print(missing_table)
185 # Print ready-to-use commands
186 if coverage_data["missing_resources"]:
187 console.print("\n[bold]Generate missing resources with:[/bold]")
188 for resource in coverage_data["missing_resources"][:5]:
189 if isinstance(resource, dict):
190 kind = resource.get("kind", resource)
191 else:
192 kind = resource
193 console.print(f" class-generator -k {kind}")
194 if len(coverage_data["missing_resources"]) > 5:
195 console.print(f" ... and {len(coverage_data['missing_resources']) - 5} more")
197 return None