prompt.py 9.4 KB

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