prompt.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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. # Always show section header first
  37. self.display.display_section_header(section.title, section.description)
  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(f"Processing required section '{section.key}' without toggle prompt")
  42. elif section.toggle:
  43. toggle_var = section.variables.get(section.toggle)
  44. if toggle_var:
  45. prompt_text = section.prompt or f"Enable {section.title}?"
  46. current_value = toggle_var.get_typed_value()
  47. new_value = self._prompt_bool(prompt_text, current_value)
  48. if new_value != current_value:
  49. collected[toggle_var.name] = new_value
  50. toggle_var.value = new_value
  51. # Skip remaining variables in section if disabled
  52. if not new_value:
  53. continue
  54. # Collect variables in this section
  55. for var_name, variable in section.variables.items():
  56. # Skip toggle variable (already handled)
  57. if section.toggle and var_name == section.toggle:
  58. continue
  59. current_value = variable.get_typed_value()
  60. # Pass section.required so _prompt_variable can enforce required inputs
  61. new_value = self._prompt_variable(variable, required=section.required)
  62. if new_value != current_value:
  63. collected[var_name] = new_value
  64. variable.value = new_value
  65. logger.info(f"Variable collection completed. Collected {len(collected)} values")
  66. return collected
  67. # !SECTION
  68. # ---------------------------
  69. # SECTION: Private Methods
  70. # ---------------------------
  71. def _prompt_variable(self, variable: Variable, required: bool = False) -> Any:
  72. """Prompt for a single variable value based on its type."""
  73. logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
  74. prompt_text = variable.prompt or variable.description or variable.name
  75. # Normalize default value once and reuse. This centralizes handling for
  76. # enums, bools, ints and strings and avoids duplicated fallback logic.
  77. default_value = self._normalize_default(variable)
  78. # Friendly hint for common semantic types — only show if a default exists
  79. if default_value is not None and variable.type in ["hostname", "email", "url"]:
  80. prompt_text += f" ({variable.type})"
  81. # If variable is required and there's no default, mark it in the prompt
  82. if required and default_value is None:
  83. prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
  84. handler = self._get_prompt_handler(variable)
  85. # Attach the optional 'extra' explanation inline (dimmed) so it appears
  86. # after the main question rather than before it.
  87. if getattr(variable, 'extra', None):
  88. # Put the extra hint inline (same line) instead of on the next line.
  89. prompt_text = f"{prompt_text} [dim]{variable.extra}[/dim]"
  90. while True:
  91. try:
  92. raw = handler(prompt_text, default_value)
  93. # Convert/validate the user's input using the Variable conversion
  94. converted = variable.convert(raw)
  95. # If this variable is required, do not accept None/empty values
  96. if required and (converted is None or (isinstance(converted, str) and converted == "")):
  97. raise ValueError("value cannot be empty for required variable")
  98. # Return the converted value (caller will update variable.value)
  99. return converted
  100. except ValueError as exc:
  101. # Conversion/validation failed — show a consistent error message and retry
  102. self._show_validation_error(str(exc))
  103. except Exception as e:
  104. # Unexpected error — log and retry using the stored (unconverted) value
  105. logger.error(f"Error prompting for variable '{variable.name}': {str(e)}")
  106. default_value = variable.value
  107. handler = self._get_prompt_handler(variable)
  108. def _normalize_default(self, variable: Variable) -> Any:
  109. """Return a normalized default suitable for prompt handlers.
  110. Tries to use the typed value if available, otherwise falls back to the raw
  111. stored value. For enums, ensures the default is one of the options.
  112. """
  113. try:
  114. typed = variable.get_typed_value()
  115. except Exception:
  116. typed = variable.value
  117. # Special-case enums: ensure default is valid
  118. if variable.type == "enum":
  119. options = variable.options or []
  120. if not options:
  121. return typed
  122. # If typed is falsy or not in options, pick first option as fallback
  123. if typed is None or str(typed) not in options:
  124. return options[0]
  125. return str(typed)
  126. # For booleans and ints return as-is (handlers will accept these types)
  127. if variable.type == "bool":
  128. if isinstance(typed, bool):
  129. return typed
  130. if typed is None:
  131. return None
  132. return bool(typed)
  133. if variable.type == "int":
  134. try:
  135. return int(typed) if typed is not None and typed != "" else None
  136. except Exception:
  137. return None
  138. # Default: return string or None
  139. if typed is None:
  140. return None
  141. return str(typed)
  142. def _get_prompt_handler(self, variable: Variable) -> Callable:
  143. """Return the prompt function for a variable type."""
  144. handlers = {
  145. "bool": self._prompt_bool,
  146. "int": self._prompt_int,
  147. # For enum prompts we pass the variable.extra through so options and extra
  148. # can be combined into a single inline hint.
  149. "enum": lambda text, default: self._prompt_enum(text, variable.options or [], default, extra=getattr(variable, 'extra', None)),
  150. }
  151. return handlers.get(variable.type, lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive))
  152. def _show_validation_error(self, message: str) -> None:
  153. """Display validation feedback consistently."""
  154. self.display.display_validation_error(message)
  155. def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str:
  156. value = Prompt.ask(
  157. prompt_text,
  158. default=str(default) if default is not None else "",
  159. show_default=True,
  160. password=is_sensitive
  161. )
  162. if value is None:
  163. return None
  164. stripped = value.strip()
  165. return stripped if stripped != "" else None
  166. def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool:
  167. default_bool = None
  168. if default is not None:
  169. default_bool = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
  170. return Confirm.ask(prompt_text, default=default_bool)
  171. def _prompt_int(self, prompt_text: str, default: Any = None) -> int:
  172. default_int = None
  173. if default is not None:
  174. try:
  175. default_int = int(default)
  176. except (ValueError, TypeError):
  177. logger.warning(f"Invalid default integer value: {default}")
  178. return IntPrompt.ask(prompt_text, default=default_int)
  179. def _prompt_enum(self, prompt_text: str, options: list[str], default: Any = None, extra: str | None = None) -> str:
  180. """Prompt for enum selection with validation. """
  181. if not options:
  182. return self._prompt_string(prompt_text, default)
  183. # Build a single inline hint that contains both the options and any extra
  184. # explanation, rendered dimmed and appended to the prompt on one line.
  185. hint_parts: list[str] = []
  186. hint_parts.append(f"Options: {', '.join(options)}")
  187. if extra:
  188. hint_parts.append(extra)
  189. # Show options and extra inline (same line) in a single dimmed block.
  190. options_text = f" [dim]{' — '.join(hint_parts)}[/dim]"
  191. prompt_text_with_options = prompt_text + options_text
  192. # Validate default is in options
  193. if default and str(default) not in options:
  194. default = options[0]
  195. while True:
  196. value = Prompt.ask(
  197. prompt_text_with_options,
  198. default=str(default) if default else options[0],
  199. show_default=True,
  200. )
  201. if value in options:
  202. return value
  203. self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")
  204. # !SECTION