Forráskód Böngészése

better logging and variable handling

xcad 7 hónapja
szülő
commit
336dc7a028

+ 107 - 14
cli/__main__.py

@@ -4,57 +4,150 @@ Main entry point for the Boilerplates CLI application.
 This file serves as the primary executable when running the CLI.
 """
 import importlib
+import logging
 import pkgutil
 import sys
 from pathlib import Path
-from typer import Typer, Context
+from typing import Optional
+from typer import Typer, Context, Option
 from rich.console import Console
 import cli.modules
 from cli.core.registry import registry
-from cli.core.exceptions import BoilerplateError
+# Using standard Python exceptions instead of custom ones
 
 app = Typer(no_args_is_help=True)
 console = Console()
 
+def setup_logging(log_level: str = "WARNING"):
+  """Configure the logging system with the specified log level.
+  
+  Args:
+      log_level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+  
+  Raises:
+      ValueError: If the log level is invalid
+      RuntimeError: If logging configuration fails
+  """
+  # Convert string to logging level
+  numeric_level = getattr(logging, log_level.upper(), None)
+  if not isinstance(numeric_level, int):
+    raise ValueError(
+      f"Invalid log level '{log_level}'. Valid levels: DEBUG, INFO, WARNING, ERROR, CRITICAL"
+    )
+  
+  try:
+    # Configure root logger
+    logging.basicConfig(
+      level=numeric_level,
+      format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+      datefmt='%Y-%m-%d %H:%M:%S'
+    )
+    
+    # Get the boilerplates logger and set its level
+    logger = logging.getLogger('boilerplates')
+    logger.setLevel(numeric_level)
+  except Exception as e:
+    raise RuntimeError(f"Failed to configure logging: {e}")
+
+
 @app.callback()
-def main(ctx: Context):
+def main(
+  ctx: Context,
+  loglevel: Optional[str] = Option(
+    "WARNING", 
+    "--loglevel", 
+    help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
+  )
+):
   """Main CLI application for managing boilerplates."""
-  pass
+  # Configure logging based on the provided log level
+  setup_logging(loglevel)
+  
+  # Store log level in context for potential use by other commands
+  ctx.ensure_object(dict)
+  ctx.obj['loglevel'] = loglevel
 
 def init_app():
-  """Initialize the application by discovering and registering modules."""
+  """Initialize the application by discovering and registering modules.
+  
+  Raises:
+      ImportError: If critical module import operations fail
+      RuntimeError: If application initialization fails
+  """
+  logger = logging.getLogger('boilerplates')
+  failed_imports = []
+  failed_registrations = []
+  
   try:
     # Auto-discover and import all modules
     modules_path = Path(cli.modules.__file__).parent
+    logger.debug(f"Discovering modules in {modules_path}")
     
     for finder, name, ispkg in pkgutil.iter_modules([str(modules_path)]):
       if not ispkg and not name.startswith('_') and name != 'base':
         try:
+          logger.debug(f"Importing module: {name}")
           importlib.import_module(f"cli.modules.{name}")
         except ImportError as e:
-          # Silently skip modules that can't be imported
-          pass
+          error_info = f"Import failed for '{name}': {str(e)}"
+          failed_imports.append(error_info)
+          logger.warning(error_info)
+        except Exception as e:
+          error_info = f"Unexpected error importing '{name}': {str(e)}"
+          failed_imports.append(error_info)
+          logger.error(error_info)
     
     # Register modules with app
-    for module in registry.create_instances():
+    modules = registry.create_instances()
+    logger.debug(f"Registering {len(modules)} discovered modules")
+    
+    for module in modules:
       try:
+        logger.debug(f"Registering module: {module.__class__.__name__}")
         module.register_cli(app)
       except Exception as e:
-        console.print(f"[yellow]Warning:[/yellow] Error registering {module.__class__.__name__}: {e}")
+        error_info = f"Registration failed for '{module.__class__.__name__}': {str(e)}"
+        failed_registrations.append(error_info)
+        # Log warning but don't raise exception for individual module failures
+        logger.warning(error_info)
+        console.print(f"[yellow]Warning:[/yellow] {error_info}")
     
+    # If we have no modules registered at all, that's a critical error
+    if not modules and not failed_imports:
+      raise RuntimeError("No modules found to register")
+    
+    # Log summary
+    successful_modules = len(modules) - len(failed_registrations)
+    logger.info(f"Application initialized: {successful_modules} modules registered successfully")
+    
+    if failed_imports:
+      logger.info(f"Module import failures: {len(failed_imports)}")
+    if failed_registrations:
+      logger.info(f"Module registration failures: {len(failed_registrations)}")
+      
   except Exception as e:
-    console.print(f"[bold red]Application initialization error:[/bold red] {e}")
-    sys.exit(1)
+    error_details = []
+    if failed_imports:
+      error_details.extend(["Import failures:"] + [f"  - {err}" for err in failed_imports])
+    if failed_registrations:
+      error_details.extend(["Registration failures:"] + [f"  - {err}" for err in failed_registrations])
+    
+    details = "\n".join(error_details) if error_details else str(e)
+    raise RuntimeError(f"Application initialization failed: {details}")
 
 def run():
   """Run the CLI application."""
   try:
     init_app()
     app()
-  except BoilerplateError as e:
-    # Handle our custom exceptions cleanly without stack trace
+  except (ValueError, RuntimeError) as e:
+    # Handle configuration and initialization errors cleanly
     console.print(f"[bold red]Error:[/bold red] {e}")
     sys.exit(1)
+  except ImportError as e:
+    # Handle module import errors with detailed info
+    console.print(f"[bold red]Module Import Error:[/bold red] {e}")
+    sys.exit(1)
   except KeyboardInterrupt:
     # Handle Ctrl+C gracefully
     console.print("\n[yellow]Operation cancelled by user[/yellow]")
@@ -62,7 +155,7 @@ def run():
   except Exception as e:
     # Handle unexpected errors - show simplified message
     console.print(f"[bold red]Unexpected error:[/bold red] {e}")
-    console.print("[dim]Run with --debug for more details[/dim]")
+    console.print("[dim]Use --loglevel DEBUG for more details[/dim]")
     sys.exit(1)
 
 if __name__ == "__main__":

+ 4 - 7
cli/core/config.py

@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
 import logging
 import yaml
 
-from .exceptions import ConfigurationError
+# Using standard Python exceptions
 
 logger = logging.getLogger('boilerplates')
 
@@ -91,7 +91,7 @@ class Config:
         return config
         
       except yaml.YAMLError as e:
-        raise ConfigurationError("config.yaml", f"Invalid YAML format: {e}")
+        raise ValueError(f"Invalid YAML format in config.yaml: {e}")
       except Exception as e:
         logger.warning(f"Failed to load config from {config_path}: {e}, using defaults")
         return cls()
@@ -142,7 +142,7 @@ class Config:
         yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
       logger.debug(f"Saved configuration to {config_path}")
     except Exception as e:
-      raise ConfigurationError("config.yaml", f"Failed to save: {e}")
+      raise OSError(f"Failed to save config.yaml: {e}")
   
   def add_library(self, library):
     """Add a library configuration.
