mirror of
https://github.com/ChristianLempa/boilerplates.git
synced 2026-08-28 10:04:57 -05:00
283 lines
9.2 KiB
Python
283 lines
9.2 KiB
Python
"""Input Manager for standardized user input handling.
|
|
|
|
This module provides a centralized interface for all user input operations,
|
|
ensuring consistent styling and validation across the CLI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import Callable
|
|
|
|
from rich.console import Console
|
|
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
|
|
from .input_settings import InputSettings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
console = Console()
|
|
|
|
|
|
class InputManager:
|
|
"""Manages all user input operations with standardized styling.
|
|
|
|
This class provides primitives for various types of user input including
|
|
text, passwords, confirmations, choices, and validated inputs.
|
|
"""
|
|
|
|
def __init__(self, settings: InputSettings | None = None):
|
|
"""Initialize InputManager.
|
|
|
|
Args:
|
|
settings: Input configuration settings (uses default if None)
|
|
"""
|
|
self.settings = settings or InputSettings()
|
|
|
|
def text(
|
|
self,
|
|
prompt: str,
|
|
default: str | None = None,
|
|
password: bool = False,
|
|
validator: Callable[[str], bool] | None = None,
|
|
error_message: str | None = None,
|
|
) -> str:
|
|
"""Prompt for text input.
|
|
|
|
Args:
|
|
prompt: Prompt message to display
|
|
default: Default value if user presses Enter
|
|
password: If True, mask the input
|
|
validator: Optional validation function
|
|
error_message: Custom error message for validation failure
|
|
|
|
Returns:
|
|
User input string
|
|
"""
|
|
if password:
|
|
return self.password(prompt, default)
|
|
|
|
while True:
|
|
result = Prompt.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
|
|
default=default or "",
|
|
show_default=default is not None,
|
|
console=console,
|
|
)
|
|
|
|
if validator and not validator(result):
|
|
msg = error_message or "Invalid input"
|
|
console.print(f"[{self.settings.PROMPT_ERROR_STYLE}]{msg}[/{self.settings.PROMPT_ERROR_STYLE}]")
|
|
continue
|
|
|
|
return result
|
|
|
|
def password(self, prompt: str, default: str | None = None) -> str:
|
|
"""Prompt for password input (masked).
|
|
|
|
Args:
|
|
prompt: Prompt message to display
|
|
default: Default value if user presses Enter
|
|
|
|
Returns:
|
|
User input string (masked during entry)
|
|
"""
|
|
return Prompt.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
|
|
default=default or "",
|
|
show_default=default is not None,
|
|
password=True,
|
|
console=console,
|
|
)
|
|
|
|
def confirm(self, prompt: str, default: bool | None = None) -> bool:
|
|
"""Prompt for yes/no confirmation.
|
|
|
|
Args:
|
|
prompt: Prompt message to display
|
|
default: Default value if user presses Enter
|
|
|
|
Returns:
|
|
True for yes, False for no
|
|
"""
|
|
if default is None:
|
|
default = self.settings.DEFAULT_CONFIRM_YES
|
|
|
|
return Confirm.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
|
|
default=default,
|
|
console=console,
|
|
)
|
|
|
|
def integer(
|
|
self,
|
|
prompt: str,
|
|
default: int | None = None,
|
|
min_value: int | None = None,
|
|
max_value: int | None = None,
|
|
) -> int:
|
|
"""Prompt for integer input with optional range validation.
|
|
|
|
Args:
|
|
prompt: Prompt message to display
|
|
default: Default value if user presses Enter
|
|
min_value: Minimum allowed value
|
|
max_value: Maximum allowed value
|
|
|
|
Returns:
|
|
Integer value
|
|
"""
|
|
while True:
|
|
if default is not None:
|
|
result = IntPrompt.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
|
|
default=default,
|
|
console=console,
|
|
)
|
|
else:
|
|
try:
|
|
result = IntPrompt.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
|
|
console=console,
|
|
)
|
|
except ValueError:
|
|
console.print(
|
|
f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_INTEGER}[/{self.settings.PROMPT_ERROR_STYLE}]"
|
|
)
|
|
continue
|
|
|
|
# Validate range
|
|
if min_value is not None and result < min_value:
|
|
error_style = self.settings.PROMPT_ERROR_STYLE
|
|
console.print(f"[{error_style}]Value must be at least {min_value}[/{error_style}]")
|
|
continue
|
|
|
|
if max_value is not None and result > max_value:
|
|
error_style = self.settings.PROMPT_ERROR_STYLE
|
|
console.print(f"[{error_style}]Value must be at most {max_value}[/{error_style}]")
|
|
continue
|
|
|
|
return result
|
|
|
|
def choice(self, prompt: str, choices: list[str], default: str | None = None) -> str:
|
|
"""Prompt user to select one option from a list.
|
|
|
|
Args:
|
|
prompt: Prompt message to display
|
|
choices: List of valid options
|
|
default: Default choice if user presses Enter
|
|
|
|
Returns:
|
|
Selected choice
|
|
"""
|
|
if not choices:
|
|
raise ValueError("Choices list cannot be empty")
|
|
|
|
choices_display = f"[{', '.join(choices)}]"
|
|
full_prompt = f"{prompt} {choices_display}"
|
|
|
|
while True:
|
|
result = Prompt.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]{full_prompt}[/{self.settings.PROMPT_STYLE}]",
|
|
default=default or "",
|
|
console=console,
|
|
)
|
|
|
|
if result in choices:
|
|
return result
|
|
|
|
console.print(
|
|
f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
|
|
)
|
|
|
|
def numbered_choice(self, prompt: str, choices: list[str], default: str | None = None) -> str:
|
|
"""Prompt user to select one option from a numbered list.
|
|
|
|
Users can answer with either the displayed number or the literal choice
|
|
value. This keeps common binary selections concise without changing the
|
|
existing free-text choice behavior used elsewhere in the CLI.
|
|
|
|
Args:
|
|
prompt: Prompt message to display
|
|
choices: List of valid options
|
|
default: Default choice if user presses Enter
|
|
|
|
Returns:
|
|
Selected choice
|
|
"""
|
|
if not choices:
|
|
raise ValueError("Choices list cannot be empty")
|
|
|
|
if default is not None and default not in choices:
|
|
raise ValueError("Default choice must be one of the available choices")
|
|
|
|
numbered_choices = {str(index): choice for index, choice in enumerate(choices, start=1)}
|
|
default_index = None
|
|
if default is not None:
|
|
default_index = choices.index(default) + 1
|
|
|
|
while True:
|
|
console.print(f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]")
|
|
for index, choice in enumerate(choices, start=1):
|
|
console.print(f" {index}. {choice}")
|
|
|
|
result = Prompt.ask(
|
|
f"[{self.settings.PROMPT_STYLE}]Selection[/{self.settings.PROMPT_STYLE}]",
|
|
default=str(default_index) if default_index is not None else "",
|
|
show_default=default_index is not None,
|
|
console=console,
|
|
).strip()
|
|
|
|
if result == "" and default_index is not None:
|
|
return numbered_choices[str(default_index)]
|
|
|
|
if result in numbered_choices:
|
|
return numbered_choices[result]
|
|
|
|
normalized_result = result
|
|
if normalized_result in choices:
|
|
return normalized_result
|
|
|
|
console.print(
|
|
f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
|
|
)
|
|
|
|
def validate_email(self, email: str) -> bool:
|
|
"""Validate email address format.
|
|
|
|
Args:
|
|
email: Email address to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
return bool(re.match(pattern, email))
|
|
|
|
def validate_url(self, url: str) -> bool:
|
|
"""Validate URL format.
|
|
|
|
Args:
|
|
url: URL to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
pattern = r"^https?://[^\s/$.?#].[^\s]*$"
|
|
return bool(re.match(pattern, url, re.IGNORECASE))
|
|
|
|
def validate_hostname(self, hostname: str) -> bool:
|
|
"""Validate hostname/domain format.
|
|
|
|
Args:
|
|
hostname: Hostname to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
pattern = (
|
|
r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*"
|
|
r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
|
|
)
|
|
return bool(re.match(pattern, hostname))
|