prompt.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. from __future__ import annotations
  2. from typing import Dict, Any, List, Callable
  3. import logging
  4. from rich.console import Console
  5. from rich.prompt import Prompt, Confirm, IntPrompt
  6. from rich.table import Table
  7. from .display import DisplayManager
  8. from .variables import Variable, VariableCollection
  9. logger = logging.getLogger(__name__)
  10. # ---------------------------
  11. # SECTION: PromptHandler Class
  12. # ---------------------------
  13. class PromptHandler:
  14. """Simple interactive prompt handler for collecting template variables."""
  15. def __init__(self) -> None:
  16. self.console = Console()
  17. self.display = DisplayManager()
  18. # --------------------------
  19. # SECTION: Public Methods
  20. # --------------------------
  21. def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
  22. """Collect values for variables by iterating through sections.
  23. Args:
  24. variables: VariableCollection with organized sections and variables
  25. Returns:
  26. Dict of variable names to collected values
  27. """
  28. if not Confirm.ask("Customize any settings?", default=False):
  29. logger.info("User opted to keep all default values")
  30. return {}
  31. collected: Dict[str, Any] = {}
  32. # Process each section
  33. for section_key, section in variables.get_sections().items():
  34. if not section.variables:
  35. continue
  36. # Check if dependencies are satisfied
  37. if not variables.is_section_satisfied(section_key):
  38. # Get list of unsatisfied dependencies for better user feedback
  39. unsatisfied = [dep for dep in section.needs if not variables.is_section_satisfied(dep)]
  40. dep_names = ", ".join(unsatisfied) if unsatisfied else "unknown"
  41. self.console.print(
  42. f"\n[dim]⊘ {section.title} (skipped - requires {dep_names} to be enabled)[/dim]"
  43. )
  44. logger.debug(f"Skipping section '{section_key}' - dependencies not satisfied: {dep_names}")
  45. continue
  46. # Always show section header first
  47. self.display.display_section_header(section.title, section.description)
  48. # Handle section toggle - skip for required sections
  49. if section.required:
  50. # Required sections are always processed, no toggle prompt needed
  51. logger.debug(f"Processing required section '{section.key}' without toggle prompt")
  52. elif section.toggle:
  53. toggle_var = section.variables.get(section.toggle)
  54. if toggle_var:
  55. # Use description for prompt if available, otherwise use title
  56. prompt_text = section.description if section.description else f"Enable {section.title}?"
  57. current_value = toggle_var.get_typed_value()
  58. new_value = self._prompt_bool(prompt_text, current_value)
  59. if new_value != current_value:
  60. collected[toggle_var.name] = new_value
  61. toggle_var.value = new_value
  62. # Use section's native is_enabled() method
  63. if not section.is_enabled():
  64. continue
  65. # Collect variables in this section
  66. for var_name, variable in section.variables.items():
  67. # Skip toggle variable (already handled)
  68. if section.toggle and var_name == section.toggle:
  69. continue
  70. current_value = variable.get_typed_value()
  71. # Pass section.required so _prompt_variable can enforce required inputs
  72. new_value = self._prompt_variable(variable, required=section.required)
  73. if new_value != current_value:
  74. collected[var_name] = new_value
  75. variable.value = new_value
  76. logger.info(f"Variable collection completed. Collected {len(collected)} values")
  77. return collected
  78. # !SECTION
  79. # ---------------------------
  80. # SECTION: Private Methods
  81. # ---------------------------
  82. def _prompt_variable(self, variable: Variable, required: bool = False) -> Any:
  83. """Prompt for a single variable value based on its type."""
  84. logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
  85. # Use variable's native methods for prompt text and default value
  86. prompt_text = variable.get_prompt_text()
  87. default_value = variable.get_normalized_default()
  88. # If variable is required and there's no default, mark it in the prompt
  89. # (but skip this for autogenerated variables since they can be empty)
  90. if required and default_value is None and not variable.autogenerated:
  91. prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
  92. handler = self._get_prompt_handler(variable)
  93. # Add validation hint (includes both extra text and enum options)
  94. hint = variable.get_validation_hint()
  95. if hint:
  96. prompt_text = f"{prompt_text} [dim]{hint}[/dim]"
  97. while True:
  98. try:
  99. raw = handler(prompt_text, default_value)
  100. # Convert/validate the user's input using the Variable conversion
  101. converted = variable.convert(raw)
  102. # Allow empty values for autogenerated variables
  103. if variable.autogenerated and (converted is None or (isinstance(converted, str) and converted == "")):
  104. return None # Return None to indicate auto-generation should happen
  105. # If this variable is required, do not accept None/empty values
  106. if required and (converted is None or (isinstance(converted, str) and converted == "")):
  107. raise ValueError("value cannot be empty for required variable")
  108. # Return the converted value (caller will update variable.value)
  109. return converted
  110. except ValueError as exc:
  111. # Conversion/validation failed — show a consistent error message and retry
  112. self._show_validation_error(str(exc))
  113. except Exception as e:
  114. # Unexpected error — log and retry using the stored (unconverted) value
  115. logger.error(f"Error prompting for variable '{variable.name}': {str(e)}")
  116. default_value = variable.value
  117. handler = self._get_prompt_handler(variable)
  118. def _get_prompt_handler(self, variable: Variable) -> Callable:
  119. """Return the prompt function for a variable type."""
  120. handlers = {
  121. "bool": self._prompt_bool,
  122. "int": self._prompt_int,
  123. # For enum prompts we pass the variable.extra through so options and extra
  124. # can be combined into a single inline hint.
  125. "enum": lambda text, default: self._prompt_enum(text, variable.options or [], default, extra=getattr(variable, 'extra', None)),
  126. }
  127. return handlers.get(variable.type, lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive))
  128. def _show_validation_error(self, message: str) -> None:
  129. """Display validation feedback consistently."""
  130. self.display.display_validation_error(message)
  131. def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str:
  132. value = Prompt.ask(
  133. prompt_text,
  134. default=str(default) if default is not None else "",
  135. show_default=True,
  136. password=is_sensitive
  137. )
  138. if value is None:
  139. return None
  140. stripped = value.strip()
  141. return stripped if stripped != "" else None
  142. def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool:
  143. default_bool = None
  144. if default is not None:
  145. default_bool = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
  146. return Confirm.ask(prompt_text, default=default_bool)
  147. def _prompt_int(self, prompt_text: str, default: Any = None) -> int:
  148. default_int = None
  149. if default is not None:
  150. try:
  151. default_int = int(default)
  152. except (ValueError, TypeError):
  153. logger.warning(f"Invalid default integer value: {default}")
  154. return IntPrompt.ask(prompt_text, default=default_int)
  155. def _prompt_enum(self, prompt_text: str, options: list[str], default: Any = None, extra: str | None = None) -> str:
  156. """Prompt for enum selection with validation.
  157. Note: prompt_text should already include hint from variable.get_validation_hint()
  158. but we keep this for backward compatibility and fallback.
  159. """
  160. if not options:
  161. return self._prompt_string(prompt_text, default)
  162. # Validate default is in options
  163. if default and str(default) not in options:
  164. default = options[0]
  165. while True:
  166. value = Prompt.ask(
  167. prompt_text,
  168. default=str(default) if default else options[0],
  169. show_default=True,
  170. )
  171. if value in options:
  172. return value
  173. self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")
  174. # !SECTION