@@ -153,10 +153,7 @@ class Config:
     # Check for duplicate names
     existing_names = {lib.name for lib in self.libraries}
     if library.name in existing_names:
-      raise ConfigurationError(
-        f"library:{library.name}",
-        f"Library with name '{library.name}' already exists"
-      )
+      raise ValueError(f"Library with name '{library.name}' already exists")
     
     self.libraries.append(library)
     # Sort by priority (highest first)

+ 0 - 89
cli/core/exceptions.py

@@ -1,89 +0,0 @@
-"""Custom exceptions for the boilerplate CLI application."""
-
-
-class BoilerplateError(Exception):
-  """Base exception for all boilerplate-related errors."""
-  pass
-
-
-class TemplateError(BoilerplateError):
-  """Base exception for template-related errors."""
-  pass
-
-
-class TemplateNotFoundError(TemplateError):
-  """Raised when a template cannot be found."""
-  
-  def __init__(self, template_id: str, module_name: str = None):
-    if module_name:
-      message = f"Template '{template_id}' not found in module '{module_name}'"
-    else:
-      message = f"Template '{template_id}' not found"
-    super().__init__(message)
-    self.template_id = template_id
-    self.module_name = module_name
-
-
-class InvalidTemplateError(TemplateError):
-  """Raised when a template has invalid format or content."""
-  
-  def __init__(self, template_path: str, reason: str):
-    message = f"Invalid template at '{template_path}': {reason}"
-    super().__init__(message)
-    self.template_path = template_path
-    self.reason = reason
-
-
-class TemplateValidationError(TemplateError):
-  """Raised when template validation fails."""
-  
-  def __init__(self, template_id: str, errors: list):
-    message = f"Template '{template_id}' validation failed:\n" + "\n".join(f"  - {e}" for e in errors)
-    super().__init__(message)
-    self.template_id = template_id
-    self.errors = errors
-
-
-class VariableError(BoilerplateError):
-  """Base exception for variable-related errors."""
-  pass
-
-
-class UndefinedVariableError(VariableError):
-  """Raised when a template references undefined variables."""
-  
-  def __init__(self, variable_names: set, template_id: str = None):
-    var_list = ", ".join(sorted(variable_names))
-    if template_id:
-      message = f"Template '{template_id}' references undefined variables: {var_list}"
-    else:
-      message = f"Undefined variables: {var_list}"
-    super().__init__(message)
-    self.variable_names = variable_names
-    self.template_id = template_id
-
-
-class LibraryError(BoilerplateError):
-  """Base exception for library-related errors."""
-  pass
-
-
-class RemoteLibraryError(LibraryError):
-  """Raised when operations on remote libraries fail."""
-  
-  def __init__(self, library_name: str, operation: str, reason: str):
-    message = f"Remote library '{library_name}' {operation} failed: {reason}"
-    super().__init__(message)
-    self.library_name = library_name
-    self.operation = operation
-    self.reason = reason
-
-
-class ConfigurationError(BoilerplateError):
-  """Raised when configuration is invalid or missing."""
-  
-  def __init__(self, config_item: str, reason: str):
-    message = f"Configuration error for '{config_item}': {reason}"
-    super().__init__(message)
-    self.config_item = config_item
-    self.reason = reason

