"""The Prompt class supplies a number of methods which manage command-line input and validation."""
import datetime
from typing import Callable, Literal, Optional, Union
from rich.console import Console
from rich.markup import escape
from .validators import (
CharacterValidator,
ChoiceValidator,
DateValidator,
FloatValidator,
IntegerValidator,
Length,
ValidationError,
)
CaseTransform = Literal["upper", "lower", "casefold", "none"]
[docs]
class Prompt:
"""Call methods of this class to prompt for and validate different
types of data (characters, integers, etc.).
If you have a Rich Console instance already created, supply
it to the constructor, otherwise one will be created.
:param console: Rich console instance, if one has been created (default=None)
:type console: :class:`~rich.console.Console`
"""
def __init__(self, console: Optional[Console] = None):
self.console = console or Console(highlight=False)
[docs]
@staticmethod
def transform_text(text: str, transform: CaseTransform) -> str:
"""Apply a case transformation.
The ``transform`` parameter indicates how the text should
be transformed. It can be one of:
* 'upper': converted to upper case
* 'lower': converted to lower case
* 'casefold`: applies :py:meth:`str.casefold()`
* 'none': no transformation applied
:param text: Text to transform
:type text: str
:param transform: Transformation to apply
:type transform: str
"""
if transform == "upper":
return text.upper()
if transform == "lower":
return text.lower()
if transform == "casefold":
return text.casefold()
return text
[docs]
def prompt(
self,
text: str,
validators: Optional[list[Callable[[str], None]]] = None,
commands: str = "",
transform: CaseTransform = "none",
default: str = "",
) -> str:
"""Display a command line prompt and return the user input.
The ``validators`` parameter takes an optional list of validators.
This is any callable that takes a string as its single parameter
and raises a :exc:`~clprompt.validators.ValidationError` if the string is invalid
according to its validation rules.
If any validation fails the user prompt will be redisplayed,
preceded by the validation error message.
``prompt`` accepts an optional string of case-insensitive single-letter commands.
If any one of these characters is entered at the prompt
it will be returned without any further validation being undertaken.
The ``transform`` parameter indicates how the input string should
be transformed before returning. It can be one of:
* 'upper': converted to upper case
* 'lower': converted to lower case
* 'casefold`: applies :py:meth:`str.casefold()`
* 'none': input string is returned as entered
If ``default`` is provided and the user input is empty,
the value of ``default`` will be returned without any further validation.
:param text: Text prompt to display
:type text: str
:param validators: Validators to test user input (default=None)
:type validators: list[Callable[[str], None]]
:param commands: String of single-letter commands to accept (default='')
:type commands: str
:param transform: Transformation to apply to input before returning (default='none')
:type transform: str
:param default: Value to return if no user input is supplied (default='')
:type default: str
:returns: Validated user input
:rtype: str
"""
valid = False
err = ""
user_input = ""
prompt_text = " ".join([text, escape(f"[{default}]")]) if default else text
while not valid:
# Display error message if there is one
if err:
if "\n" in text:
# Multiline display
msg = f"\n[bold red]{err}.[/]\n{prompt_text}"
else:
# Single line display
msg = f"[bold red]{err}.[/] {prompt_text}"
else:
# No error
msg = prompt_text
# Get input
user_input = self.console.input(f"{msg}: ")
# Return default value
if default and not user_input:
return default
# Validate input and loop round again if invalid
valid = True
# Check for a single-letter command
if (
commands
and len(user_input) == 1
and user_input.casefold() in commands.casefold()
):
break
# Run validators
if validators:
for validator in validators:
try:
validator(user_input)
except ValidationError as e:
err = str(e)
valid = False
break
return self.transform_text(user_input, transform)
[docs]
def integer(
self,
text: str,
min: Optional[int] = None,
max: Optional[int] = None,
default: Optional[int] = None,
commands: str = "",
transform: CaseTransform = "upper",
) -> Union[int, str]:
"""Prompt for an integer.
.. code-block::
prompt = Prompt()
prompt.integer('Pick a number', min=1, max=10, default=7)
# Prompt is displayed as:
# Pick a number (1-10) [7]:
:param text: Prompt text
:type text: str
:param min: Minimum accepted value (default=None)
:type min: int
:param max: Maximum accepted value (default=None)
:type max: int
:param default: Default value (default=None)
:type default: int
:param commands: String of single-letter commands to accept (default='')
:type commands: str
:param transform: Transformation to apply to command input before returning (default='upper')
:type transform: str
:returns: The integer or command entered at the prompt, or the default value if none was entered
:rtype: Union[int, str]
"""
# Number range if one is given
rng = (
f" ({min}-{max})"
if min is not None and max is not None and max > min
else ""
)
# Get response
response = self.prompt(
f"{text}{rng}",
validators=[IntegerValidator(min=min, max=max, accept_empty=False)],
commands=commands,
transform=transform,
default="" if default is None else str(default),
)
try:
return int(response)
except ValueError:
return response
[docs]
def float(
self,
text: str,
min: Optional[float] = None,
max: Optional[float] = None,
default: Optional[float] = None,
commands: str = "",
transform: CaseTransform = "upper",
) -> Union[float, str]:
"""Prompt for a float.
.. code-block::
prompt = Prompt()
prompt.float('Pick a number', min=1.2, max=3.5, default=2.0)
# Prompt is displayed as:
# Pick a number (1.2-3.5) [2.0]:
:param text: Prompt text
:type text: str
:param min: Minimum accepted value (default=None)
:type min: float
:param max: Maximum accepted value (default=None)
:type max: float
:param default: Default value (default=None)
:type default: float
:param commands: String of single-letter commands to accept (default='')
:type commands: str
:param transform: Transformation to apply to command input before returning (default='upper')
:type transform: str
:returns: The float or command entered at the prompt, or the default value if none was entered
:rtype: Union[float, str]
"""
# Number range if one is given
rng = (
f" ({min}-{max})"
if min is not None and max is not None and max > min
else ""
)
# Get response
response = self.prompt(
f"{text}{rng}",
validators=[FloatValidator(min=min, max=max, accept_empty=False)],
commands=commands,
transform=transform,
default="" if default is None else str(default),
)
try:
return float(response)
except ValueError:
return response
[docs]
def string_list(
self,
text: str,
default: Optional[list[str]] = None,
transform: CaseTransform = "none",
) -> list[str]:
"""Prompt for a list of strings.
Presents user with a text prompt and an options default::
prompt = Prompt()
prompt.list("Words to exclude", default=["yes", "no")
# Prompt is displayed as:
# Words to exclude [yes, no]:
Words should be separated by a comma, and will
be stored without leading or trailing whitespace.
:param text: Prompt text
:type text: str
:param default: Default option (default=None)
:type default: list[str]
:param transform: Transformation to apply to input before returning (default='none')
:type transform: str
:returns: The words entered at the prompt
:rtype: list[str]
"""
words = self.prompt(
text,
transform=transform,
default="" if default is None else ", ".join(default),
)
return [word.strip() for word in words.split(",") if len(word.strip())]
[docs]
def yes_no(
self,
text: str,
default: Optional[str] = None,
transform: CaseTransform = "upper",
) -> str:
"""Prompt for a yes/no response.
Presents user with a y/n option and an optional default::
prompt = Prompt()
choice = prompt.yes_no("Again", default="y")
# Prompt is displayed as:
# Again (Y/N)? [Y]:
:param text: Prompt text
:type text: str
:param default: Default option (default=None)
:type default: str
:param transform: Transformation to apply to input before returning (default='upper')
:type transform: str
:returns: The letter entered at the prompt
:rtype: str
"""
return self.prompt(
f"{text} (Y/N)?",
validators=[CharacterValidator(valid="yn"), Length(min=1, max=1)],
transform=transform,
default="" if default is None else self.transform_text(default, transform),
)
[docs]
def options(
self,
options: dict[str, str],
default: Optional[str] = None,
transform: CaseTransform = "upper",
multi_line: int = 5,
) -> str:
"""Prompt for one of a list of command options.
Options are provided as a ``dict`` where the key is the
character to enter to select the option and the value
is a description of that option. The function creates
a prompt to present the options to the user::
prompt = Prompt()
options = {
's': 'to solve',
'p': 'to play',
'q': 'to quit',
}
choice = prompt.options(options, default='s')
# Prompt is displayed as:
# Enter S to solve, P to play, Q to quit [S]:
If the number of options is greater than or equal to``multi_line``
the options will be split across multiple lines::
prompt = Prompt()
options = {
's': 'to solve',
'p': 'to play',
'q': 'to quit',
}
choice = prompt.options(options, default='s', multi_line=2)
# Prompt is displayed as:
# S to solve
# P to play
# Q to quit
# Enter choice [S]:
:param options: Options to display
:type options: dict[str, str]
:param default: Default option (default=None)
:type default: str
:param transform: Transformation to apply to input before returning (default='upper')
:type transform: str
:param multi_line: Lowest number of options to split across multiple lines (default=5)
:type multi_line: int
:returns: Selected option, or default option if none is selected
:rtype: str
"""
if len(options) >= multi_line:
prefix = ""
sep = "\n"
mid = "\nEnter choice"
else:
prefix = "Enter "
sep = ", "
mid = ""
option_text = sep.join(
[f"[bold]{k.upper()}[/] {v}" for k, v in options.items()]
)
letters = "".join(options.keys())
return self.prompt(
f"{prefix}{option_text}{mid}",
[CharacterValidator(valid=letters), Length(min=1, max=1)],
transform=transform,
default="" if default is None else self.transform_text(default, transform),
)
[docs]
def choice(
self,
text: str,
choices: list[str],
default: Optional[str] = None,
commands: str = "",
transform: CaseTransform = "none",
) -> str:
"""Prompt for one of a list of predefined choices.
The choices are provided as a list of strings::
prompt = Prompt()
choices = ["ham", "eggs", "spam"]
choice = prompt.choice("Please choose your breakfast", choices, default="ham")
# Prompt is displayed as:
# Please choose your breakfast [ham]:
:param text: Text prompt to display
:type text: str
:param choices: List of available choices
:type choices: list[str]
:param default: Default option (default=None)
:type default: str
:param commands: String of single-letter commands to accept (default='')
:type commands: str
:param transform: Transformation to apply to input before returning (default='none')
:type transform: str
:returns: Validated user input
:rtype: str
"""
return self.prompt(
f"{text}",
[ChoiceValidator(choices, accept_empty=False)],
commands=commands,
transform=transform,
default="" if default is None else self.transform_text(default, transform),
)
[docs]
def date(
self,
text: str,
format: str,
default: Optional[datetime.date] = None,
commands: str = "",
transform: CaseTransform = "upper",
accept_empty: bool = False,
) -> Union[datetime.date, str]:
"""Prompt for a date
.. code-block::
prompt = Prompt()
date = prompt.date('Enter a date (dd/mm/yyyy)', '%d/%m/%Y', default=datetime.date(2024, 1, 1))
# Prompt is displayed as:
# Enter a date (dd/mm/yyyy) [01/01/2024]:
:param text: Prompt text
:type text: str
:param format: Date format as per :meth:`~datetime.datetime.strptime`
:type format: str
:param default: Default value (default=None)
:type default: :class:`datetime.date`
:param commands: String of single-letter commands to accept (default='')
:type commands: str
:param transform: Transformation to apply to command input before returning (default='upper')
:type transform: str
:param accept_empty: Accept the empty string as a return value (default=False)
:type accept_empty: bool
:returns: The date or command entered at the prompt, or the default value if nothing was entered
:rtype: datetime.date | str
"""
date = self.prompt(
f"{text}",
[DateValidator(format, accept_empty=accept_empty)],
commands=commands,
transform=transform,
default="" if default is None else datetime.date.strftime(default, format),
)
try:
return datetime.datetime.strptime(date, format).date()
except ValueError:
return date