| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968 |
- from __future__ import annotations
- from .collection import VariableCollection
- from .exceptions import (
- TemplateLoadError,
- TemplateSyntaxError,
- TemplateValidationError,
- TemplateRenderError,
- YAMLParseError,
- IncompatibleSchemaVersionError,
- )
- from .version import is_compatible
- from pathlib import Path
- from typing import Any, Dict, List, Set, Optional, Literal
- from dataclasses import dataclass, field
- from functools import lru_cache
- import logging
- import os
- import yaml
- from jinja2 import Environment, FileSystemLoader, meta
- from jinja2.sandbox import SandboxedEnvironment
- from jinja2 import nodes
- from jinja2.visitor import NodeVisitor
- from jinja2.exceptions import (
- TemplateSyntaxError as Jinja2TemplateSyntaxError,
- UndefinedError,
- TemplateError as Jinja2TemplateError,
- TemplateNotFound as Jinja2TemplateNotFound,
- )
- logger = logging.getLogger(__name__)
- def _extract_error_context(
- file_path: Path, line_number: Optional[int], context_size: int = 3
- ) -> List[str]:
- """Extract lines of context around an error location.
- Args:
- file_path: Path to the file with the error
- line_number: Line number where error occurred (1-indexed)
- context_size: Number of lines to show before and after
- Returns:
- List of context lines with line numbers
- """
- if not line_number or not file_path.exists():
- return []
- try:
- with open(file_path, "r", encoding="utf-8") as f:
- lines = f.readlines()
- start_line = max(0, line_number - context_size - 1)
- end_line = min(len(lines), line_number + context_size)
- context = []
- for i in range(start_line, end_line):
- line_num = i + 1
- marker = ">>>" if line_num == line_number else " "
- context.append(f"{marker} {line_num:4d} | {lines[i].rstrip()}")
- return context
- except (IOError, OSError):
- return []
- def _get_common_jinja_suggestions(error_msg: str, available_vars: set) -> List[str]:
- """Generate helpful suggestions based on common Jinja2 errors.
- Args:
- error_msg: The error message from Jinja2
- available_vars: Set of available variable names
- Returns:
- List of actionable suggestions
- """
- suggestions = []
- error_lower = error_msg.lower()
- # Undefined variable errors
- if "undefined" in error_lower or "is not defined" in error_lower:
- # Try to extract variable name from error message
- import re
- var_match = re.search(r"'([^']+)'.*is undefined", error_msg)
- if not var_match:
- var_match = re.search(r"'([^']+)'.*is not defined", error_msg)
- if var_match:
- undefined_var = var_match.group(1)
- suggestions.append(
- f"Variable '{undefined_var}' is not defined in the template spec"
- )
- # Suggest similar variable names (basic fuzzy matching)
- similar = [
- v
- for v in available_vars
- if undefined_var.lower() in v.lower()
- or v.lower() in undefined_var.lower()
- ]
- if similar:
- suggestions.append(
- f"Did you mean one of these? {', '.join(sorted(similar)[:5])}"
- )
- suggestions.append(
- f"Add '{undefined_var}' to your template.yaml spec with a default value"
- )
- suggestions.append(
- "Or use the Jinja2 default filter: {{ "
- + undefined_var
- + " | default('value') }}"
- )
- else:
- suggestions.append(
- "Check that all variables used in templates are defined in template.yaml"
- )
- suggestions.append(
- "Use the Jinja2 default filter for optional variables: {{ var | default('value') }}"
- )
- # Syntax errors
- elif "unexpected" in error_lower or "expected" in error_lower:
- suggestions.append("Check for syntax errors in your Jinja2 template")
- suggestions.append(
- "Common issues: missing {% endfor %}, {% endif %}, or {% endblock %}"
- )
- suggestions.append("Make sure all {{ }} and {% %} tags are properly closed")
- # Filter errors
- elif "filter" in error_lower:
- suggestions.append("Check that the filter name is spelled correctly")
- suggestions.append("Verify the filter exists in Jinja2 built-in filters")
- suggestions.append("Make sure filter arguments are properly formatted")
- # Template not found
- elif "not found" in error_lower or "does not exist" in error_lower:
- suggestions.append("Check that the included/imported template file exists")
- suggestions.append(
- "Verify the template path is relative to the template directory"
- )
- suggestions.append(
- "Make sure the file has the .j2 extension if it's a Jinja2 template"
- )
- # Type errors
- elif "type" in error_lower and (
- "int" in error_lower or "str" in error_lower or "bool" in error_lower
- ):
- suggestions.append("Check that variable values have the correct type")
- suggestions.append(
- "Use Jinja2 filters to convert types: {{ var | int }}, {{ var | string }}"
- )
- # Add generic helpful tip
- if not suggestions:
- suggestions.append("Check the Jinja2 template syntax and variable usage")
- suggestions.append(
- "Enable --debug mode for more detailed rendering information"
- )
- return suggestions
- def _parse_jinja_error(
- error: Exception,
- template_file: TemplateFile,
- template_dir: Path,
- available_vars: set,
- ) -> tuple[str, Optional[int], Optional[int], List[str], List[str]]:
- """Parse a Jinja2 exception to extract detailed error information.
- Args:
- error: The Jinja2 exception
- template_file: The TemplateFile being rendered
- template_dir: Template directory path
- available_vars: Set of available variable names
- Returns:
- Tuple of (error_message, line_number, column, context_lines, suggestions)
- """
- error_msg = str(error)
- line_number = None
- column = None
- context_lines = []
- suggestions = []
- # Extract line number from Jinja2 errors
- if hasattr(error, "lineno"):
- line_number = error.lineno
- # Extract file path and get context
- file_path = template_dir / template_file.relative_path
- if line_number and file_path.exists():
- context_lines = _extract_error_context(file_path, line_number)
- # Generate suggestions based on error type
- if isinstance(error, UndefinedError):
- error_msg = f"Undefined variable: {error}"
- suggestions = _get_common_jinja_suggestions(str(error), available_vars)
- elif isinstance(error, Jinja2TemplateSyntaxError):
- error_msg = f"Template syntax error: {error}"
- suggestions = _get_common_jinja_suggestions(str(error), available_vars)
- elif isinstance(error, Jinja2TemplateNotFound):
- error_msg = f"Template file not found: {error}"
- suggestions = _get_common_jinja_suggestions(str(error), available_vars)
- else:
- # Generic Jinja2 error
- suggestions = _get_common_jinja_suggestions(error_msg, available_vars)
- return error_msg, line_number, column, context_lines, suggestions
- @dataclass
- class TemplateFile:
- """Represents a single file within a template directory."""
- relative_path: Path
- file_type: Literal["j2", "static"]
- output_path: Path # The path it will have in the output directory
- @dataclass
- class TemplateMetadata:
- """Represents template metadata with proper typing."""
- name: str
- description: str
- author: str
- date: str
- version: str
- module: str = ""
- tags: List[str] = field(default_factory=list)
- library: str = "unknown"
- library_type: str = "git" # Type of library ("git" or "static")
- next_steps: str = ""
- draft: bool = False
- def __init__(
- self,
- template_data: dict,
- library_name: str | None = None,
- library_type: str = "git",
- ) -> None:
- """Initialize TemplateMetadata from parsed YAML template data.
- Args:
- template_data: Parsed YAML data from template.yaml
- library_name: Name of the library this template belongs to
- """
- # Validate metadata format first
- self._validate_metadata(template_data)
- # Extract metadata section
- metadata_section = template_data.get("metadata", {})
- self.name = metadata_section.get("name", "")
- # YAML block scalar (|) preserves a trailing newline. Remove only trailing newlines
- # while preserving internal newlines/formatting.
- raw_description = metadata_section.get("description", "")
- if isinstance(raw_description, str):
- description = raw_description.rstrip("\n")
- else:
- description = str(raw_description)
- self.description = description or "No description available"
- self.author = metadata_section.get("author", "")
- self.date = metadata_section.get("date", "")
- self.version = metadata_section.get("version", "")
- self.module = metadata_section.get("module", "")
- self.tags = metadata_section.get("tags", []) or []
- self.library = library_name or "unknown"
- self.library_type = library_type
- self.draft = metadata_section.get("draft", False)
- # Extract next_steps (optional)
- raw_next_steps = metadata_section.get("next_steps", "")
- if isinstance(raw_next_steps, str):
- next_steps = raw_next_steps.rstrip("\n")
- else:
- next_steps = str(raw_next_steps) if raw_next_steps else ""
- self.next_steps = next_steps
- @staticmethod
- def _validate_metadata(template_data: dict) -> None:
- """Validate that template has required 'metadata' section with all required fields.
- Args:
- template_data: Parsed YAML data from template.yaml
- Raises:
- ValueError: If metadata section is missing or incomplete
- """
- metadata_section = template_data.get("metadata")
- if metadata_section is None:
- raise ValueError("Template format error: missing 'metadata' section")
- # Validate that metadata section has all required fields
- required_fields = ["name", "author", "version", "date", "description"]
- missing_fields = [
- field for field in required_fields if not metadata_section.get(field)
- ]
- if missing_fields:
- raise ValueError(
- f"Template format error: missing required metadata fields: {missing_fields}"
- )
- @dataclass
- class Template:
- """Represents a template directory."""
- def __init__(
- self, template_dir: Path, library_name: str, library_type: str = "git"
- ) -> None:
- """Create a Template instance from a directory path.
- Args:
- template_dir: Path to the template directory
- library_name: Name of the library this template belongs to
- library_type: Type of library ("git" or "static"), defaults to "git"
- """
- logger.debug(f"Loading template from directory: {template_dir}")
- self.template_dir = template_dir
- self.id = template_dir.name
- self.original_id = template_dir.name # Store the original ID
- self.library_name = library_name
- self.library_type = library_type
- # Initialize caches for lazy loading
- self.__module_specs: Optional[dict] = None
- self.__merged_specs: Optional[dict] = None
- self.__jinja_env: Optional[Environment] = None
- self.__used_variables: Optional[Set[str]] = None
- self.__variables: Optional[VariableCollection] = None
- self.__template_files: Optional[List[TemplateFile]] = None # New attribute
- try:
- # Find and parse the main template file (template.yaml or template.yml)
- main_template_path = self._find_main_template_file()
- with open(main_template_path, "r", encoding="utf-8") as f:
- # Load all YAML documents (handles templates with empty lines before ---)
- documents = list(yaml.safe_load_all(f))
- # Filter out None/empty documents and get the first non-empty one
- valid_docs = [doc for doc in documents if doc is not None]
- if not valid_docs:
- raise ValueError("Template file contains no valid YAML data")
- if len(valid_docs) > 1:
- logger.warning(
- "Template file contains multiple YAML documents, using the first one"
- )
- self._template_data = valid_docs[0]
- # Validate template data
- if not isinstance(self._template_data, dict):
- raise ValueError("Template file must contain a valid YAML dictionary")
- # Load metadata (always needed)
- self.metadata = TemplateMetadata(
- self._template_data, library_name, library_type
- )
- logger.debug(f"Loaded metadata: {self.metadata}")
- # Validate 'kind' field (always needed)
- self._validate_kind(self._template_data)
- # Extract schema version (default to 1.0 for backward compatibility)
- self.schema_version = str(self._template_data.get("schema", "1.0"))
- logger.debug(f"Template schema version: {self.schema_version}")
- # Note: Schema version validation is done by the module when loading templates
- # NOTE: File collection is now lazy-loaded via the template_files property
- # This significantly improves performance when listing many templates
- logger.info(f"Loaded template '{self.id}' (v{self.metadata.version})")
- except (ValueError, FileNotFoundError) as e:
- logger.error(f"Error loading template from {template_dir}: {e}")
- raise TemplateLoadError(f"Error loading template from {template_dir}: {e}")
- except yaml.YAMLError as e:
- logger.error(f"YAML parsing error in template {template_dir}: {e}")
- raise YAMLParseError(str(template_dir / "template.y*ml"), e)
- except (IOError, OSError) as e:
- logger.error(f"File I/O error loading template {template_dir}: {e}")
- raise TemplateLoadError(
- f"File I/O error loading template from {template_dir}: {e}"
- )
- def set_qualified_id(self, library_name: str | None = None) -> None:
- """Set a qualified ID for this template (used when duplicates exist across libraries).
- Args:
- library_name: Name of the library to qualify with. If None, uses self.library_name
- """
- lib_name = library_name or self.library_name
- self.id = f"{self.original_id}.{lib_name}"
- logger.debug(f"Template ID qualified: {self.original_id} -> {self.id}")
- def _find_main_template_file(self) -> Path:
- """Find the main template file (template.yaml or template.yml)."""
- for filename in ["template.yaml", "template.yml"]:
- path = self.template_dir / filename
- if path.exists():
- return path
- raise FileNotFoundError(
- f"Main template file (template.yaml or template.yml) not found in {self.template_dir}"
- )
- @staticmethod
- @lru_cache(maxsize=32)
- def _load_module_specs_for_schema(kind: str, schema_version: str) -> dict:
- """Load specifications from the corresponding module for a specific schema version.
- Uses LRU cache to avoid re-loading the same module spec multiple times.
- This significantly improves performance when listing many templates of the same kind.
- Args:
- kind: The module kind (e.g., 'compose', 'terraform')
- schema_version: The schema version to load (e.g., '1.0', '1.1')
- Returns:
- Dictionary containing the module's spec for the requested schema version,
- or empty dict if kind is empty
- Raises:
- ValueError: If module cannot be loaded or spec is invalid
- """
- if not kind:
- return {}
- try:
- import importlib
- module = importlib.import_module(f"cli.modules.{kind}")
-
- # Check if module has schema-specific specs (multi-schema support)
- # Try SCHEMAS constant first (uppercase), then schemas attribute
- schemas = getattr(module, "SCHEMAS", None) or getattr(module, "schemas", None)
- if schemas and schema_version in schemas:
- spec = schemas[schema_version]
- logger.debug(
- f"Loaded and cached module spec for kind '{kind}' schema {schema_version}"
- )
- else:
- # Fallback to default spec if schema mapping not available
- spec = getattr(module, "spec", {})
- logger.debug(
- f"Loaded and cached module spec for kind '{kind}' (default/no schema mapping)"
- )
-
- return spec
- except Exception as e:
- raise ValueError(
- f"Error loading module specifications for kind '{kind}': {e}"
- )
- def _merge_specs(self, module_specs: dict, template_specs: dict) -> dict:
- """Deep merge template specs with module specs using VariableCollection.
- Uses VariableCollection's native merge() method for consistent merging logic.
- Module specs are base, template specs override with origin tracking.
- """
- # Create VariableCollection from module specs (base)
- module_collection = (
- VariableCollection(module_specs) if module_specs else VariableCollection({})
- )
- # Set origin for module variables
- for section in module_collection.get_sections().values():
- for variable in section.variables.values():
- if not variable.origin:
- variable.origin = "module"
- # Merge template specs into module specs (template overrides)
- if template_specs:
- merged_collection = module_collection.merge(
- template_specs, origin="template"
- )
- else:
- merged_collection = module_collection
- # Convert back to dict format
- merged_spec = {}
- for section_key, section in merged_collection.get_sections().items():
- merged_spec[section_key] = section.to_dict()
- return merged_spec
- def _collect_template_files(self) -> None:
- """Collects all TemplateFile objects in the template directory."""
- template_files: List[TemplateFile] = []
- for root, _, files in os.walk(self.template_dir):
- for filename in files:
- file_path = Path(root) / filename
- relative_path = file_path.relative_to(self.template_dir)
- # Skip the main template file
- if filename in ["template.yaml", "template.yml"]:
- continue
- if filename.endswith(".j2"):
- file_type: Literal["j2", "static"] = "j2"
- output_path = relative_path.with_suffix("") # Remove .j2 suffix
- else:
- file_type = "static"
- output_path = relative_path # Static files keep their name
- template_files.append(
- TemplateFile(
- relative_path=relative_path,
- file_type=file_type,
- output_path=output_path,
- )
- )
- self.__template_files = template_files
- def _extract_all_used_variables(self) -> Set[str]:
- """Extract all undeclared variables from all .j2 files in the template directory.
- Raises:
- ValueError: If any Jinja2 template has syntax errors
- """
- used_variables: Set[str] = set()
- syntax_errors = []
- for template_file in self.template_files: # Iterate over TemplateFile objects
- if template_file.file_type == "j2":
- file_path = self.template_dir / template_file.relative_path
- try:
- with open(file_path, "r", encoding="utf-8") as f:
- content = f.read()
- ast = self.jinja_env.parse(content) # Use lazy-loaded jinja_env
- used_variables.update(meta.find_undeclared_variables(ast))
- except (IOError, OSError) as e:
- relative_path = file_path.relative_to(self.template_dir)
- syntax_errors.append(f" - {relative_path}: File I/O error: {e}")
- except Exception as e:
- # Collect syntax errors for Jinja2 issues
- relative_path = file_path.relative_to(self.template_dir)
- syntax_errors.append(f" - {relative_path}: {e}")
- # Raise error if any syntax errors were found
- if syntax_errors:
- logger.error(f"Jinja2 syntax errors found in template '{self.id}'")
- raise TemplateSyntaxError(self.id, syntax_errors)
- return used_variables
- def _extract_jinja_default_values(self) -> dict[str, object]:
- """Scan all .j2 files and extract literal arguments to the `default` filter.
- Returns a mapping var_name -> literal_value for simple cases like
- {{ var | default("value") }} or {{ var | default(123) }}.
- This does not attempt to evaluate complex expressions.
- """
- class _DefaultVisitor(NodeVisitor):
- def __init__(self):
- self.found: dict[str, object] = {}
- def visit_Filter(self, node: nodes.Filter) -> None: # type: ignore[override]
- try:
- if getattr(node, "name", None) == "default" and node.args:
- # target variable name when filter is applied directly to a Name
- target = None
- if isinstance(node.node, nodes.Name):
- target = node.node.name
- # first arg literal
- first = node.args[0]
- if isinstance(first, nodes.Const) and target:
- self.found[target] = first.value
- except Exception:
- # Be resilient to unexpected node shapes
- pass
- # continue traversal
- self.generic_visit(node)
- visitor = _DefaultVisitor()
- for template_file in self.template_files:
- if template_file.file_type != "j2":
- continue
- file_path = self.template_dir / template_file.relative_path
- try:
- with open(file_path, "r", encoding="utf-8") as f:
- content = f.read()
- ast = self.jinja_env.parse(content)
- visitor.visit(ast)
- except (IOError, OSError, yaml.YAMLError):
- # Skip failures - this extraction is best-effort only
- continue
- return visitor.found
- def _filter_specs_to_used(
- self,
- used_variables: set,
- merged_specs: dict,
- module_specs: dict,
- template_specs: dict,
- ) -> dict:
- """Filter specs to only include variables used in templates using VariableCollection.
- Uses VariableCollection's native filter_to_used() method.
- Keeps sensitive variables only if they're defined in the template spec or actually used.
- """
- # Build set of variables explicitly defined in template spec
- template_defined_vars = set()
- for section_data in (template_specs or {}).values():
- if isinstance(section_data, dict) and "vars" in section_data:
- template_defined_vars.update(section_data["vars"].keys())
- # Create VariableCollection from merged specs
- merged_collection = VariableCollection(merged_specs)
- # Filter to only used variables (and sensitive ones that are template-defined)
- # We keep sensitive variables that are either:
- # 1. Actually used in template files, OR
- # 2. Explicitly defined in the template spec (even if not yet used)
- variables_to_keep = used_variables | template_defined_vars
- filtered_collection = merged_collection.filter_to_used(
- variables_to_keep, keep_sensitive=False
- )
- # Convert back to dict format
- filtered_specs = {}
- for section_key, section in filtered_collection.get_sections().items():
- filtered_specs[section_key] = section.to_dict()
- return filtered_specs
- def _validate_schema_version(self, module_schema: str, module_name: str) -> None:
- """Validate that template schema version is supported by the module.
- Args:
- module_schema: Schema version supported by the module
- module_name: Name of the module (for error messages)
- Raises:
- IncompatibleSchemaVersionError: If template schema > module schema
- """
- template_schema = self.schema_version
- # Compare schema versions
- if not is_compatible(module_schema, template_schema):
- logger.error(
- f"Template '{self.id}' uses schema version {template_schema}, "
- f"but module '{module_name}' only supports up to {module_schema}"
- )
- raise IncompatibleSchemaVersionError(
- template_id=self.id,
- template_schema=template_schema,
- module_schema=module_schema,
- module_name=module_name,
- )
- logger.debug(
- f"Template '{self.id}' schema version compatible: "
- f"template uses {template_schema}, module supports {module_schema}"
- )
- @staticmethod
- def _validate_kind(template_data: dict) -> None:
- """Validate that template has required 'kind' field.
- Args:
- template_data: Parsed YAML data from template.yaml
- Raises:
- ValueError: If 'kind' field is missing
- """
- if not template_data.get("kind"):
- raise TemplateValidationError("Template format error: missing 'kind' field")
- def _validate_variable_definitions(
- self, used_variables: set[str], merged_specs: dict[str, Any]
- ) -> None:
- """Validate that all variables used in Jinja2 content are defined in the spec."""
- defined_variables = set()
- for section_data in merged_specs.values():
- if "vars" in section_data and isinstance(section_data["vars"], dict):
- defined_variables.update(section_data["vars"].keys())
- undefined_variables = used_variables - defined_variables
- if undefined_variables:
- undefined_list = sorted(undefined_variables)
- error_msg = (
- f"Template validation error in '{self.id}': "
- f"Variables used in template content but not defined in spec: {undefined_list}\n\n"
- f"Please add these variables to your template's template.yaml spec. "
- f"Each variable must have a default value.\n\n"
- f"Example:\n"
- f"spec:\n"
- f" general:\n"
- f" vars:\n"
- )
- for var_name in undefined_list:
- error_msg += (
- f" {var_name}:\n"
- f" type: str\n"
- f" description: Description for {var_name}\n"
- f" default: <your_default_value_here>\n"
- )
- logger.error(error_msg)
- raise TemplateValidationError(error_msg)
- @staticmethod
- def _create_jinja_env(searchpath: Path) -> Environment:
- """Create sandboxed Jinja2 environment for secure template processing.
- Uses SandboxedEnvironment to prevent code injection vulnerabilities
- when processing untrusted templates. This restricts access to dangerous
- operations while still allowing safe template rendering.
- Returns:
- SandboxedEnvironment configured for template processing.
- """
- # NOTE Use SandboxedEnvironment for security - prevents arbitrary code execution
- return SandboxedEnvironment(
- loader=FileSystemLoader(searchpath),
- trim_blocks=True,
- lstrip_blocks=True,
- keep_trailing_newline=False,
- )
- def render(
- self, variables: VariableCollection, debug: bool = False
- ) -> tuple[Dict[str, str], Dict[str, Any]]:
- """Render all .j2 files in the template directory.
- Args:
- variables: VariableCollection with values to use for rendering
- debug: Enable debug mode with verbose output
- Returns:
- Tuple of (rendered_files, variable_values) where variable_values includes autogenerated values
- """
- # Use get_satisfied_values() to exclude variables from sections with unsatisfied dependencies
- variable_values = variables.get_satisfied_values()
- # Auto-generate values for autogenerated variables that are empty
- import secrets
- import string
- for section in variables.get_sections().values():
- for var_name, variable in section.variables.items():
- if variable.autogenerated and (
- variable.value is None or variable.value == ""
- ):
- # Generate a secure random string (32 characters by default)
- alphabet = string.ascii_letters + string.digits
- generated_value = "".join(
- secrets.choice(alphabet) for _ in range(32)
- )
- variable_values[var_name] = generated_value
- logger.debug(f"Auto-generated value for variable '{var_name}'")
- if debug:
- logger.info(f"Rendering template '{self.id}' in debug mode")
- logger.info(f"Available variables: {sorted(variable_values.keys())}")
- logger.info(f"Variable values: {variable_values}")
- else:
- logger.debug(
- f"Rendering template '{self.id}' with variables: {variable_values}"
- )
- rendered_files = {}
- available_vars = set(variable_values.keys())
- for template_file in self.template_files: # Iterate over TemplateFile objects
- if template_file.file_type == "j2":
- try:
- if debug:
- logger.info(
- f"Rendering Jinja2 template: {template_file.relative_path}"
- )
- template = self.jinja_env.get_template(
- str(template_file.relative_path)
- ) # Use lazy-loaded jinja_env
- rendered_content = template.render(**variable_values)
- # Sanitize the rendered content to remove excessive blank lines
- rendered_content = self._sanitize_content(
- rendered_content, template_file.output_path
- )
- rendered_files[str(template_file.output_path)] = rendered_content
- if debug:
- logger.info(
- f"Successfully rendered: {template_file.relative_path} -> {template_file.output_path}"
- )
- except (
- UndefinedError,
- Jinja2TemplateSyntaxError,
- Jinja2TemplateNotFound,
- Jinja2TemplateError,
- ) as e:
- # Parse Jinja2 error to extract detailed information
- error_msg, line_num, col, context_lines, suggestions = (
- _parse_jinja_error(
- e, template_file, self.template_dir, available_vars
- )
- )
- logger.error(
- f"Error rendering template file {template_file.relative_path}: {error_msg}"
- )
- # Create enhanced TemplateRenderError with all context
- raise TemplateRenderError(
- message=error_msg,
- file_path=str(template_file.relative_path),
- line_number=line_num,
- column=col,
- context_lines=context_lines,
- variable_context={k: str(v) for k, v in variable_values.items()}
- if debug
- else {},
- suggestions=suggestions,
- original_error=e,
- )
- except Exception as e:
- # Catch any other unexpected errors
- logger.error(
- f"Unexpected error rendering template file {template_file.relative_path}: {e}"
- )
- raise TemplateRenderError(
- message=f"Unexpected rendering error: {e}",
- file_path=str(template_file.relative_path),
- suggestions=[
- "This is an unexpected error. Please check the template for issues."
- ],
- original_error=e,
- )
- elif template_file.file_type == "static":
- # For static files, just read their content and add to rendered_files
- # This ensures static files are also part of the output dictionary
- file_path = self.template_dir / template_file.relative_path
- try:
- if debug:
- logger.info(
- f"Copying static file: {template_file.relative_path}"
- )
- with open(file_path, "r", encoding="utf-8") as f:
- content = f.read()
- rendered_files[str(template_file.output_path)] = content
- except (IOError, OSError) as e:
- logger.error(f"Error reading static file {file_path}: {e}")
- raise TemplateRenderError(
- message=f"Error reading static file: {e}",
- file_path=str(template_file.relative_path),
- suggestions=[
- "Check that the file exists and has read permissions"
- ],
- original_error=e,
- )
- return rendered_files, variable_values
- def _sanitize_content(self, content: str, file_path: Path) -> str:
- """Sanitize rendered content by removing excessive blank lines and trailing whitespace."""
- if not content:
- return content
- lines = [line.rstrip() for line in content.split("\n")]
- sanitized = []
- prev_blank = False
- for line in lines:
- is_blank = not line
- if is_blank and prev_blank:
- continue # Skip consecutive blank lines
- sanitized.append(line)
- prev_blank = is_blank
- # Remove leading blanks and ensure single trailing newline
- return "\n".join(sanitized).lstrip("\n").rstrip("\n") + "\n"
- @property
- def template_files(self) -> List[TemplateFile]:
- if self.__template_files is None:
- self._collect_template_files() # Populate self.__template_files
- return self.__template_files
- @property
- def template_specs(self) -> dict:
- """Get the spec section from template YAML data."""
- return self._template_data.get("spec", {})
- @property
- def module_specs(self) -> dict:
- """Get the spec from the module definition for this template's schema version."""
- if self.__module_specs is None:
- kind = self._template_data.get("kind")
- self.__module_specs = self._load_module_specs_for_schema(
- kind, self.schema_version
- )
- return self.__module_specs
- @property
- def merged_specs(self) -> dict:
- if self.__merged_specs is None:
- self.__merged_specs = self._merge_specs(
- self.module_specs, self.template_specs
- )
- return self.__merged_specs
- @property
- def jinja_env(self) -> Environment:
- if self.__jinja_env is None:
- self.__jinja_env = self._create_jinja_env(self.template_dir)
- return self.__jinja_env
- @property
- def used_variables(self) -> Set[str]:
- if self.__used_variables is None:
- self.__used_variables = self._extract_all_used_variables()
- return self.__used_variables
- @property
- def variables(self) -> VariableCollection:
- if self.__variables is None:
- # Validate that all used variables are defined
- self._validate_variable_definitions(self.used_variables, self.merged_specs)
- # Filter specs to only used variables
- filtered_specs = self._filter_specs_to_used(
- self.used_variables,
- self.merged_specs,
- self.module_specs,
- self.template_specs,
- )
- # Best-effort: extract literal defaults from Jinja `default()` filter and
- # merge them into the filtered_specs when no default exists there.
- try:
- jinja_defaults = self._extract_jinja_default_values()
- for section_key, section_data in filtered_specs.items():
- # Guard against None from empty YAML sections
- vars_dict = section_data.get("vars") or {}
- for var_name, var_data in vars_dict.items():
- if "default" not in var_data or var_data.get("default") in (
- None,
- "",
- ):
- if var_name in jinja_defaults:
- var_data["default"] = jinja_defaults[var_name]
- except (KeyError, TypeError, AttributeError):
- # Keep behavior stable on any extraction errors
- pass
- self.__variables = VariableCollection(filtered_specs)
- # Sort sections: required first, then enabled, then disabled
- self.__variables.sort_sections()
- return self.__variables
|