+ 30 - 16
cli/core/library.py

@@ -2,7 +2,7 @@ from pathlib import Path
 import subprocess
 import logging
 from .config import get_config, LibraryConfig
-from .exceptions import RemoteLibraryError
+# Using standard Python exceptions
 
 logger = logging.getLogger('boilerplates')
 
@@ -31,9 +31,33 @@ class Library:
     Returns:
         Template object if found, None otherwise.
     """
+    from .template import Template  # Import here to avoid circular import
+    
+    module_path = self.path / module_name
+    if not module_path.exists():
+      return None
+    
+    # Try to find the template directory directly by ID
+    template_dir = module_path / template_id
+    if template_dir.exists() and template_dir.is_dir():
+      # Look for template files in this specific directory
+      for filename in files:
+        for file_path in template_dir.glob(filename):
+          if file_path.is_file():
+            template = Template.from_file(file_path)
+            # Set module context if not already specified in frontmatter
+            if not template.module:
+              template.module = module_name
+            # Verify this is actually the template we want
+            if template.id == template_id:
+              return template
+    
+    # Fallback to the original method if direct lookup fails
+    # This handles cases where template ID doesn't match directory structure
     for template in self.find(module_name, files, sorted=False):
       if template.id == template_id:
         return template
+    
     return None
 
   def find(self, module_name, files, sorted=False):
@@ -112,10 +136,7 @@ class RemoteLibrary(Library):
         )
         
         if result.returncode != 0:
-          raise RemoteLibraryError(
-            self.name, "clone", 
-            f"Git clone failed: {result.stderr}"
-          )
+          raise ConnectionError(f"Git clone failed for '{self.name}': {result.stderr}")
         
         logger.info(f"Successfully cloned library '{self.name}'")
         return True
@@ -157,10 +178,7 @@ class RemoteLibrary(Library):
           )
           
           if result.returncode != 0:
-            raise RemoteLibraryError(
-              self.name, "pull",
-              f"Git pull failed: {result.stderr}"
-            )
+            raise ConnectionError(f"Git pull failed for '{self.name}': {result.stderr}")
           
           logger.info(f"Successfully updated library '{self.name}' ({behind_count} new commits)")
           return True
@@ -169,15 +187,11 @@ class RemoteLibrary(Library):
           return True
           
     except subprocess.CalledProcessError as e:
-      raise RemoteLibraryError(
-        self.name, "update",
-        f"Command failed: {e.stderr if hasattr(e, 'stderr') else str(e)}"
+      raise RuntimeError(
+        f"Git command failed for '{self.name}': {e.stderr if hasattr(e, 'stderr') else str(e)}"
       )
     except Exception as e:
-      raise RemoteLibraryError(
-        self.name, "update",
-        str(e)
-      )
+      raise RuntimeError(f"Library update failed for '{self.name}': {str(e)}")
   
   def get_info(self) -> dict:
     """Get information about the remote library.

