Răsfoiți Sursa

Merge main into release/v0.1.0 - use main branch structure

xcad 5 luni în urmă
părinte
comite
136d1be46e
5 a modificat fișierele cu 450 adăugiri și 509 ștergeri
  1. 115 96
      archetypes/__main__.py
  2. 99 83
      cli/core/prompt.py
  3. 53 55
      cli/core/template/variable.py
  4. 183 266
      cli/core/template/variable_collection.py
  5. 0 9
      network-v1

+ 115 - 96
archetypes/__main__.py

@@ -17,13 +17,9 @@ from rich.table import Table
 from rich.panel import Panel
 
 # Import CLI components
-from cli.core.template import Template
 from cli.core.collection import VariableCollection
 from cli.core.display import DisplayManager
 from cli.core.exceptions import (
-    TemplateLoadError,
-    TemplateSyntaxError,
-    TemplateValidationError,
     TemplateRenderError,
 )
 
@@ -44,7 +40,7 @@ def setup_logging(log_level: str = "WARNING") -> None:
     numeric_level = getattr(logging, log_level.upper(), None)
     if not isinstance(numeric_level, int):
         raise ValueError(f"Invalid log level: {log_level}")
-    
+
     logging.basicConfig(
         level=numeric_level,
         format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -54,26 +50,30 @@ def setup_logging(log_level: str = "WARNING") -> None:
 
 class ArchetypeTemplate:
     """Simplified template for testing individual .j2 files."""
-    
+
     def __init__(self, file_path: Path, module_name: str):
         self.file_path = file_path
         self.module_name = module_name
         self.id = file_path.stem  # Filename without extension
         self.template_dir = file_path.parent
-        
+
         # Create a minimal template.yaml in memory
-        self.metadata = type('obj', (object,), {
-            'name': f"Archetype: {self.id}",
-            'description': f"Testing archetype from {file_path.name}",
-            'version': "0.1.0",
-            'author': "Testing",
-            'library': "archetype",
-            'tags': ["archetype", "test"],
-        })()
-        
+        self.metadata = type(
+            "obj",
+            (object,),
+            {
+                "name": f"Archetype: {self.id}",
+                "description": f"Testing archetype from {file_path.name}",
+                "version": "0.1.0",
+                "author": "Testing",
+                "library": "archetype",
+                "tags": ["archetype", "test"],
+            },
+        )()
+
         # Parse spec from module if available
         self.variables = self._load_module_spec()
-    
+
     def _load_module_spec(self) -> Optional[VariableCollection]:
         """Load variable spec from the module and merge with extension.yaml if present."""
         try:
@@ -82,7 +82,7 @@ class ArchetypeTemplate:
                 from cli.modules.compose import spec
                 from collections import OrderedDict
                 import yaml
-                
+
                 # Convert spec to dict if needed
                 if isinstance(spec, (dict, OrderedDict)):
                     spec_dict = OrderedDict(spec)
@@ -90,63 +90,72 @@ class ArchetypeTemplate:
                     # Extract dict from existing VariableCollection (shouldn't happen)
                     spec_dict = OrderedDict()
                 else:
-                    logging.warning(f"Spec for {self.module_name} has unexpected type: {type(spec)}")
+                    logging.warning(
+                        f"Spec for {self.module_name} has unexpected type: {type(spec)}"
+                    )
                     return None
-                
+
                 # Check for extension.yaml in the archetype directory
                 extension_file = self.template_dir / "extension.yaml"
                 if extension_file.exists():
                     try:
-                        with open(extension_file, 'r') as f:
+                        with open(extension_file, "r") as f:
                             extension_vars = yaml.safe_load(f)
-                        
+
                         if extension_vars:
                             # Apply extension defaults to existing variables in their sections
                             # Extension vars that don't exist will be added to a "testing" section
                             applied_count = 0
                             new_vars = {}
-                            
+
                             for var_name, var_spec in extension_vars.items():
                                 found = False
                                 # Search for the variable in existing sections
                                 for section_name, section_data in spec_dict.items():
-                                    if "vars" in section_data and var_name in section_data["vars"]:
+                                    if (
+                                        "vars" in section_data
+                                        and var_name in section_data["vars"]
+                                    ):
                                         # Update the default value for existing variable
                                         if "default" in var_spec:
-                                            section_data["vars"][var_name]["default"] = var_spec["default"]
+                                            section_data["vars"][var_name][
+                                                "default"
+                                            ] = var_spec["default"]
                                             applied_count += 1
                                             found = True
                                             break
-                                
+
                                 # If variable doesn't exist in spec, add it to testing section
                                 if not found:
                                     new_vars[var_name] = var_spec
-                            
+
                             # Add new test-only variables to testing section
                             if new_vars:
                                 if "testing" not in spec_dict:
                                     spec_dict["testing"] = {
                                         "title": "Testing Variables",
                                         "description": "Additional variables for archetype testing",
-                                        "vars": {}
+                                        "vars": {},
                                     }
                                 spec_dict["testing"]["vars"].update(new_vars)
-                            
-                            logging.debug(f"Applied {applied_count} extension defaults, added {len(new_vars)} new test variables from {extension_file}")
+
+                            logging.debug(
+                                f"Applied {applied_count} extension defaults, added {len(new_vars)} new test variables from {extension_file}"
+                            )
                     except Exception as e:
                         logging.warning(f"Failed to load extension.yaml: {e}")
-                
+
                 return VariableCollection(spec_dict)
         except Exception as e:
             logging.warning(f"Could not load spec for module {self.module_name}: {e}")
             return None
-    
+
     def render(self, variables: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
         """Render the single .j2 file using CLI's Template class."""
         # Create a minimal template directory structure in memory
         # by using the Template class's rendering capabilities
         from jinja2 import Environment, FileSystemLoader, StrictUndefined
-        
+
         # Set up Jinja2 environment with the archetype directory
         env = Environment(
             loader=FileSystemLoader(str(self.template_dir)),
@@ -155,11 +164,11 @@ class ArchetypeTemplate:
             lstrip_blocks=True,
             keep_trailing_newline=True,
         )
-        
+
         # Get variable values
         if variables is None:
             variables = {}
-        
+
         # Get default values from spec if available
         if self.variables:
             # Get ALL variable values, not just satisfied ones
@@ -175,15 +184,15 @@ class ArchetypeTemplate:
             final_values = {**spec_values, **variables}
         else:
             final_values = variables
-        
+
         try:
             # Load and render the template
             template = env.get_template(self.file_path.name)
             rendered_content = template.render(**final_values)
-            
+
             # Remove .j2 extension for output filename
-            output_filename = self.file_path.name.replace('.j2', '')
-            
+            output_filename = self.file_path.name.replace(".j2", "")
+
             return {output_filename: rendered_content}
         except Exception as e:
             raise TemplateRenderError(f"Failed to render {self.file_path.name}: {e}")
@@ -192,11 +201,11 @@ class ArchetypeTemplate:
 def find_archetypes(module_name: str) -> List[Path]:
     """Find all .j2 files in the module's archetype directory."""
     module_dir = ARCHETYPES_DIR / module_name
-    
+
     if not module_dir.exists():
         console.print(f"[red]Module directory not found: {module_dir}[/red]")
         return []
-    
+
     # Find all .j2 files
     j2_files = list(module_dir.glob("*.j2"))
     return sorted(j2_files)
@@ -205,98 +214,107 @@ def find_archetypes(module_name: str) -> List[Path]:
 def create_module_commands(module_name: str) -> Typer:
     """Create a Typer app with commands for a specific module."""
     module_app = Typer(help=f"Manage {module_name} archetypes")
-    
+
     @module_app.command()
     def list() -> None:
         """List all archetype files for this module."""
         archetypes = find_archetypes(module_name)
-        
+
         if not archetypes:
             display.display_warning(
                 f"No archetypes found for module '{module_name}'",
-                context=f"directory: {ARCHETYPES_DIR / module_name}"
+                context=f"directory: {ARCHETYPES_DIR / module_name}",
             )
             return
-        
+
         # Create table
-        table = Table(title=f"Archetypes for '{module_name}'", show_header=True, header_style="bold cyan")
+        table = Table(
+            title=f"Archetypes for '{module_name}'",
+            show_header=True,
+            header_style="bold cyan",
+        )
         table.add_column("ID", style="cyan")
         table.add_column("Filename", style="white")
         table.add_column("Size", style="dim")
-        
+
         for archetype_path in archetypes:
             file_size = archetype_path.stat().st_size
             if file_size < 1024:
                 size_str = f"{file_size}B"
             else:
                 size_str = f"{file_size / 1024:.1f}KB"
-            
+
             table.add_row(
                 archetype_path.stem,
                 archetype_path.name,
                 size_str,
             )
-        
+
         console.print(table)
         console.print(f"\n[dim]Found {len(archetypes)} archetype(s)[/dim]")
-    
+
     @module_app.command()
     def show(
         id: str = Argument(..., help="Archetype ID (filename without .j2)"),
     ) -> None:
         """Show details of an archetype file."""
         archetypes = find_archetypes(module_name)
-        
+
         # Find the archetype
         archetype_path = None
         for path in archetypes:
             if path.stem == id:
                 archetype_path = path
                 break
-        
+
         if not archetype_path:
             display.display_error(
-                f"Archetype '{id}' not found",
-                context=f"module '{module_name}'"
+                f"Archetype '{id}' not found", context=f"module '{module_name}'"
             )
             return
-        
+
         # Load archetype
         archetype = ArchetypeTemplate(archetype_path, module_name)
-        
+
         # Display details
         console.print()
-        console.print(Panel(
-            f"[bold]{archetype.metadata.name}[/bold]\n"
-            f"{archetype.metadata.description}\n\n"
-            f"[dim]Module:[/dim] {module_name}\n"
-            f"[dim]File:[/dim] {archetype_path.name}\n"
-            f"[dim]Path:[/dim] {archetype_path}",
-            title="Archetype Details",
-            border_style="cyan",
-        ))
-        
+        console.print(
+            Panel(
+                f"[bold]{archetype.metadata.name}[/bold]\n"
+                f"{archetype.metadata.description}\n\n"
+                f"[dim]Module:[/dim] {module_name}\n"
+                f"[dim]File:[/dim] {archetype_path.name}\n"
+                f"[dim]Path:[/dim] {archetype_path}",
+                title="Archetype Details",
+                border_style="cyan",
+            )
+        )
+
         # Show variables if spec is loaded
         if archetype.variables:
             console.print("\n[bold]Available Variables:[/bold]")
-            
+
             # Access the private _sections attribute
             for section_name, section in archetype.variables._sections.items():
                 if section.variables:
-                    console.print(f"\n[cyan]{section.title or section_name.capitalize()}:[/cyan]")
+                    console.print(
+                        f"\n[cyan]{section.title or section_name.capitalize()}:[/cyan]"
+                    )
                     for var_name, var in section.variables.items():
-                        default = var.value if var.value is not None else "[dim]none[/dim]"
+                        default = (
+                            var.value if var.value is not None else "[dim]none[/dim]"
+                        )
                         console.print(f"  {var_name}: {default}")
         else:
             console.print("\n[yellow]No variable spec loaded for this module[/yellow]")
-        
+
         # Show file content
         console.print("\n[bold]Template Content:[/bold]")
         console.print("─" * 80)
-        with open(archetype_path, 'r') as f:
+        with open(archetype_path, "r") as f:
             console.print(f.read())
         console.print()
-    
+
     @module_app.command()
     def generate(
         id: str = Argument(..., help="Archetype ID (filename without .j2)"),
@@ -313,28 +331,25 @@ def create_module_commands(module_name: str) -> Typer:
         """Generate output from an archetype file (always in preview mode)."""
         # Archetypes ALWAYS run in dry-run mode with content display
         # This is a testing tool - it never writes actual files
-        dry_run = True
-        show_content = True
-        
+
         archetypes = find_archetypes(module_name)
-        
+
         # Find the archetype
         archetype_path = None
         for path in archetypes:
             if path.stem == id:
                 archetype_path = path
                 break
-        
+
         if not archetype_path:
             display.display_error(
-                f"Archetype '{id}' not found",
-                context=f"module '{module_name}'"
+                f"Archetype '{id}' not found", context=f"module '{module_name}'"
             )
             return
-        
+
         # Load archetype
         archetype = ArchetypeTemplate(archetype_path, module_name)
-        
+
         # Parse variable overrides
         variables = {}
         if var:
@@ -343,48 +358,51 @@ def create_module_commands(module_name: str) -> Typer:
                     key, value = var_option.split("=", 1)
                     variables[key] = value
                 else:
-                    console.print(f"[yellow]Warning: Invalid --var format '{var_option}' (use KEY=VALUE)[/yellow]")
-        
+                    console.print(
+                        f"[yellow]Warning: Invalid --var format '{var_option}' (use KEY=VALUE)[/yellow]"
+                    )
+
         # Render the archetype
         try:
             rendered_files = archetype.render(variables)
         except Exception as e:
             display.display_error(
-                f"Failed to render archetype: {e}",
-                context=f"archetype '{id}'"
+                f"Failed to render archetype: {e}", context=f"archetype '{id}'"
             )
             return
-        
+
         # Determine output directory (for display purposes only)
         if directory:
             output_dir = Path(directory)
         else:
             output_dir = Path.cwd()
-        
+
         # Always show preview (archetypes never write files)
         console.print()
         console.print("[bold cyan]Archetype Preview (Testing Mode)[/bold cyan]")
-        console.print("[dim]This tool never writes files - it's for testing template snippets only[/dim]")
+        console.print(
+            "[dim]This tool never writes files - it's for testing template snippets only[/dim]"
+        )
         console.print()
         console.print(f"[dim]Reference directory:[/dim] {output_dir}")
         console.print(f"[dim]Files to preview:[/dim] {len(rendered_files)}")
         console.print()
-        
+
         for filename, content in rendered_files.items():
             full_path = output_dir / filename
             status = "Would overwrite" if full_path.exists() else "Would create"
-            size = len(content.encode('utf-8'))
+            size = len(content.encode("utf-8"))
             console.print(f"  [{status}] {filename} ({size} bytes)")
-        
+
         console.print()
         console.print("[bold]Rendered Content:[/bold]")
         console.print("─" * 80)
         for filename, content in rendered_files.items():
             console.print(content)
-        
+
         console.print()
         display.display_success("Preview complete - no files were written")
-    
+
     return module_app
 
 
@@ -393,7 +411,7 @@ def init_app() -> None:
     # Find all module directories in archetypes/
     if ARCHETYPES_DIR.exists():
         for module_dir in ARCHETYPES_DIR.iterdir():
-            if module_dir.is_dir() and not module_dir.name.startswith(('_', '.')):
+            if module_dir.is_dir() and not module_dir.name.startswith(("_", ".")):
                 module_name = module_dir.name
                 # Register module commands
                 module_app = create_module_commands(module_name)
@@ -413,10 +431,11 @@ def main(
         setup_logging(log_level)
     else:
         logging.disable(logging.CRITICAL)
-    
+
     import click
+
     ctx = click.get_current_context()
-    
+
     if ctx.invoked_subcommand is None:
         console.print(ctx.get_help())
         sys.exit(0)

+ 99 - 83
cli/core/prompt.py

@@ -1,13 +1,13 @@
 from __future__ import annotations
 
+from typing import Dict, Any, Callable
 import logging
-from typing import Any, Callable
-
 from rich.console import Console
-from rich.prompt import Confirm, IntPrompt, Prompt
+from rich.prompt import Prompt, Confirm, IntPrompt
 
 from .display import DisplayManager
-from .template import Variable, VariableCollection
+from .variable import Variable
+from .collection import VariableCollection
 
 logger = logging.getLogger(__name__)
 
@@ -19,71 +19,6 @@ class PromptHandler:
         self.console = Console()
         self.display = DisplayManager()
 
-    def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool:
-        """Handle section toggle variable and return whether section should be enabled."""
-        if section.required:
-            logger.debug(
-                f"Processing required section '{section.key}' without toggle prompt"
-            )
-            return True
-
-        if not section.toggle:
-            return True
-
-        toggle_var = section.variables.get(section.toggle)
-        if not toggle_var:
-            return True
-
-        current_value = toggle_var.convert(toggle_var.value)
-        new_value = self._prompt_variable(toggle_var, required=section.required)
-
-        if new_value != current_value:
-            collected[toggle_var.name] = new_value
-            toggle_var.value = new_value
-
-        return section.is_enabled()
-
-    def _should_skip_variable(
-        self,
-        var_name: str,
-        section,
-        variables: VariableCollection,
-        section_enabled: bool,
-    ) -> bool:
-        """Determine if a variable should be skipped during collection."""
-        # Skip toggle variable (already handled)
-        if section.toggle and var_name == section.toggle:
-            return True
-
-        # Skip variables with unsatisfied needs
-        if not variables.is_variable_satisfied(var_name):
-            logger.debug(f"Skipping variable '{var_name}' - needs not satisfied")
-            return True
-
-        # Skip all variables if section is disabled
-        if not section_enabled:
-            logger.debug(
-                f"Skipping variable '{var_name}' from disabled section '{section.key}'"
-            )
-            return True
-
-        return False
-
-    def _collect_variable_value(
-        self, variable: Variable, section, collected: dict[str, Any]
-    ) -> None:
-        """Collect a single variable value and update if changed."""
-        current_value = variable.convert(variable.value)
-        new_value = self._prompt_variable(variable, required=section.required)
-
-        # For autogenerated variables, always update even if None
-        if variable.autogenerated and new_value is None:
-            collected[variable.name] = None
-            variable.value = None
-        elif new_value != current_value:
-            collected[variable.name] = new_value
-            variable.value = new_value
-
     def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
         """Collect values for variables by iterating through sections.
 
@@ -97,29 +32,108 @@ class PromptHandler:
             logger.info("User opted to keep all default values")
             return {}
 
-        collected: dict[str, Any] = {}
+        collected: Dict[str, Any] = {}
+        prompted_variables: set[str] = (
+            set()
+        )  # Track which variables we've already prompted for
 
         # Process each section
-        for _section_key, section in variables.iter_active_sections(
-            include_disabled=True
-        ):
+        for section_key, section in variables.get_sections().items():
             if not section.variables:
                 continue
 
-            # Display section header
-            self.display.section(section.title, section.description)
+            # Check if dependencies are satisfied
+            if not variables.is_section_satisfied(section_key):
+                # Get list of unsatisfied dependencies for better user feedback
+                unsatisfied_keys = [
+                    dep
+                    for dep in section.needs
+                    if not variables.is_section_satisfied(dep)
+                ]
+                # Convert section keys to titles for user-friendly display
+                unsatisfied_titles = []
+                for dep_key in unsatisfied_keys:
+                    dep_section = variables.get_section(dep_key)
+                    if dep_section:
+                        unsatisfied_titles.append(dep_section.title)
+                    else:
+                        unsatisfied_titles.append(dep_key)
+                dep_names = (
+                    ", ".join(unsatisfied_titles) if unsatisfied_titles else "unknown"
+                )
+                self.display.display_skipped(
+                    section.title, f"requires {dep_names} to be enabled"
+                )
+                logger.debug(
+                    f"Skipping section '{section_key}' - dependencies not satisfied: {dep_names}"
+                )
+                continue
 
-            # Handle toggle and determine if section is enabled
-            section_enabled = self._handle_section_toggle(section, collected)
+            # Always show section header first
+            self.display.display_section_header(section.title, section.description)
+
+            # Track whether this section will be enabled
+            section_will_be_enabled = True
+
+            # Handle section toggle - skip for required sections
+            if section.required:
+                # Required sections are always processed, no toggle prompt needed
+                logger.debug(
+                    f"Processing required section '{section.key}' without toggle prompt"
+                )
+            elif section.toggle:
+                toggle_var = section.variables.get(section.toggle)
+                if toggle_var:
+                    # Prompt for toggle variable using standard variable prompting logic
+                    # This ensures consistent handling of description, extra text, validation hints, etc.
+                    current_value = toggle_var.convert(toggle_var.value)
+                    new_value = self._prompt_variable(
+                        toggle_var, required=section.required
+                    )
+
+                    if new_value != current_value:
+                        collected[toggle_var.name] = new_value
+                        toggle_var.value = new_value
+
+                    # Use section's native is_enabled() method
+                    if not section.is_enabled():
+                        section_will_be_enabled = False
 
             # Collect variables in this section
             for var_name, variable in section.variables.items():
-                if self._should_skip_variable(
-                    var_name, section, variables, section_enabled
-                ):
+                # Skip toggle variable (already handled)
+                if section.toggle and var_name == section.toggle:
+                    continue
+
+                # Skip variables with unsatisfied needs (similar to display logic)
+                if not variables.is_variable_satisfied(var_name):
+                    logger.debug(
+                        f"Skipping variable '{var_name}' - needs not satisfied"
+                    )
+                    continue
+
+                # Skip all variables if section is disabled
+                if not section_will_be_enabled:
+                    logger.debug(
+                        f"Skipping variable '{var_name}' from disabled section '{section_key}'"
+                    )
                     continue
 
-                self._collect_variable_value(variable, section, collected)
+                # Prompt for the variable
+                current_value = variable.convert(variable.value)
+                # Pass section.required so _prompt_variable can enforce required inputs
+                new_value = self._prompt_variable(variable, required=section.required)
+
+                # Track that we've prompted for this variable
+                prompted_variables.add(var_name)
+
+                # For autogenerated variables, always update even if None (signals autogeneration)
+                if variable.autogenerated and new_value is None:
+                    collected[var_name] = None
+                    variable.value = None
+                elif new_value != current_value:
+                    collected[var_name] = new_value
+                    variable.value = new_value
 
         logger.info(f"Variable collection completed. Collected {len(collected)} values")
         return collected
@@ -179,7 +193,9 @@ class PromptHandler:
                 self._show_validation_error(str(exc))
             except Exception as e:
                 # Unexpected error — log and retry using the stored (unconverted) value
-                logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
+                logger.error(
+                    f"Error prompting for variable '{variable.name}': {str(e)}"
+                )
                 default_value = variable.value
                 handler = self._get_prompt_handler(variable)
 
@@ -206,7 +222,7 @@ class PromptHandler:
 
     def _show_validation_error(self, message: str) -> None:
         """Display validation feedback consistently."""
-        self.display.error(message)
+        self.display.display_validation_error(message)
 
     def _prompt_string(
         self, prompt_text: str, default: Any = None, is_sensitive: bool = False

+ 53 - 55
cli/core/template/variable.py

@@ -1,14 +1,12 @@
 from __future__ import annotations
 
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
+from urllib.parse import urlparse
 import logging
 import re
-from typing import TYPE_CHECKING, Any
-from urllib.parse import urlparse
-
-from ..exceptions import VariableError, VariableValidationError
 
 if TYPE_CHECKING:
-    from .variable_section import VariableSection
+    from cli.core.section import VariableSection
 
 logger = logging.getLogger(__name__)
 
@@ -32,34 +30,34 @@ class Variable:
         """
         # Validate input
         if not isinstance(data, dict):
-            raise VariableError("Variable data must be a dictionary")
+            raise ValueError("Variable data must be a dictionary")
 
         if "name" not in data:
-            raise VariableError("Variable data must contain 'name' key")
+            raise ValueError("Variable data must contain 'name' key")
 
         # Track which fields were explicitly provided in source data
-        self._explicit_fields: set[str] = set(data.keys())
+        self._explicit_fields: Set[str] = set(data.keys())
 
         # Initialize fields
         self.name: str = data["name"]
         # Reference to parent section (set by VariableCollection)
-        self.parent_section: VariableSection | None = data.get("parent_section")
-        self.description: str | None = data.get("description") or data.get(
+        self.parent_section: Optional["VariableSection"] = data.get("parent_section")
+        self.description: Optional[str] = data.get("description") or data.get(
             "display", ""
         )
         self.type: str = data.get("type", "str")
-        self.options: list[Any] | None = data.get("options", [])
-        self.prompt: str | None = data.get("prompt")
+        self.options: Optional[List[Any]] = data.get("options", [])
+        self.prompt: Optional[str] = data.get("prompt")
         if "value" in data:
             self.value: Any = data.get("value")
         elif "default" in data:
             self.value: Any = data.get("default")
         else:
             self.value: Any = None
-        self.origin: str | None = data.get("origin")
+        self.origin: Optional[str] = data.get("origin")
         self.sensitive: bool = data.get("sensitive", False)
         # Optional extra explanation used by interactive prompts
-        self.extra: str | None = data.get("extra")
+        self.extra: Optional[str] = data.get("extra")
         # Flag indicating this variable should be auto-generated when empty
         self.autogenerated: bool = data.get("autogenerated", False)
         # Flag indicating this variable is required even when section is disabled
@@ -67,7 +65,7 @@ class Variable:
         # Flag indicating this variable can be empty/optional
         self.optional: bool = data.get("optional", False)
         # Original value before config override (used for display)
-        self.original_value: Any | None = data.get("original_value")
+        self.original_value: Optional[Any] = data.get("original_value")
         # Variable dependencies - can be string or list of strings in format "var_name=value"
         # Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3"
         needs_value = data.get("needs")
@@ -75,26 +73,24 @@ class Variable:
             if isinstance(needs_value, str):
                 # Split by semicolon to support multiple AND conditions in a single string
                 # Example: "traefik_enabled=true;network_mode=bridge,macvlan"
-                self.needs: list[str] = [
+                self.needs: List[str] = [
                     need.strip() for need in needs_value.split(";") if need.strip()
                 ]
             elif isinstance(needs_value, list):
-                self.needs: list[str] = needs_value
+                self.needs: List[str] = needs_value
             else:
-                raise VariableError(
+                raise ValueError(
                     f"Variable '{self.name}' has invalid 'needs' value: must be string or list"
                 )
         else:
-            self.needs: list[str] = []
+            self.needs: List[str] = []
 
         # Validate and convert the default/initial value if present
         if self.value is not None:
             try:
                 self.value = self.convert(self.value)
             except ValueError as exc:
-                raise VariableValidationError(
-                    self.name, f"Invalid default value: {exc}"
-                ) from exc
+                raise ValueError(f"Invalid default for variable '{self.name}': {exc}")
 
     def convert(self, value: Any) -> Any:
         """Validate and convert a raw value based on the variable type.
@@ -160,7 +156,9 @@ class Variable:
         # Allow empty values as they will be auto-generated later
         if self.autogenerated and (
             converted is None
-            or (isinstance(converted, str) and converted in ("", "*auto"))
+            or (
+                isinstance(converted, str) and (converted == "" or converted == "*auto")
+            )
         ):
             return None  # Signal that auto-generation should happen
 
@@ -171,12 +169,9 @@ class Variable:
             return None
 
         # Check if this is a required field and the value is empty
-        if (
-            check_required
-            and self.is_required()
-            and (converted is None or (isinstance(converted, str) and converted == ""))
-        ):
-            raise ValueError("This field is required and cannot be empty")
+        if check_required and self.is_required():
+            if converted is None or (isinstance(converted, str) and converted == ""):
+                raise ValueError("This field is required and cannot be empty")
 
         return converted
 
@@ -192,7 +187,7 @@ class Variable:
                 return False
         raise ValueError("value must be a boolean (true/false)")
 
-    def _convert_int(self, value: Any) -> int | None:
+    def _convert_int(self, value: Any) -> Optional[int]:
         """Convert value to integer."""
         if isinstance(value, int):
             return value
@@ -203,7 +198,7 @@ class Variable:
         except (TypeError, ValueError) as exc:
             raise ValueError("value must be an integer") from exc
 
-    def _convert_float(self, value: Any) -> float | None:
+    def _convert_float(self, value: Any) -> Optional[float]:
         """Convert value to float."""
         if isinstance(value, float):
             return value
@@ -214,7 +209,7 @@ class Variable:
         except (TypeError, ValueError) as exc:
             raise ValueError("value must be a float") from exc
 
-    def _convert_enum(self, value: Any) -> str | None:
+    def _convert_enum(self, value: Any) -> Optional[str]:
         if value == "":
             return None
         val = str(value)
@@ -239,7 +234,7 @@ class Variable:
             raise ValueError("value must be a valid email address")
         return val
 
-    def to_dict(self) -> dict[str, Any]:
+    def to_dict(self) -> Dict[str, Any]:
         """Serialize Variable to a dictionary for storage."""
         result = {}
 
@@ -319,31 +314,29 @@ class Variable:
 
         # Type-specific handlers
         if self.type == "enum":
-            result = (
-                typed
-                if not self.options
-                else (
-                    self.options[0]
-                    if typed is None or str(typed) not in self.options
-                    else str(typed)
-                )
+            if not self.options:
+                return typed
+            return (
+                self.options[0]
+                if typed is None or str(typed) not in self.options
+                else str(typed)
             )
-        elif self.type == "bool":
-            result = (
+
+        if self.type == "bool":
+            return (
                 typed
                 if isinstance(typed, bool)
                 else (None if typed is None else bool(typed))
             )
-        elif self.type == "int":
+
+        if self.type == "int":
             try:
-                result = int(typed) if typed not in (None, "") else None
+                return int(typed) if typed not in (None, "") else None
             except Exception:
-                result = None
-        else:
-            # Default: return string or None
-            result = None if typed is None else str(typed)
+                return None
 
-        return result
+        # Default: return string or None
+        return None if typed is None else str(typed)
 
     def get_prompt_text(self) -> str:
         """Get formatted prompt text for interactive input.
@@ -359,7 +352,7 @@ class Variable:
 
         return prompt_text
 
-    def get_validation_hint(self) -> str | None:
+    def get_validation_hint(self) -> Optional[str]:
         """Get validation hint for prompts (e.g., enum options).
 
         Returns:
@@ -397,7 +390,9 @@ class Variable:
         # Explicit required flag takes highest precedence
         if self.required:
             # But autogenerated variables can still be empty (will be generated later)
-            return not self.autogenerated
+            if self.autogenerated:
+                return False
+            return True
 
         # Autogenerated variables can be empty (will be generated later)
         if self.autogenerated:
@@ -408,10 +403,13 @@ class Variable:
             return False
 
         # Variables with a default value are not required
+        if self.value is not None:
+            return False
+
         # No default value and not autogenerated = required
-        return self.value is None
+        return True
 
-    def get_parent(self) -> VariableSection | None:
+    def get_parent(self) -> Optional["VariableSection"]:
         """Get the parent VariableSection that contains this variable.
 
         Returns:
@@ -419,7 +417,7 @@ class Variable:
         """
         return self.parent_section
 
-    def clone(self, update: dict[str, Any] | None = None) -> Variable:
+    def clone(self, update: Optional[Dict[str, Any]] = None) -> "Variable":
         """Create a deep copy of the variable with optional field updates.
 
         This is more efficient than converting to dict and back when copying variables.

+ 183 - 266
cli/core/template/variable_collection.py

@@ -1,12 +1,11 @@
 from __future__ import annotations
 
-import logging
 from collections import defaultdict
-from typing import Any
+from typing import Any, Dict, List, Optional, Set, Union
+import logging
 
-from ..exceptions import VariableError, VariableValidationError
 from .variable import Variable
-from .variable_section import VariableSection
+from .section import VariableSection
 
 logger = logging.getLogger(__name__)
 
@@ -40,11 +39,11 @@ class VariableCollection:
         if not isinstance(spec, dict):
             raise ValueError("Spec must be a dictionary")
 
-        self._sections: dict[str, VariableSection] = {}
+        self._sections: Dict[str, VariableSection] = {}
         # NOTE: The _variable_map provides a flat, O(1) lookup for any variable by its name,
         # avoiding the need to iterate through sections. It stores references to the same
         # Variable objects contained in the _set structure.
-        self._variable_map: dict[str, Variable] = {}
+        self._variable_map: Dict[str, Variable] = {}
         self._initialize_sections(spec)
         # Validate dependencies after all sections are loaded
         self._validate_dependencies()
@@ -112,7 +111,7 @@ class VariableCollection:
 
     def _validate_unique_variable_names(self) -> None:
         """Validate that all variable names are unique across all sections."""
-        var_to_sections: dict[str, list[str]] = defaultdict(list)
+        var_to_sections: Dict[str, List[str]] = defaultdict(list)
 
         # Build mapping of variable names to sections
         for section_key, section in self._sections.items():
@@ -139,7 +138,7 @@ class VariableCollection:
             )
             error_msg = "\n".join(errors)
             logger.error(error_msg)
-            raise VariableValidationError("__collection__", error_msg)
+            raise ValueError(error_msg)
 
     def _validate_section_toggle(self, section: VariableSection) -> None:
         """Validate that toggle variable is of type bool if it exists.
@@ -162,13 +161,13 @@ class VariableCollection:
             return
 
         if toggle_var.type != "bool":
-            raise VariableValidationError(
-                section.toggle,
-                f"Section '{section.key}' toggle variable must be type 'bool', but is type '{toggle_var.type}'",
+            raise ValueError(
+                f"Section '{section.key}' toggle variable '{section.toggle}' must be type 'bool', "
+                f"but is type '{toggle_var.type}'"
             )
 
     @staticmethod
-    def _parse_need(need_str: str) -> tuple[str, Any | None]:
+    def _parse_need(need_str: str) -> tuple[str, Optional[Any]]:
         """Parse a need string into variable name and expected value(s).
 
         Supports three formats:
@@ -206,48 +205,6 @@ class VariableCollection:
             # Old format: section name (backwards compatibility)
             return (need_str.strip(), None)
 
-    def _check_section_need(self, section_name: str) -> bool:
-        """Check if a section dependency is satisfied."""
-        section = self._sections.get(section_name)
-        if not section:
-            logger.warning(f"Need references missing section '{section_name}'")
-            return False
-        return section.is_enabled()
-
-    def _check_value_match(
-        self, actual_value: Any, expected: Any, var_type: str
-    ) -> bool:
-        """Check if actual value matches expected value based on variable type."""
-        if var_type == "bool":
-            return bool(actual_value) == bool(expected)
-        return actual_value is not None and str(actual_value) == str(expected)
-
-    def _check_variable_need(
-        self, var_name: str, expected_value: Any, variable
-    ) -> bool:
-        """Check if a variable dependency is satisfied."""
-        try:
-            actual_value = variable.convert(variable.value)
-
-            # Handle multiple expected values
-            if isinstance(expected_value, list):
-                for expected in expected_value:
-                    expected_converted = variable.convert(expected)
-                    if self._check_value_match(
-                        actual_value, expected_converted, variable.type
-                    ):
-                        return True
-                return False
-
-            # Single expected value
-            expected_converted = variable.convert(expected_value)
-            return self._check_value_match(
-                actual_value, expected_converted, variable.type
-            )
-        except Exception as e:
-            logger.debug(f"Failed to compare need for '{var_name}': {e}")
-            return False
-
     def _is_need_satisfied(self, need_str: str) -> bool:
         """Check if a single need condition is satisfied.
 
@@ -259,17 +216,58 @@ class VariableCollection:
         """
         var_or_section, expected_value = self._parse_need(need_str)
 
-        # Old format: section name check
         if expected_value is None:
-            return self._check_section_need(var_or_section)
-
-        # New format: variable value check
-        variable = self._variable_map.get(var_or_section)
-        if not variable:
-            logger.warning(f"Need references missing variable '{var_or_section}'")
-            return False
+            # Old format: check if section is enabled (backwards compatibility)
+            section = self._sections.get(var_or_section)
+            if not section:
+                logger.warning(f"Need references missing section '{var_or_section}'")
+                return False
+            return section.is_enabled()
+        else:
+            # New format: check if variable has expected value(s)
+            variable = self._variable_map.get(var_or_section)
+            if not variable:
+                logger.warning(f"Need references missing variable '{var_or_section}'")
+                return False
 
-        return self._check_variable_need(var_or_section, expected_value, variable)
+            # Convert actual value for comparison
+            try:
+                actual_value = variable.convert(variable.value)
+
+                # Handle multiple expected values (comma-separated in needs)
+                if isinstance(expected_value, list):
+                    # Check if actual value matches any of the expected values
+                    for expected in expected_value:
+                        expected_converted = variable.convert(expected)
+
+                        # Handle boolean comparisons specially
+                        if variable.type == "bool":
+                            if bool(actual_value) == bool(expected_converted):
+                                return True
+                        else:
+                            # String comparison for other types
+                            if actual_value is not None and str(actual_value) == str(
+                                expected_converted
+                            ):
+                                return True
+                    return False  # None of the expected values matched
+                else:
+                    # Single expected value (original behavior)
+                    expected_converted = variable.convert(expected_value)
+
+                    # Handle boolean comparisons specially
+                    if variable.type == "bool":
+                        return bool(actual_value) == bool(expected_converted)
+
+                    # String comparison for other types
+                    return (
+                        str(actual_value) == str(expected_converted)
+                        if actual_value is not None
+                        else False
+                    )
+            except Exception as e:
+                logger.debug(f"Failed to compare need '{need_str}': {e}")
+                return False
 
     def _validate_dependencies(self) -> None:
         """Validate section dependencies for cycles and missing references.
@@ -285,35 +283,33 @@ class VariableCollection:
                 if expected_value is None:
                     # Old format: validate section exists
                     if var_or_section not in self._sections:
-                        raise VariableError(
+                        raise ValueError(
                             f"Section '{section_key}' depends on '{var_or_section}', but '{var_or_section}' does not exist"
                         )
-                # New format: validate variable exists
-                # NOTE: We only warn here, not raise an error, because the variable might be
-                # added later during merge with module spec. The actual runtime check in
-                # _is_need_satisfied() will handle missing variables gracefully.
-                elif (
-                    expected_value is not None
-                    and var_or_section not in self._variable_map
-                ):
-                    logger.debug(
-                        f"Section '{section_key}' has need '{dep}', but variable '{var_or_section}' "
-                        f"not found (might be added during merge)"
-                    )
+                else:
+                    # New format: validate variable exists
+                    # NOTE: We only warn here, not raise an error, because the variable might be
+                    # added later during merge with module spec. The actual runtime check in
+                    # _is_need_satisfied() will handle missing variables gracefully.
+                    if var_or_section not in self._variable_map:
+                        logger.debug(
+                            f"Section '{section_key}' has need '{dep}', but variable '{var_or_section}' "
+                            f"not found (might be added during merge)"
+                        )
 
         # Check for missing dependencies in variables
         for var_name, variable in self._variable_map.items():
             for dep in variable.needs:
                 dep_var, expected_value = self._parse_need(dep)
-                # Only validate new format
-                if expected_value is not None and dep_var not in self._variable_map:
-                    # NOTE: We only warn here, not raise an error, because the variable might be
-                    # added later during merge with module spec. The actual runtime check in
-                    # _is_need_satisfied() will handle missing variables gracefully.
-                    logger.debug(
-                        f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' "
-                        f"not found (might be added during merge)"
-                    )
+                if expected_value is not None:  # Only validate new format
+                    if dep_var not in self._variable_map:
+                        # NOTE: We only warn here, not raise an error, because the variable might be
+                        # added later during merge with module spec. The actual runtime check in
+                        # _is_need_satisfied() will handle missing variables gracefully.
+                        logger.debug(
+                            f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' "
+                            f"not found (might be added during merge)"
+                        )
 
         # Check for circular dependencies using depth-first search
         # Note: Only checks section-level dependencies in old format (section names)
@@ -335,7 +331,7 @@ class VariableCollection:
                         if has_cycle(dep_name):
                             return True
                     elif dep_name in rec_stack:
-                        raise VariableError(
+                        raise ValueError(
                             f"Circular dependency detected: '{section_key}' depends on '{dep_name}', "
                             f"which creates a cycle"
                         )
@@ -418,14 +414,10 @@ class VariableCollection:
         """
         reset_vars = []
 
-        # Pre-compute satisfaction states to avoid repeated lookups
-        section_states = {
-            key: (self.is_section_satisfied(key), section.is_enabled())
-            for key, section in self._sections.items()
-        }
-
         for section_key, section in self._sections.items():
-            section_satisfied, is_enabled = section_states[section_key]
+            # Check if section dependencies are satisfied
+            section_satisfied = self.is_section_satisfied(section_key)
+            is_enabled = section.is_enabled()
 
             for var_name, variable in section.variables.items():
                 # Only process bool variables
@@ -436,23 +428,24 @@ class VariableCollection:
                 var_satisfied = self.is_variable_satisfied(var_name)
 
                 # If section is disabled OR variable dependencies aren't met, reset to False
-                # Only reset if current value is not already False and not CLI-provided
-                if (
-                    (not section_satisfied or not is_enabled or not var_satisfied)
-                    and variable.value is not False
-                    and variable.origin != "cli"
-                ):
-                    # Store original value if not already stored (for display purposes)
-                    if not hasattr(variable, "_original_disabled"):
-                        variable._original_disabled = variable.value
-
-                    variable.value = False
-                    reset_vars.append(var_name)
-                    logger.debug(
-                        f"Reset disabled bool variable '{var_name}' to False "
-                        f"(section satisfied: {section_satisfied}, enabled: {is_enabled}, "
-                        f"var satisfied: {var_satisfied})"
-                    )
+                if not section_satisfied or not is_enabled or not var_satisfied:
+                    # Only reset if current value is not already False
+                    if variable.value is not False:
+                        # Don't reset CLI-provided variables - they'll be validated later
+                        if variable.origin == "cli":
+                            continue
+
+                        # Store original value if not already stored (for display purposes)
+                        if not hasattr(variable, "_original_disabled"):
+                            variable._original_disabled = variable.value
+
+                        variable.value = False
+                        reset_vars.append(var_name)
+                        logger.debug(
+                            f"Reset disabled bool variable '{var_name}' to False "
+                            f"(section satisfied: {section_satisfied}, enabled: {is_enabled}, "
+                            f"var satisfied: {var_satisfied})"
+                        )
 
         return reset_vars
 
@@ -505,7 +498,7 @@ class VariableCollection:
         for section in self._sections.values():
             section.sort_variables(self._is_need_satisfied)
 
-    def _topological_sort(self) -> list[str]:
+    def _topological_sort(self) -> List[str]:
         """Perform topological sort on sections based on dependencies using Kahn's algorithm."""
         in_degree = {key: len(section.needs) for key, section in self._sections.items()}
         queue = [key for key, degree in in_degree.items() if degree == 0]
@@ -532,52 +525,11 @@ class VariableCollection:
 
         return result
 
-    def iter_active_sections(
-        self,
-        include_disabled: bool = False,
-        include_unsatisfied: bool = False,
-    ):
-        """Iterate over sections respecting dependencies and toggles.
-
-        This is the centralized iterator for processing sections with proper
-        filtering. It eliminates duplicate iteration logic across the codebase.
-
-        Args:
-            include_disabled: If True, include sections that are disabled via toggle
-            include_unsatisfied: If True, include sections with unsatisfied dependencies
-
-        Yields:
-            Tuple of (section_key, section) for each active section
-
-        Examples:
-            # Only enabled sections with satisfied dependencies (default)
-            for key, section in variables.iter_active_sections():
-                process(section)
-
-            # Include disabled sections but skip unsatisfied dependencies
-            for key, section in variables.iter_active_sections(include_disabled=True):
-                process(section)
-        """
-        for section_key, section in self._sections.items():
-            # Check dependencies first
-            if not include_unsatisfied and not self.is_section_satisfied(section_key):
-                logger.debug(
-                    f"Skipping section '{section_key}' - dependencies not satisfied"
-                )
-                continue
-
-            # Check enabled status
-            if not include_disabled and not section.is_enabled():
-                logger.debug(f"Skipping section '{section_key}' - section is disabled")
-                continue
-
-            yield section_key, section
-
-    def get_sections(self) -> dict[str, VariableSection]:
+    def get_sections(self) -> Dict[str, VariableSection]:
         """Get all sections in the collection."""
         return self._sections.copy()
 
-    def get_section(self, key: str) -> VariableSection | None:
+    def get_section(self, key: str) -> Optional[VariableSection]:
         """Get a specific section by its key."""
         return self._sections.get(key)
 
@@ -634,7 +586,7 @@ class VariableCollection:
 
         return satisfied_values
 
-    def get_sensitive_variables(self) -> dict[str, Any]:
+    def get_sensitive_variables(self) -> Dict[str, Any]:
         """Get only the sensitive variables with their values."""
         return {
             name: var.value
@@ -725,32 +677,23 @@ class VariableCollection:
 
         return successful
 
-    def _collect_unmet_needs(self, section, var_name: str, variable) -> set[str]:
-        """Collect unmet needs for a variable."""
-        section_satisfied = self.is_section_satisfied(section.key)
-        var_satisfied = self.is_variable_satisfied(var_name)
-        unmet_needs = set()
-
-        if not section_satisfied:
-            for need in section.needs:
-                if not self._is_need_satisfied(need):
-                    unmet_needs.add(need)
-        if not var_satisfied:
-            for need in variable.needs:
-                if not self._is_need_satisfied(need):
-                    unmet_needs.add(need)
-
-        return unmet_needs
-
-    def _validate_cli_bool_variables(self) -> list[str]:
-        """Validate CLI-provided bool variables with unsatisfied dependencies."""
+    def validate_all(self) -> None:
+        """Validate all variables in the collection.
+
+        Validates:
+        - All variables in enabled sections with satisfied dependencies
+        - Required variables even if their section is disabled (but dependencies must be satisfied)
+        - CLI-provided bool variables with unsatisfied dependencies
+        """
         errors: list[str] = []
 
+        # First, check for CLI-provided bool variables with unsatisfied dependencies
         for section_key, section in self._sections.items():
             section_satisfied = self.is_section_satisfied(section_key)
             is_enabled = section.is_enabled()
 
             for var_name, variable in section.variables.items():
+                # Check CLI-provided bool variables with unsatisfied dependencies
                 if (
                     variable.type == "bool"
                     and variable.origin == "cli"
@@ -759,9 +702,17 @@ class VariableCollection:
                     var_satisfied = self.is_variable_satisfied(var_name)
 
                     if not section_satisfied or not is_enabled or not var_satisfied:
-                        unmet_needs = self._collect_unmet_needs(
-                            section, var_name, variable
-                        )
+                        # Build error message with unmet needs (use set to avoid duplicates)
+                        unmet_needs = set()
+                        if not section_satisfied:
+                            for need in section.needs:
+                                if not self._is_need_satisfied(need):
+                                    unmet_needs.add(need)
+                        if not var_satisfied:
+                            for need in variable.needs:
+                                if not self._is_need_satisfied(need):
+                                    unmet_needs.add(need)
+
                         needs_str = (
                             ", ".join(sorted(unmet_needs))
                             if unmet_needs
@@ -771,102 +722,69 @@ class VariableCollection:
                             f"{section.key}.{var_name} (set via CLI to {variable.value} but requires: {needs_str})"
                         )
 
-        return errors
-
-    def _validate_variable(self, section, var_name: str, variable) -> str | None:
-        """Validate a single variable and return error message if invalid."""
-        try:
-            # Skip autogenerated variables when empty
-            if variable.autogenerated and not variable.value:
-                return None
-
-            # Check required fields
-            if variable.value is None:
-                return self._validate_none_value(section, var_name, variable)
-
-            # Validate typed value
-            typed = variable.convert(variable.value)
-            if variable.type not in ("bool",) and not typed:
-                return self._validate_empty_value(section, var_name, variable)
-
-        except ValueError as e:
-            return f"{section.key}.{var_name} (invalid format: {e})"
-
-        return None
-
-    def _validate_none_value(self, section, var_name: str, variable) -> str | None:
-        """Validate when variable value is None."""
-        # Optional variables can be None/empty
-        if hasattr(variable, "optional") and variable.optional:
-            return None
-        if variable.is_required():
-            return f"{section.key}.{var_name} (required - no default provided)"
-        return None
-
-    def _validate_empty_value(self, section, var_name: str, variable) -> str | None:
-        """Validate when variable value is empty."""
-        msg = f"{section.key}.{var_name}"
-        if variable.is_required():
-            return f"{msg} (required - cannot be empty)"
-        return f"{msg} (empty)"
-
-    def _validate_section_variables(self, section_key: str, section) -> list[str]:
-        """Validate all variables in a section."""
-        errors: list[str] = []
-
-        # Skip sections with unsatisfied dependencies
-        if not self.is_section_satisfied(section_key):
-            logger.debug(
-                f"Skipping validation for section '{section_key}' - dependencies not satisfied"
-            )
-            return errors
-
-        is_enabled = section.is_enabled()
-
-        if not is_enabled:
-            logger.debug(
-                f"Section '{section_key}' is disabled - validating only required variables"
-            )
-
-        # Validate variables in the section
-        for var_name, variable in section.variables.items():
-            # Skip all variables in disabled sections
-            if not is_enabled:
+        # Then validate all other variables
+        for section_key, section in self._sections.items():
+            # Skip sections with unsatisfied dependencies (even for required variables)
+            if not self.is_section_satisfied(section_key):
+                logger.debug(
+                    f"Skipping validation for section '{section_key}' - dependencies not satisfied"
+                )
                 continue
 
-            error = self._validate_variable(section, var_name, variable)
-            if error:
-                errors.append(error)
-
-        return errors
+            # Check if section is enabled
+            is_enabled = section.is_enabled()
 
-    def validate_all(self) -> None:
-        """Validate all variables in the collection.
+            if not is_enabled:
+                logger.debug(
+                    f"Section '{section_key}' is disabled - validating only required variables"
+                )
 
-        Validates:
-        - All variables in enabled sections with satisfied dependencies
-        - Required variables even if their section is disabled (but dependencies must be satisfied)
-        - CLI-provided bool variables with unsatisfied dependencies
-        """
-        errors: list[str] = []
+            # Validate variables in the section
+            for var_name, variable in section.variables.items():
+                # Skip all variables (including required ones) in disabled sections
+                # Required variables are only required when their section is actually enabled
+                if not is_enabled:
+                    continue
 
-        # First, check for CLI-provided bool variables with unsatisfied dependencies
-        errors.extend(self._validate_cli_bool_variables())
+                try:
+                    # Skip autogenerated variables when empty
+                    if variable.autogenerated and not variable.value:
+                        continue
+
+                    # Check required fields
+                    if variable.value is None:
+                        # Optional variables can be None/empty
+                        if hasattr(variable, "optional") and variable.optional:
+                            continue
+                        if variable.is_required():
+                            errors.append(
+                                f"{section.key}.{var_name} (required - no default provided)"
+                            )
+                        continue
+
+                    # Validate typed value
+                    typed = variable.convert(variable.value)
+                    if variable.type not in ("bool",) and not typed:
+                        msg = f"{section.key}.{var_name}"
+                        errors.append(
+                            f"{msg} (required - cannot be empty)"
+                            if variable.is_required()
+                            else f"{msg} (empty)"
+                        )
 
-        # Then validate all other variables
-        for section_key, section in self._sections.items():
-            errors.extend(self._validate_section_variables(section_key, section))
+                except ValueError as e:
+                    errors.append(f"{section.key}.{var_name} (invalid format: {e})")
 
         if errors:
             error_msg = "Variable validation failed: " + ", ".join(errors)
             logger.error(error_msg)
-            raise VariableValidationError("__multiple__", ", ".join(errors))
+            raise ValueError(error_msg)
 
     def merge(
         self,
-        other_spec: dict[str, Any] | VariableCollection,
+        other_spec: Union[Dict[str, Any], "VariableCollection"],
         origin: str = "override",
-    ) -> VariableCollection:
+    ) -> "VariableCollection":
         """Merge another spec or VariableCollection into this one with precedence tracking.
 
         OPTIMIZED: Works directly on objects without dict conversions for better performance.
@@ -986,10 +904,9 @@ class VariableCollection:
                     update["needs"] = other_var.needs.copy() if other_var.needs else []
 
                 # Special handling for value/default (allow explicit null to clear)
-                if (
-                    "value" in other_var._explicit_fields
-                    or "default" in other_var._explicit_fields
-                ):
+                if "value" in other_var._explicit_fields:
+                    update["value"] = other_var.value
+                elif "default" in other_var._explicit_fields:
                     update["value"] = other_var.value
 
                 merged_section.variables[var_name] = self_var.clone(update=update)
@@ -1002,8 +919,8 @@ class VariableCollection:
         return merged_section
 
     def filter_to_used(
-        self, used_variables: set[str], keep_sensitive: bool = True
-    ) -> VariableCollection:
+        self, used_variables: Set[str], keep_sensitive: bool = True
+    ) -> "VariableCollection":
         """Filter collection to only variables that are used (or sensitive).
 
         OPTIMIZED: Works directly on objects without dict conversions for better performance.
@@ -1061,7 +978,7 @@ class VariableCollection:
 
         return filtered
 
-    def get_all_variable_names(self) -> set[str]:
+    def get_all_variable_names(self) -> Set[str]:
         """Get set of all variable names across all sections.
 
         Returns:

+ 0 - 9
network-v1

@@ -1,9 +0,0 @@
----
-services:
-  test_service:
-    networks:
-      bridge:
-
-networks:
-  bridge:
-    driver: bridge