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

123 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +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 # Installer options. Only the four that replaced the pre-4.0.0 

258 # flat installer_* keys are templated; the remaining twenty are 

259 # documented in the Windows installer guide and added by hand. 

260 # Paths are emitted as JSON values so an unset one becomes null 

261 # rather than an empty string, which would coerce to Path("."). 

262 installer = config.get("installer", {}) 

263 installer_enabled = installer.get("enabled", False) 

264 installer_per_user = installer.get("per_user", False) 

265 installer_output_dir = installer.get("output_dir") or None 

266 installer_iss_path = installer.get("iss_path") or None 

267 

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

269 # top-level, then defaults. 

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

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

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

273 

274 # Replace placeholders with JSON-valid values 

275 # String values are emitted as JSON literals, quotes included: 

276 # the templates no longer wrap them. A raw Windows path such as 

277 # C:\Users\dev\main.py would otherwise produce an invalid 

278 # escape and make the generated file unparseable. 

279 replacements = { 

280 "#VERSION#": json.dumps(str(version)), 

281 "#PROJECT_NAME#": json.dumps(str(project_name)), 

282 "#PROJECT_DESCRIPTION#": json.dumps(str(project_description)), 

283 "#COMPANY_NAME#": json.dumps(str(company_name)), 

284 "#AUTHOR#": json.dumps(str(author)), 

285 "#ICON#": json.dumps(str(icon)), 

286 "#MAIN_FILE#": json.dumps(str(main_file)), 

287 "#VERSION_FILE#": json.dumps(str(version_file)), 

288 "#OUTPUT_FOLDER#": json.dumps(str(output_folder)), 

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

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

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

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

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

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

295 "#COMPILER#": json.dumps(str(compiler)), 

296 "#COMPILER_KEY#": compiler_key, 

297 "#REPO_DESTINATION#": json.dumps(str(repo_destination)), 

298 "#REPO_ENDPOINT#": json.dumps(str(repo_endpoint)), 

299 "#RELEASE_DESTINATION#": json.dumps(str(release_destination)), 

300 "#RELEASE_ENDPOINT#": json.dumps(str(release_endpoint)), 

301 "#REPO_PUBLIC_URL#": json.dumps(str(repo_public_url)), 

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

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

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

305 "#INSTALLER_ENABLED#": str(installer_enabled).lower(), 

306 "#INSTALLER_PER_USER#": str(installer_per_user).lower(), 

307 "#INSTALLER_OUTPUT_DIR#": json.dumps( 

308 str(installer_output_dir) if installer_output_dir else None 

309 ), 

310 "#INSTALLER_ISS_PATH#": json.dumps( 

311 str(installer_iss_path) if installer_iss_path else None 

312 ), 

313 } 

314 

315 result = template 

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

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

318 

319 return result 

320 except Exception as e: 

321 raise TemplateSubstitutionError( 

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

323 ) from e 

324 

325 # //////////////////////////////////////////////// 

326 # SETUP TEMPLATE PROCESSING 

327 # //////////////////////////////////////////////// 

328 

329 @staticmethod 

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

331 """ 

332 Process setup template with project configuration. 

333 

334 Args: 

335 template: The setup template string 

336 config: Project configuration dictionary 

337 

338 Returns: 

339 str: Processed template string 

340 

341 Raises: 

342 TemplateSubstitutionError: If setup substitution fails 

343 

344 Note: 

345 Formats include_files as Python dict string representation 

346 for setup.py compatibility. 

347 """ 

348 try: 

349 # Extract values from config with defaults 

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

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

352 project_description = config.get( 

353 "project_description", "Project Description" 

354 ) 

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

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

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

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

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

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

361 

362 # Create include_files dict string representation 

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

364 include_files_str = ( 

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

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

367 ) 

368 

369 # Create other lists 

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

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

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

373 

374 # Pipeline values. A config dict may arrive flat or 

375 # with its TOML/YAML sections still nested, depending on the 

376 # source, so read both shapes. Left unsubstituted, these 

377 # placeholders make the generated script invalid Python: a bare 

378 # ``#TOKEN#`` is a comment, so the assignment loses its value. 

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

380 console = config.get("console", compilation.get("console", True)) 

381 compiler = config.get("compiler", compilation.get("compiler", "")) 

382 

383 # Replace placeholders 

384 # Same rule as the config templates: string values arrive as 

385 # JSON literals, which are valid Python literals too, so a 

386 # Windows path cannot break the generated script. 

387 replacements = { 

388 "#CONSOLE#": bool(console), 

389 "#COMPILER#": json.dumps(str(compiler)), 

390 "#ZIP_NEEDED#": bool(config.get("zip_needed", True)), 

391 "#VERSION#": json.dumps(str(version)), 

392 "#PROJECT_NAME_RAW#": project_name, 

393 "#PROJECT_NAME#": json.dumps(str(project_name)), 

394 "#PROJECT_DESCRIPTION#": json.dumps(str(project_description)), 

395 "#COMPANY_NAME#": json.dumps(str(company_name)), 

396 "#AUTHOR#": json.dumps(str(author)), 

397 "#ICON#": json.dumps(str(icon)), 

398 "#MAIN_FILE#": json.dumps(str(main_file)), 

399 "#VERSION_FILE#": json.dumps(str(version_file)), 

400 "#OUTPUT_FOLDER#": json.dumps(str(output_folder)), 

401 "#INCLUDE_FILES#": include_files_str, 

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

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

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

405 } 

406 

407 result = template 

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

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

410 

411 return result 

412 except Exception as e: 

413 raise TemplateSubstitutionError( 

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

415 ) from e 

416 

417 # //////////////////////////////////////////////// 

418 # FILE CREATION METHODS 

419 # //////////////////////////////////////////////// 

420 

421 @staticmethod 

422 def _create_config_file( 

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

424 ) -> None: 

425 """ 

426 Create a configuration file from template. 

427 

428 Args: 

429 template: The configuration template 

430 _config: Configuration values to substitute (currently unused) 

431 output_path: Path where to save the config file 

432 

433 Raises: 

434 TemplateFileWriteError: If writing the file fails 

435 

436 Note: 

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

438 the template with config values. 

439 """ 

440 try: 

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

442 f.write(template) 

443 except Exception as e: 

444 raise TemplateFileWriteError( 

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

446 ) from e 

447 

448 # //////////////////////////////////////////////// 

449 # VALIDATION METHODS 

450 # //////////////////////////////////////////////// 

451 

452 @staticmethod 

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

454 """ 

455 Validate template syntax. 

456 

457 Args: 

458 template: Template string to validate 

459 

460 Returns: 

461 bool: True if template is valid 

462 

463 Raises: 

464 TemplateValidationError: If template syntax is invalid 

465 

466 Note: 

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

468 """ 

469 try: 

470 # Check for balanced braces 

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

472 if brace_count != 0: 

473 raise TemplateValidationError("Unbalanced braces in template") 

474 

475 # Check for balanced quotes (excluding escaped quotes) 

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

477 if quote_count % 2 != 0: 

478 raise TemplateValidationError("Unbalanced quotes in template") 

479 

480 return True 

481 except TemplateValidationError: 

482 raise 

483 except Exception as e: 

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