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

142 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-20 03:53 +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 elif len(kwargs) == 1 and isinstance(next(iter(kwargs.values())), dict): 

100 # Handle case where a single dict is passed as keyword argument 

101 data = next(iter(kwargs.values())) 

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

103 if hasattr(self, key): 

104 setattr(self, key, value) 

105 else: 

106 # Handle normal keyword arguments 

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

108 if hasattr(self, key): 

109 setattr(self, key, value) 

110 

111 # Compile patterns after initialization 

112 self._compile_patterns() 

113 

114 def __post_init__(self): 

115 """Compile regex patterns after initialization""" 

116 self._compile_patterns() 

117 

118 def _compile_patterns(self): 

119 """Compile regex patterns for filtering""" 

120 # Compile file filter patterns with error handling 

121 self.file_include_patterns = [] 

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

123 try: 

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

125 except re.error as e: 

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

127 # Skip invalid patterns 

128 

129 self.file_exclude_patterns = [] 

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

131 try: 

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

133 except re.error as e: 

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

135 # Skip invalid patterns 

136 

137 

138 

139 @classmethod 

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

141 """Load configuration from JSON file""" 

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

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

144 

145 try: 

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

147 data = json.load(f) 

148 

149 # Handle backward compatibility: project_roots -> source_folders 

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

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

152 

153 # Enhanced validation for source_folders 

154 if "source_folders" not in data: 

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

156 

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

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

159 

160 if not data["source_folders"]: 

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

162 

163 # Validate each source folder 

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

165 if not isinstance(folder, str): 

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

167 if not folder.strip(): 

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

169 

170 return cls(**data) 

171 

172 except json.JSONDecodeError as e: 

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

174 except Exception as e: 

175 raise ValueError( 

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

177 ) from e 

178 

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

180 """Save configuration to JSON file""" 

181 data = { 

182 "project_name": self.project_name, 

183 "source_folders": self.source_folders, 

184 "output_dir": self.output_dir, 

185 "model_output_path": self.model_output_path, 

186 "recursive_search": self.recursive_search, 

187 "include_depth": self.include_depth, 

188 "include_filter_local_only": self.include_filter_local_only, 

189 "always_show_includes": self.always_show_includes, 

190 "convert_empty_class_to_artifact": self.convert_empty_class_to_artifact, 

191 "max_function_signature_chars": self.max_function_signature_chars, 

192 "hide_macro_values": self.hide_macro_values, 

193 "file_filters": self.file_filters, 

194 "file_specific": self.file_specific, 

195 "transformations": self.transformations, 

196 } 

197 

198 try: 

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

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

201 except Exception as e: 

202 raise ValueError( 

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

204 ) from e 

205 

206 def has_filters(self) -> bool: 

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

208 # Check if any file has include_filter defined in file_specific 

209 has_include_filters = any( 

210 file_config.get("include_filter") 

211 for file_config in self.file_specific.values() 

212 ) 

213 return bool(self.file_filters or has_include_filters) 

214 

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

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

217 # Check exclude patterns first 

218 for pattern in self.file_exclude_patterns: 

219 if pattern.search(file_path): 

220 return False 

221 

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

223 if not self.file_include_patterns: 

224 return True 

225 

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

227 for pattern in self.file_include_patterns: 

228 if pattern.search(file_path): 

229 return True 

230 

231 return False 

232 

233 

234 

235 

236 

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

238 """Check if two configurations are equal""" 

239 if not isinstance(other, Config): 

240 return False 

241 

242 return ( 

243 self.project_name == other.project_name 

244 and self.source_folders == other.source_folders 

245 and self.output_dir == other.output_dir 

246 and self.model_output_path == other.model_output_path 

247 and self.recursive_search == other.recursive_search 

248 and self.include_depth == other.include_depth 

249 and self.include_filter_local_only == other.include_filter_local_only 

250 and self.always_show_includes == other.always_show_includes 

251 and self.convert_empty_class_to_artifact == other.convert_empty_class_to_artifact 

252 and self.hide_macro_values == other.hide_macro_values 

253 and self.file_filters == other.file_filters 

254 and self.file_specific == other.file_specific 

255 and self.transformations == other.transformations 

256 ) 

257 

258 def __repr__(self) -> str: 

259 """String representation of the configuration""" 

260 return ( 

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

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

263 )