from __future__ import annotations import logging from typing import Any, Callable from rich.console import Console from rich.prompt import Confirm, IntPrompt, Prompt from .display import DisplayManager from .template.variable import Variable from .template.variable_collection import VariableCollection logger = logging.getLogger(__name__) class PromptHandler: """Simple interactive prompt handler for collecting template variables.""" def __init__(self) -> None: self.console = Console() self.display = DisplayManager() def collect_variables(self, variables: VariableCollection) -> dict[str, Any]: """Collect values for variables by iterating through sections. Args: variables: VariableCollection with organized sections and variables Returns: Dict of variable names to collected values """ if not Confirm.ask("Customize any settings?", default=False): self.console.print("") # Add blank line after prompt logger.info("User opted to keep all default values") return {} self.console.print("") # Add blank line after prompt collected: dict[str, Any] = {} # Process each section for section_key, section in variables.get_sections().items(): if not section.variables: continue # Check if dependencies are satisfied if not self._check_section_dependencies(variables, section_key, section): continue # Always show section header first self.display.display_section_header(section.title, section.description) # Handle section toggle and determine if enabled section_will_be_enabled = self._handle_section_toggle(section, collected) # Collect variables in this section self._collect_section_variables(section, section_key, section_will_be_enabled, variables, collected) logger.info(f"Variable collection completed. Collected {len(collected)} values") return collected def _check_section_dependencies(self, variables: VariableCollection, section_key: str, section) -> bool: """Check if section dependencies are satisfied and display skip message if not.""" 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) unsatisfied_titles.append(dep_section.title if dep_section else 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}") return False return True def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool: """Handle section toggle prompt and return whether section will be enabled.""" # Handle sections with toggle if not section.toggle: return True toggle_var = section.variables.get(section.toggle) if not toggle_var: return True # Prompt for toggle variable current_value = toggle_var.convert(toggle_var.value) new_value = self._prompt_variable(toggle_var, required=False) if new_value != current_value: collected[toggle_var.name] = new_value toggle_var.value = new_value # Return whether section is enabled return section.is_enabled() def _collect_section_variables( self, section, section_key: str, section_enabled: bool, variables: VariableCollection, collected: dict[str, Any], ) -> None: """Collect values for all variables in a section.""" for var_name, variable in section.variables.items(): # Skip toggle variable (already handled) if section.toggle and var_name == section.toggle: continue # Skip variables with unsatisfied needs 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_enabled: logger.debug(f"Skipping variable '{var_name}' from disabled section '{section_key}'") continue # Prompt for the variable and update if changed self._prompt_and_update_variable(variable, collected) def _prompt_and_update_variable(self, variable: Variable, collected: dict[str, Any]) -> None: """Prompt for a variable and update collected values if changed.""" current_value = variable.convert(variable.value) new_value = self._prompt_variable(variable, required=False) # For autogenerated variables, always update even if None (signals autogeneration) 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 _prompt_variable(self, variable: Variable, _required: bool = False) -> Any: """Prompt for a single variable value based on its type. Args: variable: The variable to prompt for _required: Whether the containing section is required (unused, kept for API compatibility) Returns: The validated value entered by the user """ logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})") # Use variable's native methods for prompt text and default value prompt_text = variable.get_prompt_text() default_value = variable.get_normalized_default() # Add lock icon before default value for sensitive or autogenerated variables if variable.sensitive or variable.autogenerated: # Format: "Prompt text 🔒 (default)" # The lock icon goes between the text and the default value in parentheses prompt_text = f"{prompt_text} {self.display.get_lock_icon()}" # Check if this specific variable is required (has no default and not autogenerated) var_is_required = variable.is_required() # If variable is required, mark it in the prompt if var_is_required: prompt_text = f"{prompt_text} [bold red]*required[/bold red]" handler = self._get_prompt_handler(variable) # Add validation hint (includes both extra text and enum options) hint = variable.get_validation_hint() if hint: # Show options/extra inline inside parentheses, before the default prompt_text = f"{prompt_text} [dim]({hint})[/dim]" while True: try: raw = handler(prompt_text, default_value) # Use Variable's centralized validation method that handles: # - Type conversion # - Autogenerated variable detection # - Required field validation return variable.validate_and_convert(raw, check_required=True) # Return the converted value (caller will update variable.value) except ValueError as exc: # Conversion/validation failed — show a consistent error message and retry 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}") default_value = variable.value handler = self._get_prompt_handler(variable) def _get_prompt_handler(self, variable: Variable) -> Callable: """Return the prompt function for a variable type.""" handlers = { "bool": self._prompt_bool, "int": self._prompt_int, # For enum prompts we pass the variable.extra through so options and extra # can be combined into a single inline hint. "enum": lambda text, default: self._prompt_enum( text, variable.options or [], default, extra=getattr(variable, "extra", None), ), } return handlers.get( variable.type, lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive), ) def _show_validation_error(self, message: str) -> None: """Display validation feedback consistently.""" self.display.display_validation_error(message) def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str | None: value = Prompt.ask( prompt_text, default=str(default) if default is not None else "", show_default=True, password=is_sensitive, ) stripped = value.strip() if value else None return stripped if stripped else None def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None: if default is None: return Confirm.ask(prompt_text, default=None) converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on") return Confirm.ask(prompt_text, default=converted) def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None: converted = None if default is not None: try: converted = int(default) except (ValueError, TypeError): logger.warning(f"Invalid default integer value: {default}") return IntPrompt.ask(prompt_text, default=converted) def _prompt_enum( self, prompt_text: str, options: list[str], default: Any = None, _extra: str | None = None, ) -> str: """Prompt for enum selection with validation. Note: prompt_text should already include hint from variable.get_validation_hint() but we keep this for backward compatibility and fallback. """ if not options: return self._prompt_string(prompt_text, default) # Validate default is in options if default and str(default) not in options: default = options[0] while True: value = Prompt.ask( prompt_text, default=str(default) if default else options[0], show_default=True, ) if value in options: return value self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")