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
« 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"""
6import json
7import logging
8import re
9from dataclasses import dataclass, field
10from pathlib import Path
11from typing import Any, Dict, List
13from .models import FileModel
16@dataclass
17class Config:
18 """Configuration class for C to PlantUML converter"""
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
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
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)
39 # Transformations
40 transformations: Dict[str, Any] = field(default_factory=dict)
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)
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__)
51 # Initialize with default values first
52 object.__init__(self)
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 = {}
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)
105 # Compile patterns after initialization
106 self._compile_patterns()
108 def __post_init__(self):
109 """Compile regex patterns after initialization"""
110 self._compile_patterns()
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
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
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}")
139 try:
140 with open(config_file, "r", encoding="utf-8") as f:
141 data = json.load(f)
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")
147 # Enhanced validation for source_folders
148 if "source_folders" not in data:
149 raise ValueError("Configuration must contain 'source_folders' field")
151 if not isinstance(data["source_folders"], list):
152 raise ValueError(f"'source_folders' must be a list, got: {type(data['source_folders'])}")
154 if not data["source_folders"]:
155 raise ValueError("'source_folders' list cannot be empty")
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)}")
164 return cls(**data)
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
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 }
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
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)
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
216 # If no include patterns, include all files (after exclusions)
217 if not self.file_include_patterns:
218 return True
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
225 return False
231 def __eq__(self, other: Any) -> bool:
232 """Check if two configurations are equal"""
233 if not isinstance(other, Config):
234 return False
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 )
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 )