|
|
@@ -5,8 +5,8 @@ import logging
|
|
|
import re
|
|
|
from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
|
|
|
import frontmatter
|
|
|
-from .exceptions import TemplateValidationError
|
|
|
-# Module variables will be handled by the module's VariableRegistry
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
@@ -17,6 +17,7 @@ class Template:
|
|
|
file_path: Path
|
|
|
content: str = ""
|
|
|
|
|
|
+
|
|
|
# Frontmatter fields with defaults
|
|
|
name: str = ""
|
|
|
description: str = "No description available"
|
|
|
@@ -26,17 +27,21 @@ class Template:
|
|
|
module: str = ""
|
|
|
tags: List[str] = field(default_factory=list)
|
|
|
files: List[str] = field(default_factory=list)
|
|
|
- variable_metadata: Dict[str, Dict[str, Any]] = field(default_factory=dict) # Variable hints/tips from frontmatter
|
|
|
|
|
|
# Computed properties (will be set in __post_init__)
|
|
|
id: str = field(init=False)
|
|
|
- directory: str = field(init=False)
|
|
|
relative_path: str = field(init=False)
|
|
|
size: int = field(init=False)
|
|
|
|
|
|
# Template variable analysis results
|
|
|
- vars: Set[str] = field(default_factory=set, init=False)
|
|
|
- var_defaults: Dict[str, Any] = field(default_factory=dict, init=False)
|
|
|
+ vars: Dict[str, Any] = field(default_factory=dict, init=False)
|
|
|
+ frontmatter_variables: Dict[str, Any] = field(default_factory=dict, init=False)
|
|
|
+
|
|
|
+ # Cache for performance optimization
|
|
|
+ _jinja_ast: Any = field(default=None, init=False, repr=False)
|
|
|
+ _parsed_vars: Dict[str, Any] = field(default=None, init=False, repr=False)
|
|
|
+ _is_enriched: bool = field(default=False, init=False, repr=False)
|
|
|
+
|
|
|
def __post_init__(self):
|
|
|
"""Initialize computed properties after dataclass initialization."""
|
|
|
# Set default name if not provided
|
|
|
@@ -45,29 +50,24 @@ class Template:
|
|
|
|
|
|
# Computed properties
|
|
|
self.id = self.file_path.parent.name
|
|
|
- self.directory = self.file_path.parent.name
|
|
|
self.relative_path = self.file_path.name
|
|
|
self.size = self.file_path.stat().st_size if self.file_path.exists() else 0
|
|
|
|
|
|
- # Parse template variables
|
|
|
- self.vars, self.var_defaults = self._parse_template_variables(self.content)
|
|
|
-
|
|
|
- @staticmethod
|
|
|
- def _create_jinja_env() -> Environment:
|
|
|
- """Create standardized Jinja2 environment for consistent template processing."""
|
|
|
- return Environment(
|
|
|
- loader=BaseLoader(),
|
|
|
- trim_blocks=True, # Remove first newline after block tags
|
|
|
- lstrip_blocks=True, # Strip leading whitespace from block tags
|
|
|
- keep_trailing_newline=False # Remove trailing newlines
|
|
|
- )
|
|
|
+ # Initialize with empty vars - modules will enrich with their variables
|
|
|
+ # Template parsing and variable enrichment is handled by the module
|
|
|
+ self.vars = {}
|
|
|
|
|
|
@classmethod
|
|
|
def from_file(cls, file_path: Path) -> "Template":
|
|
|
- """Create a Template instance from a file path."""
|
|
|
+ """Create a Template instance from a file path.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ file_path: Path to the template file
|
|
|
+ """
|
|
|
+ logger.debug(f"Loading template from file: {file_path}")
|
|
|
try:
|
|
|
frontmatter_data, content = cls._parse_frontmatter(file_path)
|
|
|
- return cls(
|
|
|
+ template = cls(
|
|
|
file_path=file_path,
|
|
|
content=content,
|
|
|
name=frontmatter_data.get('name', ''),
|
|
|
@@ -77,180 +77,170 @@ class Template:
|
|
|
version=frontmatter_data.get('version', ''),
|
|
|
module=frontmatter_data.get('module', ''),
|
|
|
tags=frontmatter_data.get('tags', []),
|
|
|
- files=frontmatter_data.get('files', []),
|
|
|
- variable_metadata=frontmatter_data.get('variables', {})
|
|
|
+ files=frontmatter_data.get('files', [])
|
|
|
)
|
|
|
+ # Store frontmatter variables - module enrichment will handle the integration
|
|
|
+ template.frontmatter_variables = frontmatter_data.get('variables', {})
|
|
|
+
|
|
|
+ if template.frontmatter_variables:
|
|
|
+ logger.debug(f"Template '{template.id}' has frontmatter variables: {list(template.frontmatter_variables.keys())}")
|
|
|
+
|
|
|
+ logger.debug(f"Successfully loaded template '{template.id}' from {file_path}")
|
|
|
+ return template
|
|
|
except Exception:
|
|
|
# If frontmatter parsing fails, create a basic Template object
|
|
|
return cls(file_path=file_path)
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _build_dotted_name(node) -> Optional[str]:
|
|
|
+ """Build full dotted variable name from Jinja2 Getattr node.
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dotted variable name (e.g., 'traefik.host') or None if invalid
|
|
|
+ """
|
|
|
+ current = node
|
|
|
+ parts = []
|
|
|
+ while isinstance(current, nodes.Getattr):
|
|
|
+ parts.insert(0, current.attr)
|
|
|
+ current = current.node
|
|
|
+ if isinstance(current, nodes.Name):
|
|
|
+ parts.insert(0, current.name)
|
|
|
+ return '.'.join(parts)
|
|
|
+ return None
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _create_jinja_env() -> Environment:
|
|
|
+ """Create standardized Jinja2 environment for consistent template processing."""
|
|
|
+ return Environment(
|
|
|
+ loader=BaseLoader(),
|
|
|
+ trim_blocks=True, # Remove first newline after block tags
|
|
|
+ lstrip_blocks=True, # Strip leading whitespace from block tags
|
|
|
+ keep_trailing_newline=False # Remove trailing newlines
|
|
|
+ )
|
|
|
+
|
|
|
+ def _get_ast(self):
|
|
|
+ """Get cached AST or create and cache it."""
|
|
|
+ if self._jinja_ast is None:
|
|
|
+ env = self._create_jinja_env()
|
|
|
+ self._jinja_ast = env.parse(self.content)
|
|
|
+ return self._jinja_ast
|
|
|
+
|
|
|
+ def _get_used_variables(self) -> Set[str]:
|
|
|
+ """Get variables actually used in template (cached)."""
|
|
|
+ ast = self._get_ast()
|
|
|
+ used_variables = meta.find_undeclared_variables(ast)
|
|
|
+
|
|
|
+ # Handle dotted notation variables
|
|
|
+ for node in ast.find_all(nodes.Getattr):
|
|
|
+ dotted_name = Template._build_dotted_name(node)
|
|
|
+ if dotted_name:
|
|
|
+ used_variables.add(dotted_name)
|
|
|
+
|
|
|
+ return used_variables
|
|
|
+
|
|
|
@staticmethod
|
|
|
def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
|
|
|
"""Parse frontmatter and content from a file."""
|
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
|
post = frontmatter.load(f)
|
|
|
return post.metadata, post.content
|
|
|
-
|
|
|
- def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any]]:
|
|
|
- """Parse Jinja2 template to extract variables and their defaults.
|
|
|
+
|
|
|
+
|
|
|
+ def render(self, variable_values: Dict[str, Any]) -> str:
|
|
|
+ """Render the template with the provided variable values."""
|
|
|
+ logger = logging.getLogger('boilerplates')
|
|
|
+
|
|
|
+ try:
|
|
|
+ env = self._create_jinja_env()
|
|
|
+ jinja_template = env.from_string(self.content)
|
|
|
+ # Merge template vars (with defaults) with provided values
|
|
|
+ # All variables should be defined at this point due to validation
|
|
|
+ merged_variable_values = {**self.vars, **variable_values}
|
|
|
+ rendered_content = jinja_template.render(**merged_variable_values)
|
|
|
+
|
|
|
+ # Clean up excessive blank lines and whitespace
|
|
|
+ rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)
|
|
|
+ return rendered_content.strip()
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Jinja2 template rendering failed: {e}")
|
|
|
+ raise ValueError(f"Failed to render template: {e}")
|
|
|
+
|
|
|
+ def _parse_template_variables(self, template_content: str, frontmatter_vars: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
|
+ """Parse Jinja2 template to extract variables and their defaults (cached).
|
|
|
|
|
|
Handles:
|
|
|
- Simple variables: service_name
|
|
|
- Dotted notation: traefik.host, service_port.http
|
|
|
+ - Frontmatter variable definitions
|
|
|
+
|
|
|
+ Args:
|
|
|
+ template_content: The Jinja2 template content (ignored if cached)
|
|
|
+ frontmatter_vars: Variables defined in template frontmatter
|
|
|
|
|
|
Returns:
|
|
|
- Tuple of (all_variable_names, variable_defaults)
|
|
|
+ Dict mapping variable names to their default values (None if no default)
|
|
|
"""
|
|
|
+ # Use cache if available and no frontmatter changes
|
|
|
+ cache_key = f"{hash(frontmatter_vars.__str__() if frontmatter_vars else 'None')}"
|
|
|
+ if self._parsed_vars is not None and not frontmatter_vars:
|
|
|
+ return self._parsed_vars
|
|
|
+
|
|
|
try:
|
|
|
- env = self._create_jinja_env()
|
|
|
- ast = env.parse(template_content)
|
|
|
+ ast = self._get_ast() # Use cached AST
|
|
|
|
|
|
- # Start with variables found by Jinja2's meta utility
|
|
|
- all_variables = meta.find_undeclared_variables(ast)
|
|
|
+ # Get all variables used in template
|
|
|
+ all_variables = self._get_used_variables()
|
|
|
+ logger.debug(f"Template uses {len(all_variables)} variables: {sorted(all_variables)}")
|
|
|
|
|
|
- # Handle dotted notation variables (like traefik.host, service_port.http)
|
|
|
- for node in ast.find_all(nodes.Getattr):
|
|
|
- current = node.node
|
|
|
- # Build the full dotted name
|
|
|
- parts = [node.attr]
|
|
|
- while isinstance(current, nodes.Getattr):
|
|
|
- parts.insert(0, current.attr)
|
|
|
- current = current.node
|
|
|
- if isinstance(current, nodes.Name):
|
|
|
- parts.insert(0, current.name)
|
|
|
- # Add the full dotted variable name
|
|
|
- all_variables.add('.'.join(parts))
|
|
|
+ # Initialize vars dict with all variables (default to None)
|
|
|
+ vars_dict = {var_name: None for var_name in all_variables}
|
|
|
|
|
|
# Extract default values from | default() filters
|
|
|
- defaults = {}
|
|
|
+ template_defaults = {}
|
|
|
for node in ast.find_all(nodes.Filter):
|
|
|
if node.name == 'default' and node.args and isinstance(node.args[0], nodes.Const):
|
|
|
- # Handle simple variable defaults: {{ var | default(value) }}
|
|
|
+ # Handle simple variable defaults
|
|
|
if isinstance(node.node, nodes.Name):
|
|
|
- defaults[node.node.name] = node.args[0].value
|
|
|
-
|
|
|
- # Handle dotted variable defaults: {{ traefik.host | default('example.com') }}
|
|
|
+ template_defaults[node.node.name] = node.args[0].value
|
|
|
+ vars_dict[node.node.name] = node.args[0].value
|
|
|
+ # Handle dotted variable defaults
|
|
|
elif isinstance(node.node, nodes.Getattr):
|
|
|
- # Build the full dotted name
|
|
|
- current = node.node
|
|
|
- parts = []
|
|
|
- while isinstance(current, nodes.Getattr):
|
|
|
- parts.insert(0, current.attr)
|
|
|
- current = current.node
|
|
|
- if isinstance(current, nodes.Name):
|
|
|
- parts.insert(0, current.name)
|
|
|
- var_name = '.'.join(parts)
|
|
|
- defaults[var_name] = node.args[0].value
|
|
|
+ dotted_name = Template._build_dotted_name(node.node)
|
|
|
+ if dotted_name:
|
|
|
+ template_defaults[dotted_name] = node.args[0].value
|
|
|
+ vars_dict[dotted_name] = node.args[0].value
|
|
|
+
|
|
|
+ if template_defaults:
|
|
|
+ logger.debug(f"Template defines {len(template_defaults)} defaults: {template_defaults}")
|
|
|
|
|
|
- return all_variables, defaults
|
|
|
+ # Process frontmatter variables (frontmatter takes precedence)
|
|
|
+ if frontmatter_vars:
|
|
|
+ frontmatter_overrides = {}
|
|
|
+ for var_name, var_config in frontmatter_vars.items():
|
|
|
+ if var_name in vars_dict and vars_dict[var_name] is not None:
|
|
|
+ logger.warning(f"Variable '{var_name}' defined in both template content and frontmatter. Frontmatter definition takes precedence.")
|
|
|
+
|
|
|
+ # Handle both simple values and complex variable configurations
|
|
|
+ if isinstance(var_config, dict) and 'default' in var_config:
|
|
|
+ frontmatter_overrides[var_name] = var_config['default']
|
|
|
+ vars_dict[var_name] = var_config['default']
|
|
|
+ else:
|
|
|
+ frontmatter_overrides[var_name] = var_config
|
|
|
+ vars_dict[var_name] = var_config
|
|
|
+
|
|
|
+ if frontmatter_overrides:
|
|
|
+ logger.debug(f"Frontmatter overrides {len(frontmatter_overrides)} variables: {frontmatter_overrides}")
|
|
|
+
|
|
|
+ # Cache result if no frontmatter (pure template parsing)
|
|
|
+ if not frontmatter_vars:
|
|
|
+ self._parsed_vars = vars_dict.copy()
|
|
|
+
|
|
|
+ return vars_dict
|
|
|
except Exception as e:
|
|
|
- logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
|
|
|
- return set(), {}
|
|
|
+ logger.debug(f"Error parsing template variables: {e}")
|
|
|
+ return {}
|
|
|
+
|
|
|
|
|
|
- def validate(self, module_variable_registry=None, template_id: str = None):
|
|
|
- """Validate template integrity.
|
|
|
-
|
|
|
- Args:
|
|
|
- module_variable_registry: Module's VariableRegistry for validation
|
|
|
- template_id: Template ID for error messages (uses self.id if not provided)
|
|
|
-
|
|
|
- Raises:
|
|
|
- TemplateValidationError: If validation fails.
|
|
|
- """
|
|
|
- import logging
|
|
|
- from .exceptions import TemplateValidationError
|
|
|
-
|
|
|
- logger = logging.getLogger('boilerplates')
|
|
|
- template_id = template_id or self.id
|
|
|
- errors = []
|
|
|
- warnings = []
|
|
|
-
|
|
|
- # Check for Jinja2 syntax errors (critical)
|
|
|
- try:
|
|
|
- env = self._create_jinja_env()
|
|
|
- env.from_string(self.content)
|
|
|
- except TemplateSyntaxError as e:
|
|
|
- raise TemplateValidationError(template_id, [f"Invalid Jinja2 syntax at line {e.lineno}: {e.message}"])
|
|
|
- except Exception as e:
|
|
|
- raise TemplateValidationError(template_id, [f"Template parsing error: {str(e)}"])
|
|
|
-
|
|
|
- # Validate module variable registry consistency
|
|
|
- if module_variable_registry:
|
|
|
- registry_errors = module_variable_registry.validate_parent_child_relationships()
|
|
|
- if registry_errors:
|
|
|
- errors.extend(registry_errors)
|
|
|
-
|
|
|
- # Validate variable definitions (critical)
|
|
|
- undefined_vars = self._validate_variable_definitions(module_variable_registry)
|
|
|
- if undefined_vars:
|
|
|
- errors.extend(undefined_vars)
|
|
|
-
|
|
|
- # Check for missing frontmatter fields (warnings)
|
|
|
- if not self.name:
|
|
|
- warnings.append("Missing 'name' in frontmatter")
|
|
|
-
|
|
|
- if not self.description or self.description == 'No description available':
|
|
|
- warnings.append("Missing 'description' in frontmatter")
|
|
|
-
|
|
|
- # Check for empty content (warning)
|
|
|
- if not self.content.strip() and not self.files:
|
|
|
- warnings.append("Template has no content")
|
|
|
-
|
|
|
- # Raise if critical errors found
|
|
|
- if errors:
|
|
|
- raise TemplateValidationError(template_id, errors)
|
|
|
-
|
|
|
- # Log warnings
|
|
|
- for warning in warnings:
|
|
|
- logger.warning(f"Template '{template_id}': {warning}")
|
|
|
|
|
|
- def _validate_variable_definitions(self, module_variable_registry) -> List[str]:
|
|
|
- """Validate that all template variables are properly defined.
|
|
|
-
|
|
|
- Args:
|
|
|
- module_variable_registry: Module's VariableRegistry instance
|
|
|
-
|
|
|
- Returns:
|
|
|
- List of error messages for undefined variables
|
|
|
- """
|
|
|
- errors = []
|
|
|
-
|
|
|
- if not module_variable_registry:
|
|
|
- return errors
|
|
|
-
|
|
|
- # Check that all template variables are either registered or in frontmatter
|
|
|
- unregistered_vars = []
|
|
|
- for var_name in self.vars:
|
|
|
- # Check if variable is registered in module
|
|
|
- if not module_variable_registry.get_variable(var_name):
|
|
|
- # Check if it's defined in template's frontmatter
|
|
|
- if var_name not in self.variable_metadata:
|
|
|
- unregistered_vars.append(var_name)
|
|
|
-
|
|
|
- if unregistered_vars:
|
|
|
- errors.append(
|
|
|
- f"Unregistered variables found: {', '.join(unregistered_vars)}. "
|
|
|
- f"Variables must be either registered in the module or defined in template frontmatter 'variables' section."
|
|
|
- )
|
|
|
-
|
|
|
- return errors
|
|
|
|
|
|
- def render(self, variable_values: Dict[str, Any]) -> str:
|
|
|
- """Render the template with the provided variable values."""
|
|
|
- logger = logging.getLogger('boilerplates')
|
|
|
-
|
|
|
- try:
|
|
|
- env = self._create_jinja_env()
|
|
|
- jinja_template = env.from_string(self.content)
|
|
|
- # Merge template defaults with provided values
|
|
|
- # All variables should be defined at this point due to validation
|
|
|
- merged_variable_values = {**self.var_defaults, **variable_values}
|
|
|
- rendered_content = jinja_template.render(**merged_variable_values)
|
|
|
-
|
|
|
- # Clean up excessive blank lines and whitespace
|
|
|
- rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)
|
|
|
- return rendered_content.strip()
|
|
|
-
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"Jinja2 template rendering failed: {e}")
|
|
|
- raise ValueError(f"Failed to render template: {e}")
|