prompt.py 9.9 KB

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