mirror of
https://github.com/ChristianLempa/boilerplates.git
synced 2026-08-28 10:04:57 -05:00
298 lines
11 KiB
Python
298 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Callable
|
|
|
|
from rich.console import Console
|
|
from rich.prompt import Prompt
|
|
|
|
from ..display import DisplayManager
|
|
from ..input import InputManager
|
|
from ..template import Variable, VariableCollection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PromptHandler:
|
|
"""Simple interactive prompt handler for collecting template variables."""
|
|
|
|
def __init__(self) -> None:
|
|
self.console = Console()
|
|
self.display = DisplayManager()
|
|
|
|
def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool:
|
|
"""Handle section toggle variable and return whether section should be enabled."""
|
|
if not section.toggle:
|
|
return True
|
|
|
|
toggle_var = section.variables.get(section.toggle)
|
|
if not toggle_var:
|
|
return True
|
|
|
|
current_value = toggle_var.convert(toggle_var.value)
|
|
new_value = self._prompt_variable(toggle_var, _required=False)
|
|
|
|
if new_value != current_value:
|
|
collected[toggle_var.name] = new_value
|
|
toggle_var.value = new_value
|
|
|
|
return section.is_enabled()
|
|
|
|
def _should_skip_variable(
|
|
self,
|
|
var_name: str,
|
|
section,
|
|
variables: VariableCollection,
|
|
section_enabled: bool,
|
|
) -> bool:
|
|
"""Determine if a variable should be skipped during collection."""
|
|
if section.toggle and var_name == section.toggle:
|
|
return True
|
|
|
|
if not variables.is_variable_satisfied(var_name):
|
|
logger.debug(f"Skipping variable '{var_name}' - needs not satisfied")
|
|
return True
|
|
|
|
if not section_enabled:
|
|
logger.debug(f"Skipping variable '{var_name}' from disabled section '{section.key}'")
|
|
return True
|
|
|
|
return False
|
|
|
|
def _collect_variable_value(self, variable: Variable, collected: dict[str, Any]) -> None:
|
|
"""Collect a single variable value and update if changed."""
|
|
current_value = variable.convert(variable.value)
|
|
new_value = self._prompt_variable(variable, _required=False)
|
|
|
|
if variable.autogenerated and new_value is None:
|
|
collected[variable.name] = None
|
|
variable.value = None
|
|
elif new_value != current_value:
|
|
collected[variable.name] = new_value
|
|
variable.value = new_value
|
|
|
|
def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
|
|
"""Collect values for variables by iterating through sections.
|
|
|
|
Args:
|
|
variables: VariableCollection with organized sections and variables
|
|
|
|
Returns:
|
|
Dict of variable names to collected values
|
|
"""
|
|
input_mgr = InputManager()
|
|
if not input_mgr.confirm("Customize any settings?", default=False):
|
|
logger.info("User opted to keep all default values")
|
|
return {}
|
|
|
|
collected: dict[str, Any] = {}
|
|
|
|
for _section_key, section in variables.get_sections().items():
|
|
if not section.variables:
|
|
continue
|
|
|
|
self.display.section(section.title, section.description)
|
|
section_enabled = self._handle_section_toggle(section, collected)
|
|
|
|
for var_name, variable in section.variables.items():
|
|
if self._should_skip_variable(var_name, section, variables, section_enabled):
|
|
continue
|
|
|
|
self._collect_variable_value(variable, collected)
|
|
|
|
logger.info(f"Variable collection completed. Collected {len(collected)} values")
|
|
return collected
|
|
|
|
def _prompt_variable(self, variable: Variable, _required: bool = False) -> Any:
|
|
"""Prompt for a single variable value based on its type.
|
|
|
|
Args:
|
|
variable: The variable to prompt for
|
|
_required: Whether the containing section is required
|
|
(unused, kept for API compatibility)
|
|
|
|
Returns:
|
|
The validated value entered by the user
|
|
"""
|
|
logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
|
|
|
|
# Use variable's native methods for prompt text and default value
|
|
prompt_text = variable.get_prompt_text()
|
|
default_value = variable.get_normalized_default()
|
|
has_explicit_default = "default" in variable._explicit_fields or "value" in variable._explicit_fields
|
|
has_applied_default = variable.origin in {"config", "var-file", "cli"}
|
|
if (
|
|
not has_explicit_default
|
|
and not has_applied_default
|
|
and not variable.autogenerated
|
|
and not variable.is_required()
|
|
):
|
|
default_value = None
|
|
|
|
# Add lock icon before default value for secret or autogenerated variables
|
|
if variable.is_secret() or variable.autogenerated:
|
|
# Format: "Prompt text 🔒 (default)"
|
|
# The lock icon goes between the text and the default value in parentheses
|
|
prompt_text = f"{prompt_text} {self.display.get_lock_icon()}"
|
|
|
|
if variable.config.placeholder:
|
|
prompt_text = f"{prompt_text} [dim]({variable.config.placeholder})[/dim]"
|
|
|
|
# Check if this specific variable is required (has no default and not autogenerated)
|
|
var_is_required = variable.is_required()
|
|
|
|
# If variable is required, mark it in the prompt
|
|
if var_is_required:
|
|
prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
|
|
|
|
allow_empty = not var_is_required and default_value is None
|
|
handler = self._get_prompt_handler(variable, allow_empty=allow_empty)
|
|
|
|
# Add validation hint (includes both extra text and enum options)
|
|
hint = variable.get_validation_hint()
|
|
if hint:
|
|
prompt_text = f"{prompt_text} [dim]({hint})[/dim]"
|
|
|
|
while True:
|
|
try:
|
|
raw = handler(prompt_text, default_value)
|
|
# Use Variable's centralized validation method that handles:
|
|
# - Type conversion
|
|
# - Autogenerated variable detection
|
|
# - Required field validation
|
|
return variable.validate_and_convert(raw, check_required=True)
|
|
|
|
# Return the converted value (caller will update variable.value)
|
|
except ValueError as exc:
|
|
# Conversion/validation failed — show a consistent error message and retry
|
|
self._show_validation_error(str(exc))
|
|
except Exception as e:
|
|
# Unexpected error — log and retry using the stored (unconverted) value
|
|
logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
|
|
default_value = variable.value
|
|
handler = self._get_prompt_handler(variable, allow_empty=allow_empty)
|
|
|
|
def _get_prompt_handler(self, variable: Variable, allow_empty: bool = False) -> Callable:
|
|
"""Return the prompt function for a variable type."""
|
|
handlers = {
|
|
"bool": lambda text, default: self._prompt_bool(text, default, allow_empty=allow_empty),
|
|
"int": lambda text, default: self._prompt_int(
|
|
text,
|
|
default,
|
|
allow_empty=allow_empty,
|
|
min_value=variable.config.min if variable.config else None,
|
|
max_value=variable.config.max if variable.config else None,
|
|
),
|
|
# For enum prompts we pass the variable.extra through so options and extra
|
|
# can be combined into a single inline hint.
|
|
"enum": lambda text, default: self._prompt_enum(
|
|
text,
|
|
variable.options or [],
|
|
default,
|
|
allow_empty=allow_empty,
|
|
_extra=getattr(variable, "extra", None),
|
|
),
|
|
}
|
|
return handlers.get(
|
|
variable.type,
|
|
lambda text, default: self._prompt_string(text, default, is_secret=variable.is_secret()),
|
|
)
|
|
|
|
def _show_validation_error(self, message: str) -> None:
|
|
"""Display validation feedback consistently."""
|
|
self.display.error(message)
|
|
|
|
def _prompt_string(self, prompt_text: str, default: Any = None, is_secret: bool = False) -> str | None:
|
|
if is_secret:
|
|
value = Prompt.ask(
|
|
prompt_text,
|
|
default="",
|
|
show_default=False,
|
|
password=True,
|
|
)
|
|
stripped = value.strip() if value else None
|
|
if stripped:
|
|
return stripped
|
|
return default
|
|
|
|
value = Prompt.ask(
|
|
prompt_text,
|
|
default=str(default) if default is not None else "",
|
|
show_default=True,
|
|
password=False,
|
|
)
|
|
stripped = value.strip() if value else None
|
|
return stripped if stripped else None
|
|
|
|
def _prompt_bool(self, prompt_text: str, default: Any = None, allow_empty: bool = False) -> bool | str | None:
|
|
input_mgr = InputManager()
|
|
if allow_empty and default is None:
|
|
value = Prompt.ask(prompt_text, default="", show_default=False)
|
|
stripped = value.strip() if value else ""
|
|
return stripped if stripped else None
|
|
if default is None:
|
|
return input_mgr.confirm(prompt_text, default=None)
|
|
converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
|
|
return input_mgr.confirm(prompt_text, default=converted)
|
|
|
|
def _prompt_int(
|
|
self,
|
|
prompt_text: str,
|
|
default: Any = None,
|
|
allow_empty: bool = False,
|
|
min_value: int | None = None,
|
|
max_value: int | None = None,
|
|
) -> int | str | None:
|
|
converted = None
|
|
if default is not None:
|
|
try:
|
|
converted = int(default)
|
|
except (ValueError, TypeError):
|
|
logger.warning(f"Invalid default integer value: {default}")
|
|
if allow_empty and converted is None:
|
|
value = Prompt.ask(prompt_text, default="", show_default=False)
|
|
stripped = value.strip() if value else ""
|
|
return stripped if stripped else None
|
|
input_mgr = InputManager()
|
|
return input_mgr.integer(
|
|
prompt_text,
|
|
default=converted,
|
|
min_value=min_value,
|
|
max_value=max_value,
|
|
)
|
|
|
|
def _prompt_enum(
|
|
self,
|
|
prompt_text: str,
|
|
options: list[str],
|
|
default: Any = None,
|
|
allow_empty: bool = False,
|
|
_extra: str | None = None,
|
|
) -> str | None:
|
|
"""Prompt for enum selection with validation.
|
|
|
|
Note: prompt_text should already include hint from variable.get_validation_hint()
|
|
but we keep this for backward compatibility and fallback.
|
|
"""
|
|
if not options:
|
|
return self._prompt_string(prompt_text, default)
|
|
|
|
# Validate default is in options
|
|
if default and str(default) not in options:
|
|
default = options[0]
|
|
|
|
if allow_empty and default is None:
|
|
value = Prompt.ask(prompt_text, default="", show_default=False)
|
|
stripped = value.strip() if value else ""
|
|
return stripped if stripped else None
|
|
|
|
while True:
|
|
value = Prompt.ask(
|
|
prompt_text,
|
|
default=str(default) if default else options[0],
|
|
show_default=True,
|
|
)
|
|
if value in options:
|
|
return value
|
|
self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")
|