codekingpro/portable-devtools
114k
1from typing import Any, Generic, List, Optional, TextIO, TypeVar, Union, overload2 3from . import get_console4from .console import Console5from .text import Text, TextType6 7PromptType = TypeVar("PromptType")8DefaultType = TypeVar("DefaultType")9 10 11class PromptError(Exception):12 """Exception base class for prompt related errors."""13 14 15class InvalidResponse(PromptError):16 """Exception to indicate a response was invalid. Raise this within process_response() to indicate an error17 and provide an error message.18 19 Args:20 message (Union[str, Text]): Error message.21 """22 23 def __init__(self, message: TextType) -> None:24 self.message = message25 26 def __rich__(self) -> TextType:27 return self.message28 29 30class PromptBase(Generic[PromptType]):31 """Ask the user for input until a valid response is received. This is the base class, see one of32 the concrete classes for examples.33 34 Args:35 prompt (TextType, optional): Prompt text. Defaults to "".36 console (Console, optional): A Console instance or None to use global console. Defaults to None.37 password (bool, optional): Enable password input. Defaults to False.38 choices (List[str], optional): A list of valid choices. Defaults to None.39 case_sensitive (bool, optional): Matching of choices should be case-sensitive. Defaults to True.40 show_default (bool, optional): Show default in prompt. Defaults to True.41 show_choices (bool, optional): Show choices in prompt. Defaults to True.42 """43 44 response_type: type = str45 46 validate_error_message = "[prompt.invalid]Please enter a valid value"47 illegal_choice_message = (48 "[prompt.invalid.choice]Please select one of the available options"49 )50 prompt_suffix = ": "51 52 choices: Optional[List[str]] = None53 54 def __init__(55 self,56 prompt: TextType = "",57 *,58 console: Optional[Console] = None,59 password: bool = False,60 choices: Optional[List[str]] = None,61 case_sensitive: bool = True,62 show_default: bool = True,63 show_choices: bool = True,64 ) -> None:65 self.console = console or get_console()66 self.prompt = (67 Text.from_markup(prompt, style="prompt")68 if isinstance(prompt, str)69 else prompt70 )71 self.password = password72 if choices is not None:73 self.choices = choices74 self.case_sensitive = case_sensitive75 self.show_default = show_default76 self.show_choices = show_choices77 78 @classmethod79 @overload80 def ask(81 cls,82 prompt: TextType = "",83 *,84 console: Optional[Console] = None,85 password: bool = False,86 choices: Optional[List[str]] = None,87 case_sensitive: bool = True,88 show_default: bool = True,89 show_choices: bool = True,90 default: DefaultType,91 stream: Optional[TextIO] = None,92 ) -> Union[DefaultType, PromptType]:93 ...94 95 @classmethod96 @overload97 def ask(98 cls,99 prompt: TextType = "",100 *,101 console: Optional[Console] = None,102 password: bool = False,103 choices: Optional[List[str]] = None,104 case_sensitive: bool = True,105 show_default: bool = True,106 show_choices: bool = True,107 stream: Optional[TextIO] = None,108 ) -> PromptType:109 ...110 111 @classmethod112 def ask(113 cls,114 prompt: TextType = "",115 *,116 console: Optional[Console] = None,117 password: bool = False,118 choices: Optional[List[str]] = None,119 case_sensitive: bool = True,120 show_default: bool = True,121 show_choices: bool = True,122 default: Any = ...,123 stream: Optional[TextIO] = None,124 ) -> Any:125 """Shortcut to construct and run a prompt loop and return the result.126 127 Example:128 >>> filename = Prompt.ask("Enter a filename")129 130 Args:131 prompt (TextType, optional): Prompt text. Defaults to "".132 console (Console, optional): A Console instance or None to use global console. Defaults to None.133 password (bool, optional): Enable password input. Defaults to False.134 choices (List[str], optional): A list of valid choices. Defaults to None.135 case_sensitive (bool, optional): Matching of choices should be case-sensitive. Defaults to True.136 show_default (bool, optional): Show default in prompt. Defaults to True.137 show_choices (bool, optional): Show choices in prompt. Defaults to True.138 stream (TextIO, optional): Optional text file open for reading to get input. Defaults to None.139 """140 _prompt = cls(141 prompt,142 console=console,143 password=password,144 choices=choices,145 case_sensitive=case_sensitive,146 show_default=show_default,147 show_choices=show_choices,148 )149 return _prompt(default=default, stream=stream)150 151 def render_default(self, default: DefaultType) -> Text:152 """Turn the supplied default in to a Text instance.153 154 Args:155 default (DefaultType): Default value.156 157 Returns:158 Text: Text containing rendering of default value.159 """160 return Text(f"({default})", "prompt.default")161 162 def make_prompt(self, default: DefaultType) -> Text:163 """Make prompt text.164 165 Args:166 default (DefaultType): Default value.167 168 Returns:169 Text: Text to display in prompt.170 """171 prompt = self.prompt.copy()172 prompt.end = ""173 174 if self.show_choices and self.choices:175 _choices = "/".join(self.choices)176 choices = f"[{_choices}]"177 prompt.append(" ")178 prompt.append(choices, "prompt.choices")179 180 if (181 default != ...182 and self.show_default183 and isinstance(default, (str, self.response_type))184 ):185 prompt.append(" ")186 _default = self.render_default(default)187 prompt.append(_default)188 189 prompt.append(self.prompt_suffix)190 191 return prompt192 193 @classmethod194 def get_input(195 cls,196 console: Console,197 prompt: TextType,198 password: bool,199 stream: Optional[TextIO] = None,200 ) -> str:201 """Get input from user.202 203 Args:204 console (Console): Console instance.205 prompt (TextType): Prompt text.206 password (bool): Enable password entry.207 208 Returns:209 str: String from user.210 """211 return console.input(prompt, password=password, stream=stream)212 213 def check_choice(self, value: str) -> bool:214 """Check value is in the list of valid choices.215 216 Args:217 value (str): Value entered by user.218 219 Returns:220 bool: True if choice was valid, otherwise False.221 """222 assert self.choices is not None223 if self.case_sensitive:224 return value.strip() in self.choices225 return value.strip().lower() in [choice.lower() for choice in self.choices]226 227 def process_response(self, value: str) -> PromptType:228 """Process response from user, convert to prompt type.229 230 Args:231 value (str): String typed by user.232 233 Raises:234 InvalidResponse: If ``value`` is invalid.235 236 Returns:237 PromptType: The value to be returned from ask method.238 """239 value = value.strip()240 try:241 return_value: PromptType = self.response_type(value)242 except ValueError:243 raise InvalidResponse(self.validate_error_message)244 245 if self.choices is not None:246 if not self.check_choice(value):247 raise InvalidResponse(self.illegal_choice_message)248 249 if not self.case_sensitive:250 # return the original choice, not the lower case version251 return_value = self.response_type(252 self.choices[253 [choice.lower() for choice in self.choices].index(value.lower())254 ]255 )256 return return_value257 258 def on_validate_error(self, value: str, error: InvalidResponse) -> None:259 """Called to handle validation error.260 261 Args:262 value (str): String entered by user.263 error (InvalidResponse): Exception instance the initiated the error.264 """265 self.console.print(error, markup=True)266 267 def pre_prompt(self) -> None:268 """Hook to display something before the prompt."""269 270 @overload271 def __call__(self, *, stream: Optional[TextIO] = None) -> PromptType:272 ...273 274 @overload275 def __call__(276 self, *, default: DefaultType, stream: Optional[TextIO] = None277 ) -> Union[PromptType, DefaultType]:278 ...279 280 def __call__(self, *, default: Any = ..., stream: Optional[TextIO] = None) -> Any:281 """Run the prompt loop.282 283 Args:284 default (Any, optional): Optional default value.285 286 Returns:287 PromptType: Processed value.288 """289 while True:290 self.pre_prompt()291 prompt = self.make_prompt(default)292 value = self.get_input(self.console, prompt, self.password, stream=stream)293 if value == "" and default != ...:294 return default295 try:296 return_value = self.process_response(value)297 except InvalidResponse as error:298 self.on_validate_error(value, error)299 continue300 else:301 return return_value302 303 304class Prompt(PromptBase[str]):305 """A prompt that returns a str.306 307 Example:308 >>> name = Prompt.ask("Enter your name")309 310 311 """312 313 response_type = str314 315 316class IntPrompt(PromptBase[int]):317 """A prompt that returns an integer.318 319 Example:320 >>> burrito_count = IntPrompt.ask("How many burritos do you want to order")321 322 """323 324 response_type = int325 validate_error_message = "[prompt.invalid]Please enter a valid integer number"326 327 328class FloatPrompt(PromptBase[float]):329 """A prompt that returns a float.330 331 Example:332 >>> temperature = FloatPrompt.ask("Enter desired temperature")333 334 """335 336 response_type = float337 validate_error_message = "[prompt.invalid]Please enter a number"338 339 340class Confirm(PromptBase[bool]):341 """A yes / no confirmation prompt.342 343 Example:344 >>> if Confirm.ask("Continue"):345 run_job()346 347 """348 349 response_type = bool350 validate_error_message = "[prompt.invalid]Please enter Y or N"351 choices: List[str] = ["y", "n"]352 353 def render_default(self, default: DefaultType) -> Text:354 """Render the default as (y) or (n) rather than True/False."""355 yes, no = self.choices356 return Text(f"({yes})" if default else f"({no})", style="prompt.default")357 358 def process_response(self, value: str) -> bool:359 """Convert choices to a bool."""360 value = value.strip().lower()361 if value not in self.choices:362 raise InvalidResponse(self.validate_error_message)363 return value == self.choices[0]364 365 366if __name__ == "__main__": # pragma: no cover367 from rich import print368 369 if Confirm.ask("Run [i]prompt[/i] tests?", default=True):370 while True:371 result = IntPrompt.ask(372 ":rocket: Enter a number between [b]1[/b] and [b]10[/b]", default=5373 )374 if result >= 1 and result <= 10:375 break376 print(":pile_of_poo: [prompt.invalid]Number must be between 1 and 10")377 print(f"number={result}")378 379 while True:380 password = Prompt.ask(381 "Please enter a password [cyan](must be at least 5 characters)",382 password=True,383 )384 if len(password) >= 5:385 break386 print("[prompt.invalid]password too short")387 print(f"password={password!r}")388 389 fruit = Prompt.ask("Enter a fruit", choices=["apple", "orange", "pear"])390 print(f"fruit={fruit!r}")391 392 doggie = Prompt.ask(393 "What's the best Dog? (Case INSENSITIVE)",394 choices=["Border Terrier", "Collie", "Labradoodle"],395 case_sensitive=False,396 )397 print(f"doggie={doggie!r}")398 399 else:400 print("[b]OK :loudly_crying_face:")401 