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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Template processor - Variable substitution processor for EzCompiler templates.
9This module provides utilities for processing templates with variable
10substitution, including methods for config, version, and setup template
11processing with placeholder replacement.
13Utils layer can only use DEBUG and ERROR log levels.
14"""
16from __future__ import annotations
18# ///////////////////////////////////////////////////////////////
19# IMPORTS
20# ///////////////////////////////////////////////////////////////
21# Standard library imports
22import json
23from pathlib import Path
24from typing import Any
26# Local imports
27from ..shared import COMPILER_SECTION_KEYS
28from ..shared.exceptions.utils import (
29 TemplateFileWriteError,
30 TemplateSubstitutionError,
31 TemplateValidationError,
32)
34# ///////////////////////////////////////////////////////////////
35# CLASSES
36# ///////////////////////////////////////////////////////////////
39class TemplateProcessor:
40 """
41 Utility class for processing templates with variable substitution.
43 Provides static methods to replace placeholders in templates with
44 actual values from configuration dictionaries. Supports multiple
45 template types and formats.
47 Example:
48 >>> processor = TemplateProcessor()
49 >>> config = {"version": "1.0.0", "project_name": "MyApp"}
50 >>> result = processor.process_config_template(template, config)
51 """
53 # ////////////////////////////////////////////////
54 # MOCKUP GENERATION METHODS
55 # ////////////////////////////////////////////////
57 @staticmethod
58 def create_mockup_config() -> dict[str, Any]:
59 """
60 Create a mockup configuration dictionary with default values.
62 Provides realistic default values that can be used to generate
63 valid JSON/YAML templates without placeholders.
65 Returns:
66 dict[str, Any]: Dictionary with mockup configuration values
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 }
104 @staticmethod
105 def process_template_with_mockup(template: str) -> str:
106 """
107 Process a template using mockup values to create a valid file.
109 Args:
110 template: The template string with placeholders
112 Returns:
113 str: Processed template string with mockup values
115 Raises:
116 TemplateSubstitutionError: If substitution fails
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)
125 # ////////////////////////////////////////////////
126 # VERSION TEMPLATE PROCESSING
127 # ////////////////////////////////////////////////
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.
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
147 Returns:
148 str: Processed template string
150 Raises:
151 TemplateSubstitutionError: If version substitution fails
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])})"
164 # Get current year
165 from datetime import datetime
167 current_year = str(datetime.now().year)
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 }
182 result = template
183 for placeholder, value in replacements.items():
184 result = result.replace(placeholder, str(value))
186 return result
187 except Exception as e:
188 raise TemplateSubstitutionError(
189 f"Failed to process version template: {e}"
190 ) from e
192 # ////////////////////////////////////////////////
193 # CONFIG TEMPLATE PROCESSING
194 # ////////////////////////////////////////////////
196 @staticmethod
197 def process_config_template(template: str, config: dict[str, Any]) -> str:
198 """
199 Process configuration template with project configuration.
201 Args:
202 template: The configuration template string
203 config: Project configuration dictionary
205 Returns:
206 str: Processed template string
208 Raises:
209 TemplateSubstitutionError: If config substitution fails
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")
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", [])
235 # Create other lists
236 packages = config.get("packages", [])
237 includes = config.get("includes", [])
238 excludes = config.get("excludes", ["debugpy", "test", "unittest"])
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", "")
253 # Advanced options (generic)
254 advanced = config.get("advanced", {})
255 debug = advanced.get("debug", False)
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
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))
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 }
315 result = template
316 for placeholder, value in replacements.items():
317 result = result.replace(placeholder, str(value))
319 return result
320 except Exception as e:
321 raise TemplateSubstitutionError(
322 f"Failed to process config template: {e}"
323 ) from e
325 # ////////////////////////////////////////////////
326 # SETUP TEMPLATE PROCESSING
327 # ////////////////////////////////////////////////
329 @staticmethod
330 def process_setup_template(template: str, config: dict[str, Any]) -> str:
331 """
332 Process setup template with project configuration.
334 Args:
335 template: The setup template string
336 config: Project configuration dictionary
338 Returns:
339 str: Processed template string
341 Raises:
342 TemplateSubstitutionError: If setup substitution fails
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")
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 )
369 # Create other lists
370 packages = config.get("packages", [])
371 includes = config.get("includes", [])
372 excludes = config.get("excludes", ["debugpy", "test", "unittest"])
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", ""))
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 }
407 result = template
408 for placeholder, value in replacements.items():
409 result = result.replace(placeholder, str(value))
411 return result
412 except Exception as e:
413 raise TemplateSubstitutionError(
414 f"Failed to process setup template: {e}"
415 ) from e
417 # ////////////////////////////////////////////////
418 # FILE CREATION METHODS
419 # ////////////////////////////////////////////////
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.
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
433 Raises:
434 TemplateFileWriteError: If writing the file fails
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
448 # ////////////////////////////////////////////////
449 # VALIDATION METHODS
450 # ////////////////////////////////////////////////
452 @staticmethod
453 def validate_template(template: str) -> bool:
454 """
455 Validate template syntax.
457 Args:
458 template: Template string to validate
460 Returns:
461 bool: True if template is valid
463 Raises:
464 TemplateValidationError: If template syntax is invalid
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")
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")
480 return True
481 except TemplateValidationError:
482 raise
483 except Exception as e:
484 raise TemplateValidationError(f"Template validation failed: {e}") from e