Coverage for src / ezcompiler / shared / compiler_config.py: 88.43%

93 statements  

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

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

2# COMPILER_CONFIG - Configuration dataclass 

3# Project: ezcompiler 

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

5 

6""" 

7Compiler configuration - Configuration dataclass for EzCompiler. 

8 

9This module provides the CompilerConfig dataclass for centralizing all 

10configuration parameters needed for project compilation, versioning, 

11packaging, and distribution. 

12""" 

13 

14from __future__ import annotations 

15 

16# /////////////////////////////////////////////////////////////// 

17# IMPORTS 

18# /////////////////////////////////////////////////////////////// 

19# Standard library imports 

20from dataclasses import dataclass, field 

21from pathlib import Path 

22from typing import Any 

23 

24# Local imports 

25from .exceptions import ConfigurationError 

26 

27# /////////////////////////////////////////////////////////////// 

28# CLASSES 

29# /////////////////////////////////////////////////////////////// 

30 

31 

32@dataclass 

33class CompilerConfig: 

34 """ 

35 Configuration class for project compilation. 

36 

37 Centralizes all configuration parameters needed for project 

38 compilation, version generation, packaging, and distribution. 

39 Validates configuration on initialization and provides helper 

40 properties for file paths. 

41 

42 Attributes: 

43 version: Project version (e.g., "1.0.0") 

44 project_name: Name of the project 

45 main_file: Path to main Python file 

46 include_files: Dict with 'files' and 'folders' lists 

47 output_folder: Path to output directory 

48 version_filename: Name of version info file (default: "version_info.txt") 

49 project_description: Project description 

50 company_name: Company or organization name 

51 author: Project author 

52 icon: Path to project icon 

53 packages: List of Python packages to include 

54 includes: List of modules to include 

55 excludes: List of modules to exclude 

56 console: Show console window in compiled app (default: True) 

57 compiler: Compiler to use - "auto", "Cx_Freeze", "PyInstaller", "Nuitka" 

58 zip_needed: Create zip archive (default: True) 

59 repo_needed: Use repository (default: False) 

60 upload_structure: Upload target - "disk" or "server" 

61 repo_path: Repository path (default: "releases") 

62 server_url: Server upload URL 

63 optimize: Optimize code (default: True) 

64 strip: Strip debug info (default: False) 

65 debug: Enable debug mode (default: False) 

66 compiler_options: Compiler-specific options dict (default: {}) 

67 

68 Example: 

69 >>> config = CompilerConfig( 

70 ... version="1.0.0", 

71 ... project_name="MyApp", 

72 ... main_file="main.py", 

73 ... include_files={"files": ["config.yaml"], "folders": ["lib"]}, 

74 ... output_folder=Path("dist") 

75 ... ) 

76 >>> config_dict = config.to_dict() 

77 """ 

78 

79 # //////////////////////////////////////////////// 

80 # REQUIRED FIELDS 

81 # //////////////////////////////////////////////// 

82 

83 version: str 

84 project_name: str 

85 main_file: str 

86 include_files: dict[str, list[str]] 

87 output_folder: Path 

88 

89 # //////////////////////////////////////////////// 

90 # OPTIONAL FIELDS WITH DEFAULTS 

91 # //////////////////////////////////////////////// 

92 

93 version_filename: str = "version_info.txt" 

94 project_description: str = "" 

95 company_name: str = "" 

96 author: str = "" 

97 icon: str = "" 

98 packages: list[str] = field(default_factory=list) 

99 includes: list[str] = field(default_factory=list) 

100 excludes: list[str] = field(default_factory=list) 

101 

102 # //////////////////////////////////////////////// 

103 # COMPILATION OPTIONS 

104 # //////////////////////////////////////////////// 

105 

106 console: bool = True 

107 compiler: str = "auto" # "auto", "Cx_Freeze", "PyInstaller", "Nuitka" 

108 zip_needed: bool = True 

109 repo_needed: bool = False 

110 

111 # //////////////////////////////////////////////// 

112 # UPLOAD OPTIONS 

113 # //////////////////////////////////////////////// 

114 

115 upload_structure: str = "disk" # "disk" or "server" 

116 repo_path: str = "releases" 

