Coverage for src/c2puml/config.py: 54%

137 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-14 18:23 +0000

1#!/usr/bin/env python3 

2""" 

3Configuration management for C to PlantUML converter 

4""" 

5 

6import json 

7import logging 

8import re 

9from dataclasses import dataclass, field 

10from pathlib import Path 

11from typing import Any, Dict, List 

12 

13from .models import FileModel 

14 

15 

16@dataclass 

17class Config: 

18 """Configuration class for C to PlantUML converter""" 

19 

20 # Configuration settings 

21 project_name: str = "Unknown_Project" 

22 source_folders: List[str] = field(default_factory=list) 

23 output_dir: str = "./output" 

24 model_output_path: str = "model.json" 

25 recursive_search: bool = True 

26 include_depth: int = 1 

27 include_filter_local_only: bool = False 

28 always_show_includes: bool = False 

29 convert_empty_class_to_artifact: bool = False 

30 

31 # Generator formatting options 

32 max_function_signature_chars: int = 0 # 0 or less means unlimited (no truncation) 

33 hide_macro_values: bool = False # Hide macro values in generated PlantUML diagrams 

34 

35 # Filters 

36 file_filters: Dict[str, List[str]] = field(default_factory=dict) 

37 file_specific: Dict[str, Dict[str, Any]] = field(default_factory=dict) 

38 

39 # Transformations 

40 transformations: Dict[str, Any] = field(default_factory=dict) 

41 

42 # Compiled patterns for performance 

43 file_include_patterns: List[re.Pattern] = field(default_factory=list) 

44 file_exclude_patterns: List[re.Pattern] = field(default_factory=list) 

45 

46 def __init__(self, *args, **kwargs): 

47 """Initialize configuration with keyword arguments or a single dict""" 

48 # Initialize logger 

49 self.logger = logging.getLogger(__name__) 

50 

51 # Initialize with default values first 

52 object.__init__(self) 

53 

54 # Ensure all dataclass fields are initialized with defaults 

55 if not hasattr(self, "project_name"): 

56 self.project_name = "Unknown_Project" 

57 if not hasattr(self, "source_folders"): 

58 self.source_folders = [] 

59 if not hasattr(self, "output_dir"): 

60 self.output_dir = "./output" 

61 if not hasattr(self, "model_output_path"): 

62 self.model_output_path = "model.json" 

63 if not hasattr(self, "recursive_search"): 

64 self.recursive_search = True 

65 if not hasattr(self, "include_depth"): 

66 self.include_depth = 1 

67 if not hasattr(self, "include_filter_local_only"): 

68 self.include_filter_local_only = False 

69 if not hasattr(self, "always_show_includes"): 

70 self.always_show_includes = False 

71 if not hasattr(self, "convert_empty_class_to_artifact"): 

72 self.convert_empty_class_to_artifact = False 

73 if not hasattr(self, "max_function_signature_chars"): 

74 self.max_function_signature_chars = 0 

75 if not hasattr(self, "hide_macro_values"): 

76 self.hide_macro_values = False 

77 if not hasattr(self, "file_filters"): 

78 self.file_filters = {} 

79 if not hasattr(self, "file_specific"): 

80 self.file_specific = {} 

81 if not hasattr(self, "transformations"): 

82 self.transformations = {} 

83 if not hasattr(self, "file_include_patterns"): 

84 self.file_include_patterns = [] 

85 if not hasattr(self, "file_exclude_patterns"): 

86 self.file_exclude_patterns = [] 

87 if not hasattr(self, "element_include_patterns"): 

88 self.element_include_patterns = {} 

89 if not hasattr(self, "element_exclude_patterns"): 

90 self.element_exclude_patterns = {} 

91 

92 if len(args) == 1 and isinstance(args[0], dict): 

93 # Handle case where a single dict is passed as positional argument 

94 data = args[0] 

95 # Set attributes manually 

96 for key, value in data.items(): 

97 if hasattr(self, key): 

98 setattr(self, key, value) 

99 else: 

100 # Handle normal keyword arguments 

101 for key, value in kwargs.items(): 

102 if hasattr(self, key): 

103 setattr(self, key, value) 

104 

105 # Compile patterns after initialization 

106 self._compile_patterns() 

107 

108 def __post_init__(self): 

109 """Compile regex patterns after initialization""" 

110 self._compile_patterns() 

111 

112 def _compile_patterns(self): 

113 """Compile regex patterns for filtering""" 

114 # Compile file filter patterns with error handling 

115 self.file_include_patterns = [] 

116 for pattern in self.file_filters.get("include", []): 

117 try: 

118 self.file_include_patterns.append(re.compile(pattern)) 

119 except re.error as e: 

120 self.logger.warning("Invalid include pattern '%s': %s", pattern, e) 

121 # Skip invalid patterns 

122 

123 self.file_exclude_patterns = [] 

124 for pattern in self.file_filters.get("exclude", []): 

125 try: 

126 self.file_exclude_patterns.append(re.compile(pattern)) 

