Coverage for src/ezcompiler/utils/_template_utils.py: 93.70%

115 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-04 11:22 +0000

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

2# TEMPLATE_UTILS - Template processing utilities 

3# Project: ezcompiler 

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

5 

6""" 

7Template processor - Variable substitution processor for EzCompiler templates. 

8 

9This module provides utilities for processing templates with variable 

10substitution, including methods for config, version, and setup template 

11processing with placeholder replacement. 

12 

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

14""" 

15 

16from __future__ import annotations 

17 

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

19# IMPORTS 

20# /////////////////////////////////////////////////////////////// 

21# Standard library imports 

22import json 

23from pathlib import Path 

24from typing import Any 

25 

26# Local imports 

27from ..shared import COMPILER_SECTION_KEYS 

28from ..shared.exceptions.utils import ( 

29 TemplateFileWriteError, 

30 TemplateSubstitutionError, 

31 TemplateValidationError, 

32) 

33 

34# /////////////////////////////////////////////////////////////// 

35# CLASSES 

36# /////////////////////////////////////////////////////////////// 

37 

38 

39class TemplateProcessor: 

40 """ 

41 Utility class for processing templates with variable substitution. 

42 

43 Provides static methods to replace placeholders in templates with 

44 actual values from configuration dictionaries. Supports multiple 

45 template types and formats. 

46 

47 Example: 

48 >>> processor = TemplateProcessor() 

49 >>> config = {"version": "1.0.0", "project_name": "MyApp"} 

50 >>> result = processor.process_config_template(template, config) 

51 """ 

52 

53 # //////////////////////////////////////////////// 

54 # MOCKUP GENERATION METHODS 

55 # //////////////////////////////////////////////// 

56 

57 @staticmethod 

58 def create_mockup_config() -> dict[str, Any]: 

59 """ 

60 Create a mockup configuration dictionary with default values. 

61 

62 Provides realistic default values that can be used to generate 

63 valid JSON/YAML templates without placeholders. 

64 

65 Returns: 

66 dict[str, Any]: Dictionary with mockup configuration values 

67 

68 Example: 

69 >>> mockup = TemplateProcessor.create_mockup_config() 

70 >>> print(mockup["version"]) 

71 '1.0.0' 

72 """ 

73 return { 

74 "version": "1.0.0", 

75 "project_name": "MyProject", 

76 "project_description": "A sample project description", 

77 "company_name": "MyCompany", 

78 "author": "John Doe", 

79 "main_file": "main.py", 

80 "icon": "icon.ico", 

81 "version_filename": "version_info.txt", 

82 "output_folder": "dist", 

83 "include_files": { 

84 "files": ["config.yaml", "README.md", "requirements.txt"], 

85 "folders": ["assets", "data", "docs"], 

86 }, 

87 "packages": ["requests", "pyyaml", "click"], 

88 "includes": ["utils", "helpers", "config"], 

89 "excludes": ["test", "debug", "temp"], 

90 "compilation": { 

91 "console": True, 

92 "compiler": "PyInstaller", 

93 }, 

94 "upload": { 

95 "repo_destination": "disk", 

96 "repo_endpoint": "releases", 

97 "release_destination": "disk", 

98 "release_endpoint": "", 

99 }, 

100 "pyinstaller": {"optimize": True, "strip": False}, 

101 "advanced": {"debug": False}, 

102 } 

103 

104 @staticmethod 

105 def process_template_with_mockup(template: str) -> str: 

106 """ 

107 Process a template using mockup values to create a valid file. 

108 

109 Args: 

110 template: The template string with placeholders 

111 

112 Returns: 

113 str: Processed template string with mockup values 

114 

115 Raises: 

116 TemplateSubstitutionError: If substitution fails 

117 

118 Example: 

119 >>> template = "version: #VERSION#\\nproject: #PROJECT_NAME#" 

120 >>> result = TemplateProcessor.process_template_with_mockup(template) 

121 """ 