117 server_url: str = "" 

118 

119 # //////////////////////////////////////////////// 

120 # ADVANCED OPTIONS 

121 # //////////////////////////////////////////////// 

122 

123 optimize: bool = True 

124 strip: bool = False 

125 debug: bool = False 

126 

127 # //////////////////////////////////////////////// 

128 # COMPILER-SPECIFIC OPTIONS 

129 # //////////////////////////////////////////////// 

130 

131 compiler_options: dict[str, Any] = field(default_factory=dict) 

132 

133 # //////////////////////////////////////////////// 

134 # INITIALIZATION AND VALIDATION 

135 # //////////////////////////////////////////////// 

136 

137 def __post_init__(self) -> None: 

138 """ 

139 Validate configuration after initialization. 

140 

141 Called automatically after __init__ to validate all fields 

142 and ensure configuration is valid before use. 

143 

144 Raises: 

145 ConfigurationError: If any validation fails 

146 """ 

147 self._validate_required_fields() 

148 self._validate_include_files() 

149 self._validate_paths() 

150 self._validate_compiler_option() 

151 

152 def _validate_required_fields(self) -> None: 

153 """ 

154 Validate required fields are not empty. 

155 

156 Raises: 

157 ConfigurationError: If any required field is empty 

158 """ 

159 if not self.version: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 raise ConfigurationError("Version cannot be empty") 

161 if not self.project_name: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 raise ConfigurationError("Project name cannot be empty") 

163 if not self.main_file: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 raise ConfigurationError("Main file cannot be empty") 

165 

166 def _validate_include_files(self) -> None: 

167 """ 

168 Validate and normalize include_files payload. 

169 

170 Expected format: 

171 {"files": ["..."], "folders": ["..."]} 

172 

173 Raises: 

174 ConfigurationError: If include_files structure is invalid 

175 """ 

176 if not isinstance(self.include_files, dict): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 raise ConfigurationError("include_files must be a dictionary") 

178 

179 files = self.include_files.get("files", []) 

180 folders = self.include_files.get("folders", []) 

181 

182 if not isinstance(files, list): 

183 raise ConfigurationError("include_files['files'] must be a list") 

184 if not isinstance(folders, list): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise ConfigurationError("include_files['folders'] must be a list") 

186 

187 if not all(isinstance(item, str) and item.strip() for item in files): 

188 raise ConfigurationError( 

189 "include_files['files'] must contain non-empty strings" 

190 ) 

191 if not all(isinstance(item, str) and item.strip() for item in folders): 

192 raise ConfigurationError( 

193 "include_files['folders'] must contain non-empty strings" 

194 ) 

195 

196 # Normalize to canonical shape even when keys are missing. 

197 self.include_files = { 

198 "files": files, 

199 "folders": folders, 

200 } 

201 

202 def _validate_paths(self) -> None: 

203 """ 

204 Validate file and folder paths. 

205 

206 Ensures main file exists and output folder is accessible. 

207 Converts output_folder to Path if it's a string. 

208 

209 Raises: 

210 ConfigurationError: If main file doesn't exist 

211 """ 

212 if not Path(self.main_file).exists(): 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

213 raise ConfigurationError(f"Main file not found: {self.main_file}") 

214 

215 if isinstance(self.output_folder, str): 

216 self.output_folder = Path(self.output_folder) 

217 

218 def _validate_compiler_option(self) -> None: 

219 """ 

220 Validate compiler option. 

221 

222 Ensures compiler is one of the supported options. 

223 

224 Raises: 

225 ConfigurationError: If compiler is not valid 

226 """ 

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

228 if self.compiler not in valid_compilers: 

229 raise ConfigurationError( 

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

231 ) 

232 

233 # //////////////////////////////////////////////// 

234 # PATH HELPER PROPERTIES 

235 # //////////////////////////////////////////////// 

236 

237 @property 

238 def version_file(self) -> Path: 

239 """ 

240 Get the full path to the version file. 

241 

242 Returns: 

243 Path: Full path to version_info.txt in output folder 

244 """ 

245 return self.output_folder / self.version_filename 

246 

247 @property 

248 def zip_file_path(self) -> Path: 

