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
« 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"""
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 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)
111 # Compile patterns after initialization
112 self._compile_patterns()
114 def __post_init__(self):
115 """Compile regex patterns after initialization"""
116 self._compile_patterns()
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
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
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}")
145 try:
146 with open(config_file, "r", encoding="utf-8") as f:
147 data = json.load(f)
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")
153 # Enhanced validation for source_folders
154 if "source_folders" not in data:
155 raise ValueError("Configuration must contain 'source_folders' field")
157 if not isinstance(data["source_folders"], list):
158 raise ValueError(f"'source_folders' must be a list, got: {type(data['source_folders'])}")
160 if not data["source_folders"]:
161 raise ValueError("'source_folders' list cannot be empty")
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)}")
170 return cls(**data)
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
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 }
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
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)
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
222 # If no include patterns, include all files (after exclusions)
223 if not self.file_include_patterns:
224 return True
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
231 return False
237 def __eq__(self, other: Any) -> bool:
238 """Check if two configurations are equal"""
239 if not isinstance(other, Config):
240 return False
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 )
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 )