"""
**puissant** provides a collection of functions for command-line validated user input.
The following conveninence user-input functions are available:
- :func:`puissant.menu` to choose single string options from a menu.
- :func:`puissant.yes_no` for yes/no questions input.
- :func:`puissant.tickbox_menu` for multiple choices from a menu
- :func:`puissant.ranged_int` for ranged integer input.
- :func:`puissant.ranged_float` for ranged floating point input.
- :func:`puissant.enum_str` for user input chosen from a string out of a list.
All of the above functions are wrappers around :func:`puissant.vinput`, which allows writing
generic user-validated input functions.
Examples
===========
- yes or no questions (`bool` output)::
>>> from puissant import *
>>> yes_no(prompt = 'Do you want to continue', default = 'n')
Do you want to continue [y/n]?(default:n) y
True
>>> yes_no(prompt = 'Do you want to continue', default = 'n')
Do you want to continue [y/n]?(default:n) <= Enter
False
- semantic versioning input::
>>> semantic_version('Enter next version: ')
Enter next version: a1.3.2
Not conformat to Semantic Versioning 2.0.0-rc.2 spec.
Enter next version: 1.3.2-alpha+001
(1, 3, 2, 'alpha', '001')
>>>
- menu input::
>>> menu(prompt = 'what next?', options = ['restart', 'continue', 'quit'])
what next?
1 - restart
2 - continue
3 - quit
select an item [range: 1..3]: 5
input must be in range 1..3.
select an item [range: 1..3]: 3
(2, 'quit')
- tickbox menu::
>>> tickbox_menu('add extras', ['mayo', 'ketchup', 'garlic', 'tabasco'])
add extras
1 [ ] - mayo
2 [ ] - ketchup
3 [ ] - garlic
4 [ ] - tabasco
- type a number to tick the option.
- "a" selects all.
- "n" de-selects all.
- "d" selection done.
Option? : 1
add extras
1 [x] - mayo
2 [ ] - ketchup
3 [ ] - garlic
4 [ ] - tabasco
- type a number to tick the option.
- "a" selects all.
- "n" de-selects all.
- "d" selection done.
Option? : 4
add extras
1 [x] - mayo
2 [ ] - ketchup
3 [ ] - garlic
4 [x] - tabasco
- type a number to tick the option.
- "a" selects all.
- "n" de-selects all.
- "d" selection done.
Option? : d
[(0, 'mayo'), (3, 'tabasco')]
- string enumeration input::
>>> enum_str(prompt = 'how do you want it?', enum = ['fried', 'poached', 'scrambled'])
how do you want it? (valid choices: fried, poached, scrambled):
raw
input must be one of fried, poached, scrambled.
how do you want it? (valid choices: fried, poached, scrambled):
poached
'poached'
- ranged integer input::
>>> ranged_int(prompt = 'how old are you?', low = 1, high = 150)
how old are you? [range: 1..150]: -3
input must be in range 1..150.
how old are you? [range: 1..150]: 151
input must be in range 1..150.
how old are you? [range: 1..150]: 35
35
"""
from __future__ import annotations
from typing import Tuple, TypeVar, Sequence, Callable, List, Type, Any
import os
import re
# Semantic Versioning regular expression as recommended in https://semver.org
_semver_re: re.Pattern = re.compile(r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$')
T = TypeVar("T")
R = TypeVar("R")
def _identity(x: T) -> Any:
return x
def _always_good(x: T) -> bool:
return True
[docs]def yes_no(prompt: str, default: str | None = None, retries: int = -1) -> bool:
"""gets validated input from user for a yes/no question.
Example::
>>> from puissant import *
>>> yes_no('do you want desert')
do you want desert [y/n]?maybe
input must be one of y, n, yes, no.
do you want desert [y/n]?y
True
>>> yes_no('are you ok', default = 'n')
are you ok [y/n]?(default:n)
False
Arguments:
prompt: user input prompt.
default: default input value if user enters newline only.
retries: allowed number of extra attempts for user input (-1 = unlimited)
Returns:
True (affirmative answer) or False (negative answer)
Raises:
ValueError: if invalid input is entered for ``retries + 1``
"""
return vinput(
prompt + " [y/n]?",
typ=str,
default=default,
retries=retries,
enum=["y", "n", "yes", "no"],
pre_fn=str.lower,
post_fn=lambda x: x in ["y", "yes"],
)
def _rint_estr_in(prompt: str, low: int, high: int, enum: List[str], retries=-1) -> str:
"""get user input from a range of ints and a list of strings.
Internal function, not meant for API (to be used inside menu_tickbox function).
"""
extended_enum = enum + list([str(i) for i in range(low, high + 1)])
return enum_str(prompt, extended_enum, quiet=True, retries=retries)
[docs]def enum_str(
prompt: str,
enum: List[str],
default: str | None = None,
quiet: bool = False,
retries=-1,
) -> str:
valid_choices = ", ".join(enum)
if quiet:
fprompt = prompt + ": "
else:
fprompt = f"{prompt} (valid choices: {valid_choices}):\n"
# if len(valid_choices) + len(prompt) > (os.get_terminal_size()[0] - 18):
# fprompt = f'{prompt}\n(valid choices: {valid_choices}):\n'
# else:
# fprompt = f'{prompt} (valid choices: {valid_choices}):\n'
return vinput(fprompt, typ=str, default=default, retries=retries, enum=enum)
[docs]def ranged_int(
prompt: str, low: int, high: int, default: int | None = None, retries: int = -1
) -> int:
"""gets ranged integer validated user input.
Example::
>>> ranged_int(prompt = 'roll a dice...', low = 1, high = 6)
roll a dice... [range: 1..6]: 0
input must be in range 1..6.
roll a dice... [range: 1..6]: 1
1
Arguments:
prompt: user input prompt.
low: minimum allowed integer input.
high: maximum allowed integer input.
default: default result if user only presses Return.
retries: allowed number of attempts for user input.
Returns:
The integer user input, if it is deemed valid.
Raises:
ValueError: after ``retries + 1`` invalid user input attemtpts.
"""
return vinput(
prompt + f" [range: {low}..{high}]: ",
typ=int,
default=default,
retries=retries,
nrange=(low, high),
)
[docs]def ranged_float(
prompt: str, low: float, high: float, default: float | None = None, retries=-1
) -> float:
"""gets ranged float validated user input.
Example::
>>> ranged_float('how tall are you in meters?', 0.5, 2.75)
how tall are you in meters? [range: 0.5..2.75]: 0.3
input must be in range 0.5..2.75.
how tall are you in meters? [range: 0.5..2.75]: 3.0
input must be in range 0.5..2.75.
how tall are you in meters? [range: 0.5..2.75]: 1.75
1.75
Arguments:
prompt: user input prompt.
low: minimum allowed floating point input.
high: maximum allowed floating point input.
default: default result if user only presses Return.
retries: allowed number of extra attempts for user input.
Returns:
The floating point user input, if it is deemed valid.
Raises:
ValueError: after ``retries + 1`` invalid user input attemtpts.
"""
return vinput(
prompt + f" [range: {low}..{high}]: ",
typ=float,
default=default,
retries=retries,
nrange=(low, high),
)
def _semver_preproc(svin: str) -> re.MAtch:
" pre-processing for validated semantic version input - matches input string with semver.org recommended regexp."
return _semver_re.match(svin)
def _semver_validation(m: re.Match) -> bool:
" validation function for validated semantic version input: OK if semver.org regexp matched input string."
return m != None
def _semver_postproc(m: re.Match) -> Tuple[int, int, int, str | None, str | None]:
"""post-processing for validated semantic version input - takes semver.org regexp match and returns a tuple:
(major, minor, patch, release, build)
"""
sv = m.groups()
return (int(sv[0]), int(sv[1]), int(sv[2]), sv[3], sv[4])
[docs]def semantic_version(prompt: str, retries=-1) -> Tuple[int, int, int, str | None, str | None]:
""" gets Semantic Version 2.0.0-rc.2 validated input.
Example::
>>> semantic_version('Enter next version: ')
Enter next version: a1.3.2
Not conformat to Semantic Versioning 2.0.0-rc.2 spec.
Enter next version: 1.3.2-alpha+001
(1, 3, 2, 'alpha', '001')
>>>
Arguments:
prompt: user input prompt
retries: allowed number of extra attempts for user input
Returns:
A tuple ``(major, minor, patch, release, build)`` with version data.
Raises:
ValueError: after ``retries + 1`` invalid user input attemtpts.
"""
return vinput(prompt = prompt,
typ = str,
pre_fn = _semver_preproc,
validation_fn = _semver_validation,
post_fn = _semver_postproc,
type_err_msg = "Not conformat to Semantic Versioning 2.0.0-rc.2 spec.")