122 mockup_config = TemplateProcessor.create_mockup_config() 

123 return TemplateProcessor.process_config_template(template, mockup_config) 

124 

125 # //////////////////////////////////////////////// 

126 # VERSION TEMPLATE PROCESSING 

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

128 

129 @staticmethod 

130 def process_version_template( 

131 template: str, 

132 version: str, 

133 company_name: str, 

134 project_description: str, 

135 project_name: str, 

136 ) -> str: 

137 """ 

138 Process version info template with project-specific values. 

139 

140 Args: 

141 template: The version template string 

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

143 company_name: Company name 

144 project_description: Project description 

145 project_name: Project name 

146 

147 Returns: 

148 str: Processed template string 

149 

150 Raises: 

151 TemplateSubstitutionError: If version substitution fails 

152 

153 Note: 

154 Converts version string to tuple format (e.g., "1.0.0" -> "(1, 0, 0, 0)") 

155 and automatically includes current year for copyright. 

156 """ 

157 try: 

158 # Convert version string to tuple format 

159 version_parts = version.split(".") 

160 while len(version_parts) < 4: 

161 version_parts.append("0") 

162 fixed_version = f"({', '.join(version_parts[:4])})" 

163 

164 # Get current year 

165 from datetime import datetime 

166 

167 current_year = str(datetime.now().year) 

168 

169 # Replace placeholders 

170 replacements = { 

171 "#FIXED_VERSION#": fixed_version, 

172 "#STRING_VERSION#": version, 

173 "#COMPANY_NAME#": company_name, 

174 "#FILE_DESCRIPTION#": project_description, 

175 "#PRODUCT_NAME#": project_name, 

176 "#INTERNAL_NAME#": project_name, 

177 "#LEGAL_COPYRIGHT#": company_name, 

178 "#ORIGINAL_FILENAME#": project_name, 

179 "#YEAR#": current_year, 

180 } 

181 

182 result = template 

183 for placeholder, value in replacements.items(): 

184 result = result.replace(placeholder, str(value)) 

185 

186 return result 

187 except Exception as e: 

188 raise TemplateSubstitutionError( 

189 f"Failed to process version template: {e}" 

190 ) from e 

191 

192 # //////////////////////////////////////////////// 

193 # CONFIG TEMPLATE PROCESSING 

194 # //////////////////////////////////////////////// 

195 

196 @staticmethod 

197 def process_config_template(template: str, config: dict[str, Any]) -> str: 

198 """ 

199 Process configuration template with project configuration. 

200 

201 Args: 

202 template: The configuration template string 

203 config: Project configuration dictionary 

204 

205 Returns: 

206 str: Processed template string 

207 

208 Raises: 

209 TemplateSubstitutionError: If config substitution fails 

210 

211 Note: 

212 Handles nested dictionaries for include_files, compilation, 

213 upload, and advanced settings. Converts Python booleans to 

214 JSON-compatible lowercase strings. 

215 """ 

216 try: 

217 # Extract values from config with defaults 

218 version = config.get("version", "1.0.0") 

219 project_name = config.get("project_name", "MyProject") 

220 project_description = config.get( 

221 "project_description", "Project Description" 

222 ) 

223 company_name = config.get("company_name", "Company Name") 

224 author = config.get("author", "Author Name") 

225 icon = config.get("icon", "icon.ico") 

226 main_file = config.get("main_file", "main.py") 

227 version_file = config.get("version_filename", "version_info.txt") 

228 output_folder = config.get("output_folder", "dist") 

229 

230 # Create include_files lists 

231 include_files = config.get("include_files", {"files": [], "folders": []}) 

232 include_files_list = include_files.get("files", []) 

233 include_folders_list = include_files.get("folders", []) 

234 

235 # Create other lists 

236 packages = config.get("packages", []) 

237 includes = config.get("includes", []) 

238 excludes = config.get("excludes", ["debugpy", "test", "unittest"]) 