127 except re.error as e: 

128 self.logger.warning("Invalid exclude pattern '%s': %s", pattern, e) 

129 # Skip invalid patterns 

130 

131 

132 

133 @classmethod 

134 def load(cls, config_file: str) -> "Config": 

135 """Load configuration from JSON file""" 

136 if not Path(config_file).exists(): 

137 raise FileNotFoundError(f"Configuration file not found: {config_file}") 

138 

139 try: 

140 with open(config_file, "r", encoding="utf-8") as f: 

141 data = json.load(f) 

142 

143 # Handle backward compatibility: project_roots -> source_folders 

144 if "project_roots" in data and "source_folders" not in data: 

145 data["source_folders"] = data.pop("project_roots") 

146 

147 # Enhanced validation for source_folders 

148 if "source_folders" not in data: 

149 raise ValueError("Configuration must contain 'source_folders' field") 

150 

151 if not isinstance(data["source_folders"], list): 

152 raise ValueError(f"'source_folders' must be a list, got: {type(data['source_folders'])}") 

153 

154 if not data["source_folders"]: 

155 raise ValueError("'source_folders' list cannot be empty") 

156 

157 # Validate each source folder 

158 for i, folder in enumerate(data["source_folders"]): 

159 if not isinstance(folder, str): 

160 raise ValueError(f"Source folder at index {i} must be a string, got: {type(folder)}") 

161 if not folder.strip(): 

162 raise ValueError(f"Source folder at index {i} cannot be empty or whitespace: {repr(folder)}") 

163 

164 return cls(**data) 

165 

166 except json.JSONDecodeError as e: 

167 raise ValueError(f"Invalid JSON in configuration file {config_file}: {e}") 

168 except Exception as e: 

169 raise ValueError( 

170 f"Failed to load configuration from {config_file}: {e}" 

171 ) from e 

172 

173 def save(self, config_file: str) -> None: 

174 """Save configuration to JSON file""" 

175 data = { 

176 "project_name": self.project_name, 

177 "source_folders": self.source_folders, 

178 "output_dir": self.output_dir, 

179 "model_output_path": self.model_output_path, 

180 "recursive_search": self.recursive_search, 

181 "include_depth": self.include_depth, 

182 "include_filter_local_only": self.include_filter_local_only, 

183 "always_show_includes": self.always_show_includes, 

184 "convert_empty_class_to_artifact": self.convert_empty_class_to_artifact, 

185 "max_function_signature_chars": self.max_function_signature_chars, 

186 "hide_macro_values": self.hide_macro_values, 

187 "file_filters": self.file_filters, 

188 "file_specific": self.file_specific, 

189 "transformations": self.transformations, 

190 } 

191 

192 try: 

193 with open(config_file, "w", encoding="utf-8") as f: 

194 json.dump(data, f, indent=2, ensure_ascii=False) 

195 except Exception as e: 

196 raise ValueError( 

197 f"Failed to save configuration to {config_file}: {e}" 

198 ) from e 

199 

200 def has_filters(self) -> bool: 

201 """Check if configuration has any filters defined""" 

202 # Check if any file has include_filter defined in file_specific 

203 has_include_filters = any( 

204 file_config.get("include_filter") 

205 for file_config in self.file_specific.values() 

206 ) 

207 return bool(self.file_filters or has_include_filters) 

208 

209 def _should_include_file(self, file_path: str) -> bool: 

210 """Check if a file should be included based on filters""" 

211 # Check exclude patterns first 

212 for pattern in self.file_exclude_patterns: 

213 if pattern.search(file_path): 

214 return False 

215 

216 # If no include patterns, include all files (after exclusions) 

217 if not self.file_include_patterns: 

218 return True 

219 

220 # Check include patterns - file must match at least one 

221 for pattern in self.file_include_patterns: 

222 if pattern.search(file_path): 

223 return True 

224 

225 return False 

226 

227 

228 

229 

230 

231 def __eq__(self, other: Any) -> bool: 

232 """Check if two configurations are equal""" 

233 if not isinstance(other, Config): 

234 return False 

235 

236 return ( 

237 self.project_name == other.project_name 

238 and self.source_folders == other.source_folders 

239 and self.output_dir == other.output_dir 

240 and self.model_output_path == other.model_output_path 

241 and self.recursive_search == other.recursive_search 

242 and self.include_depth == other.include_depth 

243 and self.include_filter_local_only == other.include_filter_local_only 

244 and self.always_show_includes == other.always_show_includes 

245 and self.convert_empty_class_to_artifact == other.convert_empty_class_to_artifact 

246 and self.hide_macro_values == other.hide_macro_values 

247 and self.file_filters == other.file_filters 

248 and self.file_specific == other.file_specific 

249 and self.transformations == other.transformations 

250 ) 

251 

252 def __repr__(self) -> str: 

253 """String representation of the configuration""" 

254 return ( 

255 f"Config(project_name='{self.project_name}', " 

256 f"source_folders={self.source_folders})" 

257 )