Source code for aly.templates.loader

# Copyright 2025 Mohamed Aly
# SPDX-License-Identifier: Apache-2.0

"""Template loader for ALY project generation."""

from __future__ import annotations

import glob
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

import yaml
import re

# Optional Jinja2 support - falls back to simple string replacement
try:
    from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined

    HAS_JINJA2 = True
except ImportError:
    HAS_JINJA2 = False


@dataclass
class TemplateVariable:
    """Definition of a template variable that can be prompted or provided."""

    name: str
    description: str = ""
    default: Any = None
    choices: list[str] = field(default_factory=list)
    pattern: str | None = None
    required: bool = True


@dataclass
class TemplateInfo:
    """Basic information about a template."""

    name: str
    description: str
    version: str = "1.0"
    extends: str | None = None


[docs] class TemplateLoader: """ Loads and processes project templates from the templates directory. Each template consists of: - template.yaml: Single file containing metadata, variables, structure, and file mappings - files/: Directory containing actual template files (can include .j2 Jinja2 templates) Example usage: loader = TemplateLoader() templates = loader.list_templates() loader.create_project("soc", Path("my-project"), {"project_name": "my-soc"}) """ TEMPLATE_FILE = "template.yaml" def __init__(self, templates_dir: Path | None = None): """ Initialize the template loader. Args: templates_dir: Custom templates directory. Defaults to the templates directory within this package. """ if templates_dir is None: self.templates_dir = Path(__file__).parent else: self.templates_dir = Path(templates_dir)
[docs] def list_templates(self) -> list[TemplateInfo]: """ List all available templates. Returns: List of TemplateInfo objects for each valid template. """ templates = [] for path in sorted(self.templates_dir.iterdir()): if not path.is_dir(): continue template_file = path / self.TEMPLATE_FILE if not template_file.exists(): continue with open(template_file, encoding="utf-8") as f: data = yaml.safe_load(f) templates.append( TemplateInfo( name=path.name, description=data.get("description", ""), version=data.get("version", "1.0"), extends=data.get("extends"), ) ) return templates
def get_template_names(self) -> list[str]: """Get list of template names for CLI choices.""" return [t.name for t in self.list_templates()] def load_template(self, template_name: str) -> dict[str, Any]: """ Load a template's configuration file. Args: template_name: Name of the template directory. Returns: Parsed template dictionary with added _path key. Raises: ValueError: If template doesn't exist. """ template_dir = self.templates_dir / template_name template_file = template_dir / self.TEMPLATE_FILE if not template_file.exists(): available = ", ".join(self.get_template_names()) raise ValueError( f"Template '{template_name}' not found. Available: {available}" ) with open(template_file, encoding="utf-8") as f: template = yaml.safe_load(f) # Store the template path for file resolution template["_path"] = template_dir return template def get_variables(self, template_name: str) -> list[TemplateVariable]: """ Get the variables defined by a template. Args: template_name: Name of the template. Returns: List of TemplateVariable objects. """ template = self.load_template(template_name) variables = [] for name, spec in template.get("variables", {}).items(): if isinstance(spec, dict): variables.append( TemplateVariable( name=name, description=spec.get("description", ""), default=spec.get("default"), choices=spec.get("choices", []), pattern=spec.get("pattern"), required=spec.get("required", True), ) ) else: # Simple default value variables.append( TemplateVariable(name=name, default=spec, required=False) ) return variables def create_project( self, template_name: str, project_path: Path, variables: dict[str, Any], log_callback: Callable[[str], None] | None = None, ) -> None: """ Create a new project from a template. Args: template_name: Name of the template to use. project_path: Destination directory for the project. variables: Variable values for template rendering. log_callback: Optional function to call with log messages. """ log = log_callback or (lambda msg: None) template = self.load_template(template_name) template_dir = template["_path"] files_dir = template_dir / "files" # Handle template inheritance if template.get("extends"): base_template = self.load_template(template["extends"]) template = self._merge_templates(base_template, template) base_files_dir = base_template["_path"] / "files" else: base_files_dir = None # Create directory structure if "structure" in template: directories = template["structure"].get("directories", []) self._create_directories(project_path, directories, log) # Setup Jinja2 environment if available and needed jinja_env = self._create_jinja_env(files_dir, base_files_dir) # Process files for file_spec in template.get("files", []): # Determine source directory (base or current) if file_spec.get("from_base") and base_files_dir: src_dir = base_files_dir else: src_dir = files_dir self._process_file( file_spec, src_dir, project_path, variables, jinja_env, log ) # Run post-create hooks hooks = template.get("hooks", {}).get("post_create", []) self._run_hooks(hooks, project_path, variables, log) def _create_jinja_env( self, files_dir: Path, base_files_dir: Path | None = None ) -> Environment | None: """Create Jinja2 environment with template directories.""" if not HAS_JINJA2: return None # Build search path for templates search_paths = [str(files_dir)] if base_files_dir and base_files_dir.exists(): search_paths.append(str(base_files_dir)) # Use a permissive Undefined so templates with filters/defaults # continue to render even if optional variables are missing. return Environment( loader=FileSystemLoader(search_paths), undefined=Undefined, keep_trailing_newline=True, trim_blocks=True, lstrip_blocks=True, ) def _create_directories( self, base_path: Path, structure: list[Any], log: Callable[[str], None], prefix: str = "", ) -> None: """ Recursively create directory structure. Args: base_path: Base directory to create structure in. structure: List of directory definitions (strings or nested dicts). log: Logging callback function. prefix: Current path prefix for logging. """ for item in structure: if isinstance(item, str): # Simple directory name dir_path = base_path / item dir_path.mkdir(parents=True, exist_ok=True) log(f" Created: {prefix}{item}/") elif isinstance(item, dict): # Nested structure: {"dirname": [children]} for dir_name, children in item.items(): dir_path = base_path / dir_name dir_path.mkdir(parents=True, exist_ok=True) log(f" Created: {prefix}{dir_name}/") if children: self._create_directories( dir_path, children, log, prefix=f"{prefix}{dir_name}/" ) def _process_file( self, spec: dict[str, Any], src_dir: Path, dest_dir: Path, variables: dict[str, Any], jinja_env: Environment | None, log: Callable[[str], None], ) -> None: """ Process a single file specification. Args: spec: File specification dict with src, dest, template, when keys. src_dir: Source directory containing template files. dest_dir: Destination directory for output. variables: Template variables. jinja_env: Jinja2 environment or None. log: Logging callback. """ src_pattern = spec["src"] dest_pattern = spec["dest"] is_template = spec.get("template", False) condition = spec.get("when") # Evaluate condition if present if condition and not self._evaluate_condition(condition, variables, jinja_env): return # Handle glob patterns (* or ? or **) and directory copies if "*" in src_pattern or "?" in src_pattern: # Expand glob pattern pattern_path = src_dir / src_pattern # Determine the base directory for relative path calculation # For patterns like "tb/unit/**/*", base is "tb/unit" # For patterns like "tb/unit/*", base is "tb/unit" # For patterns like "**/*.yaml", base is "." pattern_parts = src_pattern.replace("\\", "/").split("/") base_parts = [] for part in pattern_parts: if "*" in part or "?" in part: break base_parts.append(part) base_dir = "/".join(base_parts) if base_parts else "." # Use glob with recursive=True for ** patterns matched_files = glob.glob(str(pattern_path), recursive=True) for src_file in matched_files: src_path = Path(src_file) # Skip directories - only process files if src_path.is_dir(): continue # Calculate relative path from base directory try: if base_dir == ".": rel_path = src_path.relative_to(src_dir) else: rel_path = src_path.relative_to(src_dir / base_dir) except ValueError: rel_path = Path(src_path.name) # Build destination path preserving directory structure dest_path = dest_dir / dest_pattern / rel_path self._copy_or_render( src_path, dest_path, is_template, variables, jinja_env, log ) # Handle directory copy (src ends with /) elif src_pattern.endswith("/"): src_path = src_dir / src_pattern.rstrip("/") if not src_path.exists() or not src_path.is_dir(): return # Skip missing directories # Copy all files recursively from the directory for src_file in src_path.rglob("*"): if src_file.is_dir(): continue rel_path = src_file.relative_to(src_path) dest_path = dest_dir / dest_pattern / rel_path self._copy_or_render( src_file, dest_path, is_template, variables, jinja_env, log ) else: # Single file src_path = src_dir / src_pattern if not src_path.exists(): return # Skip missing optional files # Handle .j2 extension removal dest_name = dest_pattern if dest_name.endswith(".j2"): dest_name = dest_name[:-3] dest_path = dest_dir / dest_name self._copy_or_render( src_path, dest_path, is_template, variables, jinja_env, log ) def _evaluate_condition( self, condition: str, variables: dict[str, Any], jinja_env: Environment | None, ) -> bool: """Evaluate a condition expression.""" if jinja_env: try: result = jinja_env.from_string("{{ " + condition + " }}").render( **variables ) return result.lower() not in ("false", "0", "", "none") except Exception: return True else: # Simple variable check without Jinja2 return bool(variables.get(condition, True)) def _copy_or_render( self, src: Path, dest: Path, is_template: bool, variables: dict[str, Any], jinja_env: Environment | None, log: Callable[[str], None], ) -> None: """ Copy a file or render it as a template. Args: src: Source file path. dest: Destination file path. is_template: Whether to process as Jinja2 template. variables: Template variables. jinja_env: Jinja2 environment or None. log: Logging callback. """ dest.parent.mkdir(parents=True, exist_ok=True) if is_template and src.suffix == ".j2": if jinja_env: # Render with Jinja2 using from_string to avoid loader path issues try: raw = src.read_text(encoding="utf-8") template = jinja_env.from_string(raw) content = template.render(**variables) dest.write_text(content, encoding="utf-8") except Exception: # Fallback to simple copy if template fails content = src.read_text(encoding="utf-8") content = self._simple_render(content, variables) dest.write_text(content, encoding="utf-8") else: # Simple string replacement fallback content = src.read_text(encoding="utf-8") content = self._simple_render(content, variables) dest.write_text(content, encoding="utf-8") else: # Direct copy shutil.copy2(src, dest) log(f" Created: {dest.name}") def _simple_render(self, content: str, variables: dict[str, Any]) -> str: """ Simple template rendering without Jinja2. Replaces {{ variable_name }} patterns with values. """ # Remove simple Jinja2 block tags like {% if ... %}, {% endif %}, etc. content = re.sub(r"{%[^%]*%}", "", content) # Replace variable placeholders for key, value in variables.items(): content = content.replace("{{ " + key + " }}", str(value)) content = content.replace("{{" + key + "}}", str(value)) return content def _merge_templates( self, base: dict[str, Any], child: dict[str, Any] ) -> dict[str, Any]: """ Merge a base template into a child template. Child values override base values. Lists are concatenated. """ result = {} # Copy base values for key, value in base.items(): if key.startswith("_"): continue result[key] = value # Override/extend with child values for key, value in child.items(): if key == "variables": # Merge variables dicts result["variables"] = { **base.get("variables", {}), **child.get("variables", {}), } elif key == "files": # Concatenate file lists, base first base_files = [{**f, "from_base": True} for f in base.get("files", [])] result["files"] = base_files + child.get("files", []) elif key == "structure": # Child structure overrides base result["structure"] = value elif key.startswith("_"): continue else: result[key] = value # Preserve child path result["_path"] = child["_path"] return result def _run_hooks( self, hooks: list[dict[str, Any] | str], project_path: Path, variables: dict[str, Any], log: Callable[[str], None], ) -> None: """ Run post-creation hooks. Args: hooks: List of hook definitions (strings or dicts with cmd/when). project_path: Working directory for commands. variables: Variables for condition evaluation and substitution. log: Logging callback. """ for hook in hooks: if isinstance(hook, str): cmd = hook condition = None else: cmd = hook.get("cmd", "") condition = hook.get("when") # Check condition if condition is not None: # Handle boolean conditions directly if isinstance(condition, bool): if not condition: continue else: var_name = str(condition).strip() # Handle string boolean literals if var_name in ("true", "True", "1"): pass # Always run elif var_name in ("false", "False", "0"): continue # Never run elif not variables.get(var_name, True): continue if not cmd: continue # Substitute variables in command cmd = self._simple_render(cmd, variables) try: log(f" Running: {cmd[:80]}...") subprocess.run( cmd, shell=True, cwd=project_path, check=False, capture_output=True, timeout=120, ) except subprocess.TimeoutExpired: log(f" Warning: Hook timed out") except Exception as e: log(f" Warning: Hook failed: {e}")