Coverage for src / ezcompiler / utils / config_utils.py: 46.54%

113 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-27 06:49 +0000

1# /////////////////////////////////////////////////////////////// 

2# CONFIG_UTILS - Configuration-specific utility functions 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Configuration utilities - Configuration-specific utility functions for EzCompiler. 

8 

9This module provides specialized utility functions for configuration validation 

10and processing. Uses thematic utils (ValidationUtils, FileUtils) internally. 

11 

12Note: These utilities are intended for use by services and other layers that 

13need to validate or process CompilerConfig instances. The CompilerConfig class 

14itself performs its own validation during initialization. 

15 

16Utils layer can only use DEBUG and ERROR log levels. 

17""" 

18 

19from __future__ import annotations 

20 

21# /////////////////////////////////////////////////////////////// 

22# IMPORTS 

23# /////////////////////////////////////////////////////////////// 

24# Standard library imports 

25import json 

26import tomllib 

27from pathlib import Path 

28from typing import Any 

29 

30# Third-party imports 

31import yaml 

32 

33# Local imports 

34from ..shared.compiler_config import CompilerConfig 

35from ..shared.exceptions.utils.config_exceptions import ( 

36 CompilerOptionError, 

37 ConfigFileNotFoundError, 

38 ConfigFileParseError, 

39 ConfigPathError, 

40 MissingRequiredConfigError, 

41) 

42from .file_utils import FileUtils 

43from .validators import validate_string_length 

44 

45# /////////////////////////////////////////////////////////////// 

46# CLASSES 

47# /////////////////////////////////////////////////////////////// 

48 

49 

50class ConfigUtils: 

51 """ 

52 Utility class for configuration-specific operations. 

53 

54 Provides static methods for configuration validation and processing. 

55 Uses thematic utils (ValidationUtils, FileUtils) internally. 

56 

57 Example: 

58 >>> config = CompilerConfig(...) 

59 >>> ConfigUtils.validate_required_config_fields(config) 

60 >>> ConfigUtils.validate_config_paths(config) 

61 >>> ConfigUtils.validate_compiler_option(config.compiler) 

62 """ 

63 

64 # //////////////////////////////////////////////// 

65 # VALIDATION METHODS 

66 # //////////////////////////////////////////////// 

67 

68 @staticmethod 

69 def validate_required_config_fields(config: CompilerConfig) -> None: 

70 """ 

71 Validate required configuration fields are not empty. 

72 

73 Args: 

74 config: CompilerConfig instance to validate 

75 

76 Raises: 

77 ConfigurationError: If any required field is empty 

78 

79 Note: 

80 Uses ValidationUtils for string validation. 

81 

82 Example: 

83 >>> config = CompilerConfig(...) 

84 >>> ConfigUtils.validate_required_config_fields(config) 

85 """ 

86 if not config.version: 

87 raise MissingRequiredConfigError("Version cannot be empty") 

88 

89 if not validate_string_length(config.project_name, min_length=1): 

90 raise MissingRequiredConfigError("Project name cannot be empty") 

91 

92 if not validate_string_length(config.main_file, min_length=1): 

93 raise MissingRequiredConfigError("Main file cannot be empty") 

94 

95 if not config.include_files: 

96 raise MissingRequiredConfigError("Include files cannot be empty") 

97 

98 @staticmethod 

99 def validate_config_paths(config: CompilerConfig) -> None: 

100 """ 

101 Validate file and folder paths in configuration. 

102 

103 Ensures main file exists and normalizes output_folder to Path. 