+ 127 - 8
cli/core/module.py

@@ -5,10 +5,10 @@ import logging
 import yaml
 from typer import Typer, Option, Argument
 from rich.console import Console
-from .exceptions import TemplateNotFoundError
+# Using standard Python exceptions
 from .library import LibraryManager
 
-logger = logging.getLogger('boilerplates')
+logger = logging.getLogger(__name__)
 console = Console()
 
 
@@ -31,6 +31,16 @@ class Module(ABC):
     # Initialize variables if the subclass defines _init_variables method
     if hasattr(self, '_init_variables'):
       self._init_variables()
+      
+      # Validate module variable registry consistency after initialization
+      # NOTE: This ensures the module's variable hierarchy is properly structured (e.g., traefik.host requires traefik to exist).
+      # The registry defines parent-child relationships where child variables like 'traefik.tls.certresolver' can only be used
+      # when their parents ('traefik' and 'traefik.tls') are enabled. This prevents invalid module configurations.
+      if hasattr(self, 'variables') and self.variables:
+        registry_errors = self.variables.validate_parent_child_relationships()
+        if registry_errors:
+          error_msg = f"Module '{self.name}' has invalid variable registry:\n" + "\n".join(f"  - {e}" for e in registry_errors)
+          raise ValueError(error_msg)
     
     self.metadata = self._build_metadata()
   
@@ -52,8 +62,12 @@ class Module(ABC):
   def list(self):
     """List all templates."""
     templates = self.libraries.find(self.name, self.files, sorted=True)
+    
+    # Enrich each template with module variables
     for template in templates:
+      self._enrich_template_with_variables(template)
       console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
+    
     return templates
 
   def show(self, id: str = Argument(..., help="Template ID")):
@@ -85,25 +99,130 @@ class Module(ABC):
       print(f"\n{template.content}")
 
   def _get_template(self, template_id: str):
-    """Get template by ID with unified error handling."""
+    """Get template by ID with unified error handling and variable enrichment."""
     template = self.libraries.find_by_id(self.name, self.files, template_id)
     
     if not template:
-      raise TemplateNotFoundError(template_id, self.name)
-
+      raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
+    
+    # Enrich template with module variables if available
+    self._enrich_template_with_variables(template)
+    
     return template
 