239 

240 # Compilation options 

241 compilation = config.get("compilation", {}) 

242 console = compilation.get("console", True) 

243 compiler = compilation.get("compiler") or "PyInstaller" 

244 compiler_key = COMPILER_SECTION_KEYS.get(compiler, "pyinstaller") 

245 # Upload options 

246 upload = config.get("upload", {}) 

247 repo_destination = upload.get("repo_destination", "disk") 

248 repo_endpoint = upload.get("repo_endpoint", "") 

249 release_destination = upload.get("release_destination", "disk") 

250 release_endpoint = upload.get("release_endpoint", "") 

251 repo_public_url = upload.get("repo_public_url", "") 

252 

253 # Advanced options (generic) 

254 advanced = config.get("advanced", {}) 

255 debug = advanced.get("debug", False) 

256 

257 # Compiler-specific options: read from the compiler section, then 

258 # top-level, then defaults. 

259 section = config.get(compiler_key, {}) 

260 optimize = section.get("optimize", config.get("optimize", True)) 

261 strip = section.get("strip", config.get("strip", False)) 

262 

263 # Replace placeholders with JSON-valid values 

264 replacements = { 

265 "#VERSION#": version, 

266 "#PROJECT_NAME#": project_name, 

267 "#PROJECT_DESCRIPTION#": project_description, 

268 "#COMPANY_NAME#": company_name, 

269 "#AUTHOR#": author, 

270 "#ICON#": icon, 

271 "#MAIN_FILE#": main_file, 

272 "#VERSION_FILE#": version_file, 

273 "#OUTPUT_FOLDER#": output_folder, 

274 "#INCLUDE_FILES#": json.dumps(include_files_list), 

275 "#INCLUDE_FOLDERS#": json.dumps(include_folders_list), 

276 "#PACKAGES#": json.dumps(packages), 

277 "#INCLUDES#": json.dumps(includes), 

278 "#EXCLUDES#": json.dumps(excludes), 

279 "#CONSOLE#": str(console).lower(), 

280 "#COMPILER#": compiler, 

281 "#COMPILER_KEY#": compiler_key, 

282 "#REPO_DESTINATION#": repo_destination, 

283 "#REPO_ENDPOINT#": repo_endpoint, 

284 "#RELEASE_DESTINATION#": release_destination, 

285 "#RELEASE_ENDPOINT#": release_endpoint, 

286 "#REPO_PUBLIC_URL#": repo_public_url, 

287 "#OPTIMIZE#": str(optimize).lower(), 

288 "#STRIP#": str(strip).lower(), 

289 "#DEBUG#": str(debug).lower(), 

290 } 

291 

292 result = template 

293 for placeholder, value in replacements.items(): 

294 result = result.replace(placeholder, str(value)) 

295 

296 return result 

297 except Exception as e: 

298 raise TemplateSubstitutionError( 

299 f"Failed to process config template: {e}" 

300 ) from e 

301 

302 # //////////////////////////////////////////////// 

303 # SETUP TEMPLATE PROCESSING 

304 # //////////////////////////////////////////////// 

305 

306 @staticmethod 

307 def process_setup_template(template: str, config: dict[str, Any]) -> str: 

308 """ 

309 Process setup template with project configuration. 

310 

311 Args: 

312 template: The setup template string 

313 config: Project configuration dictionary 

314 

315 Returns: 

316 str: Processed template string 

317 

318 Raises: 

319 TemplateSubstitutionError: If setup substitution fails 

320 

321 Note: 

322 Formats include_files as Python dict string representation 

323 for setup.py compatibility. 

324 """ 

325 try: 

326 # Extract values from config with defaults 

327 version = config.get("version", "1.0.0") 

328 project_name = config.get("project_name", "MyProject") 

329 project_description = config.get( 

330 "project_description", "Project Description" 

331 ) 

332 company_name = config.get("company_name", "Company Name") 

