input_manager.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """Input Manager for standardized user input handling.
  2. This module provides a centralized interface for all user input operations,
  3. ensuring consistent styling and validation across the CLI.
  4. """
  5. from __future__ import annotations
  6. import logging
  7. import re
  8. from typing import Callable
  9. from rich.console import Console
  10. from rich.prompt import Confirm, IntPrompt, Prompt
  11. from .input_settings import InputSettings
  12. logger = logging.getLogger(__name__)
  13. console = Console()
  14. class InputManager:
  15. """Manages all user input operations with standardized styling.
  16. This class provides primitives for various types of user input including
  17. text, passwords, confirmations, choices, and validated inputs.
  18. """
  19. def __init__(self, settings: InputSettings | None = None):
  20. """Initialize InputManager.
  21. Args:
  22. settings: Input configuration settings (uses default if None)
  23. """
  24. self.settings = settings or InputSettings()
  25. def text(
  26. self,
  27. prompt: str,
  28. default: str | None = None,
  29. password: bool = False,
  30. validator: Callable[[str], bool] | None = None,
  31. error_message: str | None = None,
  32. ) -> str:
  33. """Prompt for text input.
  34. Args:
  35. prompt: Prompt message to display
  36. default: Default value if user presses Enter
  37. password: If True, mask the input
  38. validator: Optional validation function
  39. error_message: Custom error message for validation failure
  40. Returns:
  41. User input string
  42. """
  43. if password:
  44. return self.password(prompt, default)
  45. while True:
  46. result = Prompt.ask(
  47. f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
  48. default=default or "",
  49. show_default=default is not None,
  50. console=console,
  51. )
  52. if validator and not validator(result):
  53. msg = error_message or "Invalid input"
  54. console.print(f"[{self.settings.PROMPT_ERROR_STYLE}]{msg}[/{self.settings.PROMPT_ERROR_STYLE}]")
  55. continue
  56. return result
  57. def password(self, prompt: str, default: str | None = None) -> str:
  58. """Prompt for password input (masked).
  59. Args:
  60. prompt: Prompt message to display
  61. default: Default value if user presses Enter
  62. Returns:
  63. User input string (masked during entry)
  64. """
  65. return Prompt.ask(
  66. f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
  67. default=default or "",
  68. show_default=default is not None,
  69. password=True,
  70. console=console,
  71. )
  72. def confirm(self, prompt: str, default: bool | None = None) -> bool:
  73. """Prompt for yes/no confirmation.
  74. Args:
  75. prompt: Prompt message to display
  76. default: Default value if user presses Enter
  77. Returns:
  78. True for yes, False for no
  79. """
  80. if default is None:
  81. default = self.settings.DEFAULT_CONFIRM_YES
  82. return Confirm.ask(
  83. f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
  84. default=default,
  85. console=console,
  86. )
  87. def integer(
  88. self,
  89. prompt: str,
  90. default: int | None = None,
  91. min_value: int | None = None,
  92. max_value: int | None = None,
  93. ) -> int:
  94. """Prompt for integer input with optional range validation.
  95. Args:
  96. prompt: Prompt message to display
  97. default: Default value if user presses Enter
  98. min_value: Minimum allowed value
  99. max_value: Maximum allowed value
  100. Returns:
  101. Integer value
  102. """
  103. while True:
  104. if default is not None:
  105. result = IntPrompt.ask(
  106. f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
  107. default=default,
  108. console=console,
  109. )
  110. else:
  111. try:
  112. result = IntPrompt.ask(
  113. f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
  114. console=console,
  115. )
  116. except ValueError:
  117. console.print(
  118. f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_INTEGER}[/{self.settings.PROMPT_ERROR_STYLE}]"
  119. )
  120. continue
  121. # Validate range
  122. if min_value is not None and result < min_value:
  123. error_style = self.settings.PROMPT_ERROR_STYLE
  124. console.print(f"[{error_style}]Value must be at least {min_value}[/{error_style}]")
  125. continue
  126. if max_value is not None and result > max_value:
  127. error_style = self.settings.PROMPT_ERROR_STYLE
  128. console.print(f"[{error_style}]Value must be at most {max_value}[/{error_style}]")
  129. continue
  130. return result
  131. def choice(self, prompt: str, choices: list[str], default: str | None = None) -> str:
  132. """Prompt user to select one option from a list.
  133. Args:
  134. prompt: Prompt message to display
  135. choices: List of valid options
  136. default: Default choice if user presses Enter
  137. Returns:
  138. Selected choice
  139. """
  140. if not choices:
  141. raise ValueError("Choices list cannot be empty")
  142. choices_display = f"[{', '.join(choices)}]"
  143. full_prompt = f"{prompt} {choices_display}"
  144. while True:
  145. result = Prompt.ask(
  146. f"[{self.settings.PROMPT_STYLE}]{full_prompt}[/{self.settings.PROMPT_STYLE}]",
  147. default=default or "",
  148. console=console,
  149. )
  150. if result in choices:
  151. return result
  152. console.print(
  153. f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
  154. )
  155. def numbered_choice(self, prompt: str, choices: list[str], default: str | None = None) -> str:
  156. """Prompt user to select one option from a numbered list.
  157. Users can answer with either the displayed number or the literal choice
  158. value. This keeps common binary selections concise without changing the
  159. existing free-text choice behavior used elsewhere in the CLI.
  160. Args:
  161. prompt: Prompt message to display
  162. choices: List of valid options
  163. default: Default choice if user presses Enter
  164. Returns:
  165. Selected choice
  166. """
  167. if not choices:
  168. raise ValueError("Choices list cannot be empty")
  169. if default is not None and default not in choices:
  170. raise ValueError("Default choice must be one of the available choices")
  171. numbered_choices = {str(index): choice for index, choice in enumerate(choices, start=1)}
  172. default_index = None
  173. if default is not None:
  174. default_index = choices.index(default) + 1
  175. while True:
  176. console.print(f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]")
  177. for index, choice in enumerate(choices, start=1):
  178. console.print(f" {index}. {choice}")
  179. result = Prompt.ask(
  180. f"[{self.settings.PROMPT_STYLE}]Selection[/{self.settings.PROMPT_STYLE}]",
  181. default=str(default_index) if default_index is not None else "",
  182. show_default=default_index is not None,
  183. console=console,
  184. ).strip()
  185. if result == "" and default_index is not None:
  186. return numbered_choices[str(default_index)]
  187. if result in numbered_choices:
  188. return numbered_choices[result]
  189. normalized_result = result
  190. if normalized_result in choices:
  191. return normalized_result
  192. console.print(
  193. f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
  194. )
  195. def validate_email(self, email: str) -> bool:
  196. """Validate email address format.
  197. Args:
  198. email: Email address to validate
  199. Returns:
  200. True if valid, False otherwise
  201. """
  202. pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
  203. return bool(re.match(pattern, email))
  204. def validate_url(self, url: str) -> bool:
  205. """Validate URL format.
  206. Args:
  207. url: URL to validate
  208. Returns:
  209. True if valid, False otherwise
  210. """
  211. pattern = r"^https?://[^\s/$.?#].[^\s]*$"
  212. return bool(re.match(pattern, url, re.IGNORECASE))
  213. def validate_hostname(self, hostname: str) -> bool:
  214. """Validate hostname/domain format.
  215. Args:
  216. hostname: Hostname to validate
  217. Returns:
  218. True if valid, False otherwise
  219. """
  220. pattern = (
  221. r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*"
  222. r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
  223. )
  224. return bool(re.match(pattern, hostname))