+  def _enrich_template_with_variables(self, template):
+    """Enrich template with module variable registry defaults (optimized).
+    
+    This method updates the template's vars with module defaults while preserving
+    template-specific variables and frontmatter definitions.
+    
+    Args:
+        template: Template instance to enrich
+    """
+    # Skip if already enriched or no variables
+    if template._is_enriched or not hasattr(self, 'variables') or not self.variables:
+      return
+    
+    logger = logging.getLogger('boilerplates')
+    logger.debug(f"Enriching template '{template.id}' with {len(self.variables.get_all_variables())} module variables")
+    
+    # Get template variables first (this is cached)
+    template_vars = template._parse_template_variables(
+      template.content, 
+      getattr(template, 'frontmatter_variables', {})
+    )
+    
+    # Only get module variables that are actually used in the template
+    used_variables = template._get_used_variables()
+    module_vars = {}
+    module_defaults = {}
+    
+    for var_name in used_variables:
+      var_obj = self.variables.get_variable(var_name)
+      if var_obj:
+        module_vars[var_name] = var_obj.default if var_obj.default is not None else None
+        if var_obj.default is not None:
+          module_defaults[var_name] = var_obj.default
+    
+    if module_defaults:
+      logger.debug(f"Module provides {len(module_defaults)} defaults for used variables: {module_defaults}")
+    
+    # Merge with template taking precedence
+    final_vars = dict(module_vars)
+    overrides = {}
+    
+    for var_name, var_value in template_vars.items():
+      if var_name in final_vars and final_vars[var_name] != var_value and var_value is not None:
+        logger.warning(
+          f"Variable '{var_name}' defined in both module and template. Template takes precedence."
+        )
+        overrides[var_name] = var_value
+      final_vars[var_name] = var_value
+    
+    if overrides:
+      logger.debug(f"Template overrode {len(overrides)} module variables")
+    
+    # Set final variables and mark as enriched
+    template.vars = final_vars
+    template._is_enriched = True
+    
+    logger.debug(f"Template '{template.id}' enriched with {len(final_vars)} final variables")
+
+  def _check_template_readiness(self, template):
+    """Check if template is ready for generation (replaces complex validation).
+    
+    Args:
+        template: Template instance to check
+    
+    Raises:
+        ValueError: If template has critical issues preventing generation
+    """
+    logger = logging.getLogger('boilerplates')
+    errors = []
+    
+    # Check for basic template issues
+    if not template.content.strip():
+      errors.append("Template has no content")
+    
+    # Check for undefined variables (variables used but not available)
+    undefined_vars = []
+    for var_name, var_value in template.vars.items():
+      if var_value is None:
+        # Check if it's in module registry
+        if hasattr(self, 'variables') and self.variables:
+          var_obj = self.variables.get_variable(var_name)
+          if not var_obj:
+            # Not in module registry and no template default - problematic
+            undefined_vars.append(var_name)
+    
+    if undefined_vars:
+      errors.append(
+        f"Template uses undefined variables: {', '.join(undefined_vars)}. "
+        f"These variables are not registered in the module and have no template defaults."
+      )
+    
+    # Check for syntax errors by attempting to create AST
+    try:
+      template._get_ast()
+    except Exception as e:
+      errors.append(f"Template has Jinja2 syntax errors: {str(e)}")
+    
+    if errors:
+      error_msg = f"Template '{template.id}' is not ready for generation:\n" + "\n".join(f"  - {e}" for e in errors)
+      raise ValueError(error_msg)
+
   def generate(
     self,
     id: str = Argument(..., help="Template ID"),
     out: Optional[Path] = Option(None, "--out", "-o")
   ):
     """Generate from template."""
+
+    # Fetch template from library
     template = self._get_template(id)
     
-    # Validate template (will raise TemplateValidationError if validation fails)
-    module_variable_registry = getattr(self, 'variables', None)
-    template.validate(module_variable_registry, id)
+    # Check for critical template issues during enrichment
+    self._check_template_readiness(template)
     
     print("TEST SUCCESSFUL")
   

+ 156 - 166
cli/core/template.py

@@ -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}")

+ 7 - 0
cli/modules/compose.py

@@ -44,6 +44,13 @@ class ComposeModule(Module):
       options=["debug", "info", "warn", "error"]
     ))
     