333 author = config.get("author", "Author Name") 

334 icon = config.get("icon", "icon.ico") 

335 main_file = config.get("main_file", "main.py") 

336 version_file = config.get("version_filename", "version_info.txt") 

337 output_folder = config.get("output_folder", "dist") 

338 

339 # Create include_files dict string representation 

340 include_files = config.get("include_files", {"files": [], "folders": []}) 

341 include_files_str = ( 

342 f'{{"files": {include_files.get("files", [])}, ' 

343 f'"folders": {include_files.get("folders", [])}}}' 

344 ) 

345 

346 # Create other lists 

347 packages = config.get("packages", []) 

348 includes = config.get("includes", []) 

349 excludes = config.get("excludes", ["debugpy", "test", "unittest"]) 

350 

351 # Replace placeholders 

352 replacements = { 

353 "#VERSION#": version, 

354 "#PROJECT_NAME#": project_name, 

355 "#PROJECT_DESCRIPTION#": project_description, 

356 "#COMPANY_NAME#": company_name, 

357 "#AUTHOR#": author, 

358 "#ICON#": icon, 

359 "#MAIN_FILE#": main_file, 

360 "#VERSION_FILE#": version_file, 

361 "#OUTPUT_FOLDER#": output_folder, 

362 "#INCLUDE_FILES#": include_files_str, 

363 "#PACKAGES#": str(packages), 

364 "#INCLUDES#": str(includes), 

365 "#EXCLUDES#": str(excludes), 

366 } 

367 

368 result = template 

369 for placeholder, value in replacements.items(): 

370 result = result.replace(placeholder, str(value)) 

371 

372 return result 

373 except Exception as e: 

374 raise TemplateSubstitutionError( 

375 f"Failed to process setup template: {e}" 

376 ) from e 

377 

378 # //////////////////////////////////////////////// 

379 # FILE CREATION METHODS 

380 # //////////////////////////////////////////////// 

381 

382 @staticmethod 

383 def _create_config_file( 

384 template: str, _config: dict[str, Any], output_path: Path 

385 ) -> None: 

386 """ 

387 Create a configuration file from template. 

388 

389 Args: 

390 template: The configuration template 

391 _config: Configuration values to substitute (currently unused) 

392 output_path: Path where to save the config file 

393 

394 Raises: 

395 TemplateFileWriteError: If writing the file fails 

396 

397 Note: 

398 Currently writes template as-is. Future versions may process 

399 the template with config values. 

400 """ 

401 try: 

402 with open(output_path, "w", encoding="utf-8") as f: 

403 f.write(template) 

404 except Exception as e: 

405 raise TemplateFileWriteError( 

406 f"Failed to write config file to {output_path}: {e}" 

407 ) from e 

408 

409 # //////////////////////////////////////////////// 

410 # VALIDATION METHODS 

411 # //////////////////////////////////////////////// 

412 

413 @staticmethod 

414 def validate_template(template: str) -> bool: 

415 """ 

416 Validate template syntax. 

417 

418 Args: 

419 template: Template string to validate 

420 

421 Returns: 

422 bool: True if template is valid 

423 

424 Raises: 

425 TemplateValidationError: If template syntax is invalid 

426 

427 Note: 

428 Performs basic validation: checks for balanced braces and quotes. 

429 """ 

430 try: 

431 # Check for balanced braces 

432 brace_count = template.count("{") - template.count("}") 

433 if brace_count != 0: 

434 raise TemplateValidationError("Unbalanced braces in template") 

435 

436 # Check for balanced quotes (excluding escaped quotes) 

437 quote_count = template.count('"') - template.count('\\"') 

438 if quote_count % 2 != 0: 

439 raise TemplateValidationError("Unbalanced quotes in template") 

440 

441 return True 

442 except TemplateValidationError: 

443 raise 

444 except Exception as e: 

445 raise TemplateValidationError(f"Template validation failed: {e}") from e