template.py 7.8 KB

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