+    self.variables.register_variable(Variable(
+      name="container_hostname",
+      type=VariableType.STR,
+      description="Container hostname (shows up in logs and networking)",
+      display="Container Hostname"
+    ))
+    
     self.variables.register_variable(Variable(
       name="restart_policy",
       type=VariableType.ENUM,

+ 27 - 18
library/compose/alloy/compose.yaml

@@ -10,23 +10,26 @@ tags:
   - "monitoring"
   - "http"
   - "traefik"
+variables:
+  container_hostname:
+    description: "Sets the container's internal hostname (this will show up in the collected logs)"
+    type: "string"
+    required: true
 ---
-{% variables %}
-container_hostname:
-  description: "Sets the container's internal hostname (this will show up in the collected logs)"
-{% endvariables %}
 services:
-  {{ service_name | default("alloy")}}:
+  {{ service_name | default("alloy") }}:
     image: grafana/alloy:v1.10.2
-  container_name: {{ container_name | default("alloy") }}
+    container_name: {{ container_name | default("alloy") }}
     hostname: {{ container_hostname }}
     command:
       - run
       - --server.http.listen-addr=0.0.0.0:12345
       - --storage.path=/var/lib/alloy/data
       - /etc/alloy/config.alloy
+    {% if ports %}
     ports:
       - "12345:12345"
+    {% endif %}
     volumes:
       - ./config.alloy:/etc/alloy/config.alloy
       - alloy_data:/var/lib/alloy/data
@@ -36,21 +39,23 @@ services:
       - /sys:/sys:ro
       - /var/lib/docker/:/var/lib/docker/:ro
       - /run/udev/data:/run/udev/data:ro
+    {% if network %}
     networks:
-      - {{ docker_network | default("bridge")}}
+      - {{ network.name | default("bridge") }}
+    {% endif %}
     {% if traefik %}
     labels:
-      - traefik.enable={{ traefik_enable | default(true) }}
-      - traefik.http.services.{{ service_name }}.loadbalancer.server.port=12345
-      - traefik.http.services.{{ service_name }}.loadbalancer.server.scheme=http
-      - traefik.http.routers.{{ service_name }}.service={{ service_name }}
-      - traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}`)
-      {% if traefik_tls %}
-      - traefik.http.routers.{{ service_name }}.tls={{ traefik_tls | default(true) }}
-      - traefik.http.routers.{{ service_name }}.entrypoints={{ traefik_entrypoint | default("websecure") }}
-      - traefik.http.routers.{{ service_name }}.tls.certresolver={{ traefik_certresolver | default("cloudflare") }}
+      - traefik.enable=true
+      - traefik.http.services.{{ service_name | default("alloy") }}.loadbalancer.server.port=12345
+      - traefik.http.services.{{ service_name | default("alloy") }}.loadbalancer.server.scheme=http
+      - traefik.http.routers.{{ service_name | default("alloy") }}.service={{ service_name | default("alloy") }}
+      - traefik.http.routers.{{ service_name | default("alloy") }}.rule=Host(`{{ traefik.host }}`)
+      {% if traefik.tls %}
+      - traefik.http.routers.{{ service_name | default("alloy") }}.tls=true
+      - traefik.http.routers.{{ service_name | default("alloy") }}.entrypoints={{ traefik.tls.entrypoint | default("websecure") }}
+      - traefik.http.routers.{{ service_name | default("alloy") }}.tls.certresolver={{ traefik.tls.certresolver }}
       {% else %}
-      - traefik.http.routers.{{ service_name }}.entrypoints={{ traefik_entrypoint | default("websecure") }}
+      - traefik.http.routers.{{ service_name | default("alloy") }}.entrypoints={{ traefik.entrypoint | default("web") }}
       {% endif %}
     {% endif %}
     restart: {{ restart_policy | default("unless-stopped") }}
@@ -59,6 +64,10 @@ volumes:
   alloy_data:
     driver: local
 
+{% if network %}
 networks:
-  {{ docker_network | default("bridge")}}:
+  {{ network.name | default("bridge") }}:
+    {% if network.external %}
     external: true
+    {% endif %}
+{% endif %}