prompt_manager.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. from __future__ import annotations
  2. import logging
  3. from typing import Any, Callable
  4. from rich.console import Console
  5. from rich.prompt import Confirm, IntPrompt, Prompt
  6. from .display import DisplayManager
  7. from .template import Variable, VariableCollection
  8. logger = logging.getLogger(__name__)
  9. class PromptHandler:
  10. """Simple interactive prompt handler for collecting template variables."""
  11. def __init__(self) -> None:
  12. self.console = Console()
  13. self.display = DisplayManager()
  14. def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
  15. """Collect values for variables by iterating through sections.
  16. Args:
  17. variables: VariableCollection with organized sections and variables
  18. Returns:
  19. Dict of variable names to collected values
  20. """
  21. if not Confirm.ask("Customize any settings?", default=False):
  22. logger.info("User opted to keep all default values")
  23. return {}
  24. collected: dict[str, Any] = {}
  25. prompted_variables: set[str] = (
  26. set()
  27. ) # Track which variables we've already prompted for
  28. # Process each section (only satisfied dependencies, include disabled for toggle handling)
  29. for section_key, section in variables.iter_active_sections(
  30. include_disabled=True
  31. ):
  32. if not section.variables:
  33. continue
  34. # Always show section header first
  35. self.display.display_section(section.title, section.description)
  36. # Track whether this section will be enabled
  37. section_will_be_enabled = True
  38. # Handle section toggle - skip for required sections
  39. if section.required:
  40. # Required sections are always processed, no toggle prompt needed
  41. logger.debug(
  42. f"Processing required section '{section.key}' without toggle prompt"
  43. )
  44. elif section.toggle:
  45. toggle_var = section.variables.get(section.toggle)
  46. if toggle_var:
  47. # Prompt for toggle variable using standard variable prompting logic
  48. # This ensures consistent handling of description, extra text, validation hints, etc.
  49. current_value = toggle_var.convert(toggle_var.value)
  50. new_value = self._prompt_variable(
  51. toggle_var, required=section.required
  52. )
  53. if new_value != current_value:
  54. collected[toggle_var.name] = new_value
  55. toggle_var.value = new_value
  56. # Use section's native is_enabled() method
  57. if not section.is_enabled():
  58. section_will_be_enabled = False
  59. # Collect variables in this section
  60. for var_name, variable in section.variables.items():
  61. # Skip toggle variable (already handled)
  62. if section.toggle and var_name == section.toggle:
  63. continue
  64. # Skip variables with unsatisfied needs (similar to display logic)
  65. if not variables.is_variable_satisfied(var_name):
  66. logger.debug(
  67. f"Skipping variable '{var_name}' - needs not satisfied"
  68. )
  69. continue
  70. # Skip all variables if section is disabled
  71. if not section_will_be_enabled:
  72. logger.debug(
  73. f"Skipping variable '{var_name}' from disabled section '{section_key}'"
  74. )
  75. continue
  76. # Prompt for the variable
  77. current_value = variable.convert(variable.value)
  78. # Pass section.required so _prompt_variable can enforce required inputs
  79. new_value = self._prompt_variable(variable, required=section.required)
  80. # Track that we've prompted for this variable
  81. prompted_variables.add(var_name)
  82. # For autogenerated variables, always update even if None (signals autogeneration)
  83. if variable.autogenerated and new_value is None:
  84. collected[var_name] = None
  85. variable.value = None
  86. elif new_value != current_value:
  87. collected[var_name] = new_value
  88. variable.value = new_value
  89. logger.info(f"Variable collection completed. Collected {len(collected)} values")
  90. return collected
  91. def _prompt_variable(self, variable: Variable, required: bool = False) -> Any:
  92. """Prompt for a single variable value based on its type.
  93. Args:
  94. variable: The variable to prompt for
  95. required: Whether the containing section is required (for context/display)
  96. Returns:
  97. The validated value entered by the user
  98. """
  99. logger.debug(
  100. f"Prompting for variable '{variable.name}' (type: {variable.type})"
  101. )
  102. # Use variable's native methods for prompt text and default value
  103. prompt_text = variable.get_prompt_text()
  104. default_value = variable.get_normalized_default()
  105. # Add lock icon before default value for sensitive or autogenerated variables
  106. if variable.sensitive or variable.autogenerated:
  107. # Format: "Prompt text 🔒 (default)"
  108. # The lock icon goes between the text and the default value in parentheses
  109. prompt_text = f"{prompt_text} {self.display.get_lock_icon()}"
  110. # Check if this specific variable is required (has no default and not autogenerated)
  111. var_is_required = variable.is_required()
  112. # If variable is required, mark it in the prompt
  113. if var_is_required:
  114. prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
  115. handler = self._get_prompt_handler(variable)
  116. # Add validation hint (includes both extra text and enum options)
  117. hint = variable.get_validation_hint()
  118. if hint:
  119. # Show options/extra inline inside parentheses, before the default
  120. prompt_text = f"{prompt_text} [dim]({hint})[/dim]"
  121. while True:
  122. try:
  123. raw = handler(prompt_text, default_value)
  124. # Use Variable's centralized validation method that handles:
  125. # - Type conversion
  126. # - Autogenerated variable detection
  127. # - Required field validation
  128. converted = variable.validate_and_convert(raw, check_required=True)
  129. # Return the converted value (caller will update variable.value)
  130. return converted
  131. except ValueError as exc:
  132. # Conversion/validation failed — show a consistent error message and retry
  133. self._show_validation_error(str(exc))
  134. except Exception as e:
  135. # Unexpected error — log and retry using the stored (unconverted) value
  136. logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
  137. default_value = variable.value
  138. handler = self._get_prompt_handler(variable)
  139. def _get_prompt_handler(self, variable: Variable) -> Callable:
  140. """Return the prompt function for a variable type."""
  141. handlers = {
  142. "bool": self._prompt_bool,
  143. "int": self._prompt_int,
  144. # For enum prompts we pass the variable.extra through so options and extra
  145. # can be combined into a single inline hint.
  146. "enum": lambda text, default: self._prompt_enum(
  147. text,
  148. variable.options or [],
  149. default,
  150. extra=getattr(variable, "extra", None),
  151. ),
  152. }
  153. return handlers.get(
  154. variable.type,
  155. lambda text, default: self._prompt_string(
  156. text, default, is_sensitive=variable.sensitive
  157. ),
  158. )
  159. def _show_validation_error(self, message: str) -> None:
  160. """Display validation feedback consistently."""
  161. self.display.display_validation_error(message)
  162. def _prompt_string(
  163. self, prompt_text: str, default: Any = None, is_sensitive: bool = False
  164. ) -> str | None:
  165. value = Prompt.ask(
  166. prompt_text,
  167. default=str(default) if default is not None else "",
  168. show_default=True,
  169. password=is_sensitive,
  170. )
  171. stripped = value.strip() if value else None
  172. return stripped if stripped else None
  173. def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None:
  174. if default is None:
  175. return Confirm.ask(prompt_text, default=None)
  176. converted = (
  177. default
  178. if isinstance(default, bool)
  179. else str(default).lower() in ("true", "1", "yes", "on")
  180. )
  181. return Confirm.ask(prompt_text, default=converted)
  182. def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None:
  183. converted = None
  184. if default is not None:
  185. try:
  186. converted = int(default)
  187. except (ValueError, TypeError):
  188. logger.warning(f"Invalid default integer value: {default}")
  189. return IntPrompt.ask(prompt_text, default=converted)
  190. def _prompt_enum(
  191. self,
  192. prompt_text: str,
  193. options: list[str],
  194. default: Any = None,
  195. extra: str | None = None,
  196. ) -> str:
  197. """Prompt for enum selection with validation.
  198. Note: prompt_text should already include hint from variable.get_validation_hint()
  199. but we keep this for backward compatibility and fallback.
  200. """
  201. if not options:
  202. return self._prompt_string(prompt_text, default)
  203. # Validate default is in options
  204. if default and str(default) not in options:
  205. default = options[0]
  206. while True:
  207. value = Prompt.ask(
  208. prompt_text,
  209. default=str(default) if default else options[0],
  210. show_default=True,
  211. )
  212. if value in options:
  213. return value
  214. self.console.print(
  215. f"[red]Invalid choice. Select from: {', '.join(options)}[/red]"
  216. )