249 """ 

250 Get the path to the zip file. 

251 

252 Uses the project name as the zip filename, placed next to the 

253 output folder (e.g., dist/MyApp.zip). 

254 

255 Returns: 

256 Path: Path to the zip archive file 

257 """ 

258 return self.output_folder.parent / f"{self.project_name}.zip" 

259 

260 # //////////////////////////////////////////////// 

261 # SERIALIZATION METHODS 

262 # //////////////////////////////////////////////// 

263 

264 def to_dict(self) -> dict[str, Any]: 

265 """ 

266 Convert configuration to dictionary. 

267 

268 Creates a comprehensive dictionary representation of the 

269 configuration with nested structures for compilation, upload, 

270 and advanced settings. 

271 

272 Returns: 

273 dict[str, Any]: Configuration as nested dictionary 

274 

275 Example: 

276 >>> config = CompilerConfig(...) 

277 >>> config_dict = config.to_dict() 

278 >>> print(config_dict["version"]) 

279 '1.0.0' 

280 """ 

281 return { 

282 "version": self.version, 

283 "project_name": self.project_name, 

284 "project_description": self.project_description, 

285 "company_name": self.company_name, 

286 "author": self.author, 

287 "main_file": self.main_file, 

288 "icon": self.icon, 

289 "version_filename": self.version_filename, 

290 "output_folder": str(self.output_folder), 

291 "include_files": self.include_files, 

292 "packages": self.packages, 

293 "includes": self.includes, 

294 "excludes": self.excludes, 

295 "compilation": { 

296 "console": self.console, 

297 "compiler": self.compiler, 

298 "zip_needed": self.zip_needed, 

299 "repo_needed": self.repo_needed, 

300 }, 

301 "upload": { 

302 "structure": self.upload_structure, 

303 "repo_path": self.repo_path, 

304 "server_url": self.server_url, 

305 }, 

306 "advanced": { 

307 "optimize": self.optimize, 

308 "strip": self.strip, 

309 "debug": self.debug, 

310 }, 

311 "compiler_options": self.compiler_options, 

312 } 

313 

314 @classmethod 

315 def from_dict(cls, config_dict: dict[str, Any]) -> CompilerConfig: 

316 """ 

317 Create configuration from dictionary. 

318 

319 Flattens nested structures (compilation, upload, advanced) 

320 and creates a new CompilerConfig instance. Handles backward 

321 compatibility for 'version_file' key. 

322 

323 Args: 

324 config_dict: Configuration dictionary with nested structures 

325 

326 Returns: 

327 CompilerConfig: New configuration instance 

328 

329 Raises: 

330 ConfigurationError: If required fields are missing or invalid 

331 

332 Example: 

333 >>> config_dict = { 

334 ... "version": "1.0.0", 

335 ... "project_name": "MyApp", 

336 ... "main_file": "main.py", 

337 ... "include_files": {"files": [], "folders": []}, 

338 ... "output_folder": "dist" 

339 ... } 

340 >>> config = CompilerConfig.from_dict(config_dict) 

341 """ 

342 config_copy = config_dict.copy() 

343 

344 # Flatten nested structures 

345 compilation = config_copy.get("compilation", {}) 

346 upload = config_copy.get("upload", {}) 

347 advanced = config_copy.get("advanced", {}) 

348 

349 config_copy.update(compilation) 

350 config_copy.update(upload) 

351 config_copy.update(advanced) 

352 

353 # Remove nested keys 

354 config_copy.pop("compilation", None) 

355 config_copy.pop("upload", None) 

356 config_copy.pop("advanced", None) 

357 

358 # Remap nested key names to dataclass field names 

359 # "upload.structure" becomes "upload_structure" after flattening 

360 if "structure" in config_copy: 

361 if "upload_structure" not in config_copy: 361 ↛ 364line 361 didn't jump to line 364 because the condition on line 361 was always true

362 config_copy["upload_structure"] = config_copy.pop("structure") 

363 else: 

364 config_copy.pop("structure") 

365 

366 # Handle backward compatibility 

367 if "version_file" in config_copy and "version_filename" not in config_copy: 

368 config_copy["version_filename"] = config_copy.pop("version_file") 

369 

370 return cls(**config_copy)