| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- from __future__ import annotations
- import logging
- from typing import Any, Callable
- from rich.console import Console
- from rich.prompt import IntPrompt, Prompt
- from ..display import DisplayManager
- from ..input import InputManager
- from ..template import Variable, 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 _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool:
- """Handle section toggle variable and return whether section should be enabled."""
- 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=False)
- 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."""
- if section.toggle and var_name == section.toggle:
- return True
- if not variables.is_variable_satisfied(var_name):
- logger.debug(f"Skipping variable '{var_name}' - needs not satisfied")
- return True
- 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, 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=False)
- 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.
- Args:
- variables: VariableCollection with organized sections and variables
- Returns:
- Dict of variable names to collected values
- """
- input_mgr = InputManager()
- if not input_mgr.confirm("Customize any settings?", default=False):
- logger.info("User opted to keep all default values")
- return {}
- collected: dict[str, Any] = {}
- for _section_key, section in variables.get_sections().items():
- if not section.variables:
- continue
- self.display.section(section.title, section.description)
- section_enabled = self._handle_section_toggle(section, collected)
- for var_name, variable in section.variables.items():
- if self._should_skip_variable(var_name, section, variables, section_enabled):
- continue
- self._collect_variable_value(variable, collected)
- logger.info(f"Variable collection completed. Collected {len(collected)} values")
- return collected
- 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.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:
- input_mgr = InputManager()
- if default is None:
- return input_mgr.confirm(prompt_text, default=None)
- converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
- return input_mgr.confirm(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]")
|