104 Does not create the output folder (that's done during compilation). 

105 

106 Args: 

107 config: CompilerConfig instance to validate 

108 

109 Raises: 

110 ConfigurationError: If main file doesn't exist 

111 

112 Note: 

113 Uses FileUtils for file existence checks. 

114 

115 Example: 

116 >>> config = CompilerConfig(..., main_file="main.py", output_folder="dist") 

117 >>> ConfigUtils.validate_config_paths(config) 

118 """ 

119 if not FileUtils.validate_file_exists(config.main_file): 

120 raise ConfigPathError(f"Main file not found: {config.main_file}") 

121 

122 # Normalize output_folder to Path if it's a string 

123 if isinstance(config.output_folder, str): 

124 config.output_folder = Path(config.output_folder) 

125 

126 @staticmethod 

127 def validate_compiler_option(compiler: str) -> None: 

128 """ 

129 Validate compiler option value. 

130 

131 Args: 

132 compiler: Compiler name to validate 

133 

134 Raises: 

135 ConfigurationError: If compiler is not valid 

136 

137 Example: 

138 >>> ConfigUtils.validate_compiler_option("PyInstaller") 

139 >>> ConfigUtils.validate_compiler_option("invalid") 

140 ConfigurationError: Invalid compiler: invalid 

141 """ 

142 valid_compilers = ["auto", "Cx_Freeze", "PyInstaller", "Nuitka"] 

143 if compiler not in valid_compilers: 

144 raise CompilerOptionError( 

145 f"Invalid compiler: {compiler}. Must be one of {valid_compilers}" 

146 ) 

147 

148 @staticmethod 

149 def normalize_output_folder(output_folder: str | Path) -> Path: 

150 """ 

151 Normalize output folder path to Path object. 

152 

153 Args: 

154 output_folder: Output folder as string or Path 

155 

156 Returns: 

157 Path: Normalized Path object 

158 

159 Example: 

160 >>> folder = ConfigUtils.normalize_output_folder("dist") 

161 >>> print(type(folder)) 

162 <class 'pathlib.Path'> 

163 """ 

164 if isinstance(output_folder, str): 

165 return Path(output_folder) 

166 return output_folder 

167 

168 # //////////////////////////////////////////////// 

169 # FILE LOADING METHODS 

170 # //////////////////////////////////////////////// 

171 

172 @staticmethod 

173 def load_yaml_config(path: Path) -> dict[str, Any]: 

174 """ 

175 Load configuration from a YAML file. 

176 

177 Args: 

178 path: Path to the YAML file 

179 

180 Returns: 

181 dict[str, Any]: Parsed configuration dictionary 

182 

183 Raises: 

184 ConfigFileNotFoundError: If the file does not exist 

185 ConfigFileParseError: If the file cannot be parsed 

186 """ 

187 if not path.exists(): 

188 raise ConfigFileNotFoundError(f"YAML config file not found: {path}") 

189 try: 

190 with open(path, encoding="utf-8") as f: 

191 data = yaml.safe_load(f) 

192 return data if isinstance(data, dict) else {} 

193 except yaml.YAMLError as e: 

194 raise ConfigFileParseError(f"Failed to parse YAML file {path}: {e}") from e 

195 

196 @staticmethod 

197 def load_json_config(path: Path) -> dict[str, Any]: 

198 """ 

199 Load configuration from a JSON file. 

200 

201 Args: 

202 path: Path to the JSON file 

203 

204 Returns: 

205 dict[str, Any]: Parsed configuration dictionary 

206 

207 Raises: 

208 ConfigFileNotFoundError: If the file does not exist 

209 ConfigFileParseError: If the file cannot be parsed 

210 """ 

211 if not path.exists(): 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 raise ConfigFileNotFoundError(f"JSON config file not found: {path}") 

213 try: 

214 with open(path, encoding="utf-8") as f: 

215 data = json.load(f) 

216 return data if isinstance(data, dict) else {} 

217 except json.JSONDecodeError as e: 

218 raise ConfigFileParseError(f"Failed to parse JSON file {path}: {e}") from e 

219 

220 @staticmethod 

221 def load_toml_config(path: Path) -> dict[str, Any]: 

222 """ 

223 Load and return the full parsed TOML dictionary. 

224 

225 Args: 

226 path: Path to the TOML file 

227 

228 Returns: 

229 dict[str, Any]: Parsed TOML dictionary 

230 

231 Raises: 

232 ConfigFileNotFoundError: If the file does not exist 

233 ConfigFileParseError: If the file cannot be parsed 

234 """ 

235 if not path.exists(): 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true

236 raise ConfigFileNotFoundError(f"TOML config file not found: {path}") 

237 try: 

238 with open(path, "rb") as f: 

239 return tomllib.load(f) 

240 except Exception as e: 

241 raise ConfigFileParseError(f"Failed to parse TOML file {path}: {e}") from e 

242 

243 # //////////////////////////////////////////////// 

244 # PYPROJECT.TOML EXTRACTION 

245 # //////////////////////////////////////////////// 

246 

247 @staticmethod 

248 def extract_pyproject_config(toml_data: dict[str, Any]) -> dict[str, Any]: 

249 """ 

250 Extract ezcompiler configuration from pyproject.toml structure. 

251 

252 Maps [project] fields (name, version, description, authors) and 

253 [tool.ezcompiler] fields into the flat config dict format expected 

254 by CompilerConfig.from_dict(). 

255 

256 Args: 

257 toml_data: Full parsed pyproject.toml dictionary 

258 

259 Returns: 

260 dict[str, Any]: Extracted configuration dictionary (may be empty) 

261 """ 

262 result: dict[str, Any] = {} 

263 

264 # Extract from [project] section 

265 project = toml_data.get("project", {}) 

266 if "name" in project: 266 ↛ 268line 266 didn't jump to line 268 because the condition on line 266 was always true

267 result["project_name"] = project["name"] 

268 if "version" in project: 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was always true

269 result["version"] = project["version"] 

270 if "description" in project: 270 ↛ 272line 270 didn't jump to line 272 because the condition on line 270 was always true

271 result["project_description"] = project["description"] 

272 if "authors" in project and project["authors"]: 272 ↛ 280line 272 didn't jump to line 280 because the condition on line 272 was always true

273 first_author = project["authors"][0] 

274 if isinstance(first_author, dict): 274 ↛ 280line 274 didn't jump to line 280 because the condition on line 274 was always true

275 author_name = first_author.get("name", "") 

276 result["author"] = author_name 

277 result["company_name"] = author_name 

278 

279 # Extract from [tool.ezcompiler] section 

280 tool_config = toml_data.get("tool", {}).get("ezcompiler", {}) 

281 if tool_config: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 result.update(tool_config) 

283 

284 return result 

285 

286 # //////////////////////////////////////////////// 

287 # CONFIG DISCOVERY 

288 # //////////////////////////////////////////////// 

289 

290 @staticmethod 

291 def discover_config_file(search_dir: Path) -> Path | None: 

292 """ 

293 Auto-discover configuration file in the given directory. 

294 

295 Priority order: 

296 1. ezcompiler.yaml 

297 2. ezcompiler.json 

298 3. pyproject.toml (only if [tool.ezcompiler] section exists) 

299 

300 Args: 

301 search_dir: Directory to search in 

302 

303 Returns: 

304 Path | None: Path to discovered config file, or None if not found 

305 """ 

306 # Check YAML first 

307 yaml_path = search_dir / "ezcompiler.yaml" 

308 if yaml_path.exists(): 

309 return yaml_path 

310 

311 # Check JSON 

312 json_path = search_dir / "ezcompiler.json" 

313 if json_path.exists(): 

314 return json_path 

315 

316 # Check pyproject.toml (only if it has [tool.ezcompiler]) 

317 toml_path = search_dir / "pyproject.toml" 

318 if toml_path.exists(): 

319 try: 

320 with open(toml_path, "rb") as f: 

321 data = tomllib.load(f) 

322 if data.get("tool", {}).get("ezcompiler"): 

323 return toml_path 

324 except Exception: # noqa: S110 

325 pass 

326 

327 return None 

328 

329 # //////////////////////////////////////////////// 

330 # CONFIG MERGING 

331 # //////////////////////////////////////////////// 

332 

333 @staticmethod 

334 def merge_config_dicts( 

335 base: dict[str, Any], override: dict[str, Any] 

336 ) -> dict[str, Any]: 

337 """ 

338 Merge two configuration dictionaries. 

339 

340 Override replaces base values. For known nested sections 

341 (compilation, upload, advanced), keys are merged within the section. 

342 All other values (including include_files, lists) are replaced entirely. 

343 

344 Args: 

345 base: Base configuration dictionary 

346 override: Override configuration dictionary 

347 

348 Returns: 

349 dict[str, Any]: Merged configuration dictionary 

350 """ 

351 result = base.copy() 

352 nested_sections = {"compilation", "upload", "advanced"} 

353 

354 for key, value in override.items(): 

355 if ( 355 ↛ 361line 355 didn't jump to line 361 because the condition on line 355 was never true

356 key in nested_sections 

357 and key in result 

358 and isinstance(result[key], dict) 

359 and isinstance(value, dict) 

360 ): 

361 result[key] = {**result[key], **value} 

362 else: 

363 result[key] = value 

364 

365 return result