template.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. from pathlib import Path
  2. from typing import Any, Dict, List, Set, Tuple
  3. from dataclasses import dataclass, field
  4. import logging
  5. import re
  6. from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
  7. import frontmatter
  8. from .exceptions import TemplateValidationError
  9. from .variables import TemplateVariable, analyze_template_variables
  10. @dataclass
  11. class Template:
  12. """Data class for template information extracted from frontmatter."""
  13. # Required fields
  14. file_path: Path
  15. content: str = ""
  16. # Frontmatter fields with defaults
  17. name: str = ""
  18. description: str = "No description available"
  19. author: str = ""
  20. date: str = ""
  21. version: str = ""
  22. module: str = ""
  23. tags: List[str] = field(default_factory=list)
  24. files: List[str] = field(default_factory=list)
  25. variable_metadata: Dict[str, Dict[str, Any]] = field(default_factory=dict) # Variable hints/tips from frontmatter
  26. # Computed properties (will be set in __post_init__)
  27. id: str = field(init=False)
  28. directory: str = field(init=False)
  29. relative_path: str = field(init=False)
  30. size: int = field(init=False)
  31. # Template variable analysis results
  32. vars: Set[str] = field(default_factory=set, init=False)
  33. var_defaults: Dict[str, Any] = field(default_factory=dict, init=False)
  34. variables: Dict[str, TemplateVariable] = field(default_factory=dict, init=False) # Analyzed variables
  35. def __post_init__(self):
  36. """Initialize computed properties after dataclass initialization."""
  37. # Set default name if not provided
  38. if not self.name:
  39. self.name = self.file_path.parent.name
  40. # Computed properties
  41. self.id = self.file_path.parent.name
  42. self.directory = self.file_path.parent.name
  43. self.relative_path = self.file_path.name
  44. self.size = self.file_path.stat().st_size if self.file_path.exists() else 0
  45. # Parse template variables
  46. self.vars, self.var_defaults = self._parse_template_variables(self.content)
  47. # Analyze variables to create TemplateVariable objects
  48. self.variables = analyze_template_variables(
  49. self.vars, self.var_defaults, self.content
  50. )
  51. @staticmethod
  52. def _create_jinja_env() -> Environment:
  53. """Create standardized Jinja2 environment for consistent template processing."""
  54. return Environment(
  55. loader=BaseLoader(),
  56. trim_blocks=True, # Remove first newline after block tags
  57. lstrip_blocks=True, # Strip leading whitespace from block tags
  58. keep_trailing_newline=False # Remove trailing newlines
  59. )
  60. @classmethod
  61. def from_file(cls, file_path: Path) -> "Template":
  62. """Create a Template instance from a file path."""
  63. try:
  64. frontmatter_data, content = cls._parse_frontmatter(file_path)
  65. return cls(
  66. file_path=file_path,
  67. content=content,
  68. name=frontmatter_data.get('name', ''),
  69. description=frontmatter_data.get('description', 'No description available'),
  70. author=frontmatter_data.get('author', ''),
  71. date=frontmatter_data.get('date', ''),
  72. version=frontmatter_data.get('version', ''),
  73. module=frontmatter_data.get('module', ''),
  74. tags=frontmatter_data.get('tags', []),
  75. files=frontmatter_data.get('files', []),
  76. variable_metadata=frontmatter_data.get('variables', {})
  77. )
  78. except Exception:
  79. # If frontmatter parsing fails, create a basic Template object
  80. return cls(file_path=file_path)
  81. @staticmethod
  82. def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
  83. """Parse frontmatter and content from a file."""
  84. with open(file_path, 'r', encoding='utf-8') as f:
  85. post = frontmatter.load(f)
  86. return post.metadata, post.content
  87. def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any]]:
  88. """Parse Jinja2 template to extract variables and their defaults.
  89. Handles:
  90. - Simple variables: service_name
  91. - Dotted notation: traefik.host, service_port.http
  92. Returns:
  93. Tuple of (all_variable_names, variable_defaults)
  94. """
  95. try:
  96. env = self._create_jinja_env()
  97. ast = env.parse(template_content)
  98. # Start with variables found by Jinja2's meta utility
  99. all_variables = meta.find_undeclared_variables(ast)
  100. # Handle dotted notation variables (like traefik.host, service_port.http)
  101. for node in ast.find_all(nodes.Getattr):
  102. current = node.node
  103. # Build the full dotted name
  104. parts = [node.attr]
  105. while isinstance(current, nodes.Getattr):
  106. parts.insert(0, current.attr)
  107. current = current.node
  108. if isinstance(current, nodes.Name):
  109. parts.insert(0, current.name)
  110. # Add the full dotted variable name
  111. all_variables.add('.'.join(parts))
  112. # Extract default values from | default() filters
  113. defaults = {}
  114. for node in ast.find_all(nodes.Filter):
  115. if node.name == 'default' and node.args and isinstance(node.args[0], nodes.Const):
  116. # Handle simple variable defaults: {{ var | default(value) }}
  117. if isinstance(node.node, nodes.Name):
  118. defaults[node.node.name] = node.args[0].value
  119. # Handle dotted variable defaults: {{ traefik.host | default('example.com') }}
  120. elif isinstance(node.node, nodes.Getattr):
  121. # Build the full dotted name
  122. current = node.node
  123. parts = []
  124. while isinstance(current, nodes.Getattr):
  125. parts.insert(0, current.attr)
  126. current = current.node
  127. if isinstance(current, nodes.Name):
  128. parts.insert(0, current.name)
  129. var_name = '.'.join(parts)
  130. defaults[var_name] = node.args[0].value
  131. return all_variables, defaults
  132. except Exception as e:
  133. logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
  134. return set(), {}
  135. def validate(self) -> List[str]:
  136. """Validate template integrity.
  137. Returns:
  138. List of validation error messages. Empty list if valid.
  139. Raises:
  140. TemplateValidationError: If validation fails (critical errors only).
  141. """
  142. errors = []
  143. # Check for Jinja2 syntax errors (critical - should raise immediately)
  144. try:
  145. env = self._create_jinja_env()
  146. env.from_string(self.content)
  147. except TemplateSyntaxError as e:
  148. raise TemplateValidationError(self.id, [f"Invalid Jinja2 syntax at line {e.lineno}: {e.message}"])
  149. except Exception as e:
  150. raise TemplateValidationError(self.id, [f"Template parsing error: {str(e)}"])
  151. # All variables are now auto-detected, no need to check for undefined
  152. # The template parser will have found all variables used
  153. # Check for missing required frontmatter fields
  154. if not self.name or self.name == self.file_path.parent.name:
  155. errors.append("Missing 'name' in frontmatter")
  156. if not self.description or self.description == 'No description available':
  157. errors.append("Missing 'description' in frontmatter")
  158. # Check for empty content (unless it's intentionally a metadata-only template)
  159. if not self.content.strip() and not self.files:
  160. errors.append("Template has no content")
  161. return errors
  162. def render(self, variable_values: Dict[str, Any]) -> str:
  163. """Render the template with the provided variable values."""
  164. logger = logging.getLogger('boilerplates')
  165. try:
  166. env = self._create_jinja_env()
  167. jinja_template = env.from_string(self.content)
  168. # Merge template defaults with provided values
  169. # All variables should be defined at this point due to validation
  170. merged_variable_values = {**self.var_defaults, **variable_values}
  171. rendered_content = jinja_template.render(**merged_variable_values)
  172. # Clean up excessive blank lines and whitespace
  173. rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)
  174. return rendered_content.strip()
  175. except Exception as e:
  176. logger.error(f"Jinja2 template rendering failed: {e}")
  177. raise ValueError(f"Failed to render template: {e}")