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

1"""Coverage analysis for generated resources vs available cluster resources.""" 

2 

3import ast 

4import json 

5import os 

6from typing import Any 

7 

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 

13 

14from class_generator.constants import GENERATED_USING_MARKER 

15from class_generator.core.schema import read_resources_mapping_file 

16 

17LOGGER = get_logger(name=__name__) 

18 

19 

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() 

27 

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) 

44 

45 # Scan implemented resources 

46 generated_resources = [] # Auto-generated resources 

47 manual_resources = [] # Manually created resources 

48 

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 = [] 

56 

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 

62 

63 # Skip utility/helper files 

64 if filename in ["utils.py", "constants.py", "exceptions.py", "resource.py"]: 

65 continue 

66 

67 filepath = os.path.join(resources_dir, filename) 

68 if not os.path.isfile(filepath): 

69 continue 

70 

71 try: 

72 with open(filepath, "r") as f: 

73 content = f.read() 

74 

75 # Check if file is auto-generated 

76 is_generated = GENERATED_USING_MARKER in content 

77 

78 # Parse the file to find class definitions 

79 tree = ast.parse(content) 

80 

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 

92 

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 

100 

101 except Exception as e: 

102 LOGGER.debug(f"Failed to parse {filename}: {e}") 

103 

104 # Calculate coverage based on schema mapping 

105 generated_set = set(generated_resources) 

106 manual_set = set(manual_resources) 

107 

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} 

111 

112 # Find matches (case-insensitive) 

113 matched_generated = [] 

114 not_generated = [] 

115 

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) 

121 

122 not_generated.sort() 

123 

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 

128 

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 } 

141 

142 

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"] 

146 

147 if output_format == "json": 

148 return json.dumps(coverage_data, indent=2) 

149 

150 # Default behavior - console output 

151 console = Console() 

152 

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") 

157 

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"])) 

164 

165 console.print(Panel(summary_table, title="Coverage Analysis", border_style="green")) 

166 

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") 

171 

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) 

179 

180 if len(coverage_data["missing_resources"]) > 20: 

181 missing_table.add_row(f"... and {len(coverage_data['missing_resources']) - 20} more") 

182 

183 console.print(missing_table) 

184 

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") 

196 

197 return None