simplibs-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simplibs/cli/__init__.py +87 -0
- simplibs/cli/interaction/__init__.py +21 -0
- simplibs/cli/interaction/_helpers/__init__.py +15 -0
- simplibs/cli/interaction/_helpers/is_terminal.py +34 -0
- simplibs/cli/interaction/_validations/__init__.py +15 -0
- simplibs/cli/interaction/_validations/raise_not_interactive_error.py +42 -0
- simplibs/cli/interaction/is_interactive.py +41 -0
- simplibs/cli/interaction/require_interactive.py +41 -0
- simplibs/cli/prompts/__init__.py +43 -0
- simplibs/cli/prompts/_helpers/__init__.py +27 -0
- simplibs/cli/prompts/_helpers/build_rejection_message.py +43 -0
- simplibs/cli/prompts/_helpers/describe_range.py +36 -0
- simplibs/cli/prompts/_helpers/format_default_hint.py +33 -0
- simplibs/cli/prompts/_helpers/parse_keys.py +30 -0
- simplibs/cli/prompts/_helpers/print_problem.py +23 -0
- simplibs/cli/prompts/_helpers/read_answer.py +44 -0
- simplibs/cli/prompts/_helpers/unquote_answer.py +32 -0
- simplibs/cli/prompts/_validations/__init__.py +23 -0
- simplibs/cli/prompts/_validations/raise_create_needs_kind_error.py +31 -0
- simplibs/cli/prompts/_validations/raise_default_not_in_options_error.py +38 -0
- simplibs/cli/prompts/_validations/raise_no_answer_available_error.py +41 -0
- simplibs/cli/prompts/_validations/raise_options_empty_error.py +28 -0
- simplibs/cli/prompts/_validations/raise_range_empty_error.py +41 -0
- simplibs/cli/prompts/prompt_choice.py +87 -0
- simplibs/cli/prompts/prompt_confirm.py +73 -0
- simplibs/cli/prompts/prompt_int.py +99 -0
- simplibs/cli/prompts/prompt_multi_choice.py +107 -0
- simplibs/cli/prompts/prompt_path.py +153 -0
- simplibs/cli/prompts/prompt_secret.py +71 -0
- simplibs/cli/prompts/prompt_text.py +90 -0
- simplibs/cli/prompts/resolve_or_prompt.py +74 -0
- simplibs/cli/py.typed +1 -0
- simplibs/cli/reporting/__init__.py +18 -0
- simplibs/cli/reporting/print_choices.py +58 -0
- simplibs/cli/reporting/report_failures.py +64 -0
- simplibs/cli/verbosity/__init__.py +16 -0
- simplibs/cli/verbosity/resolve_verbosity.py +76 -0
- simplibs_cli-0.1.0.dist-info/METADATA +436 -0
- simplibs_cli-0.1.0.dist-info/RECORD +42 -0
- simplibs_cli-0.1.0.dist-info/WHEEL +5 -0
- simplibs_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- simplibs_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import NoReturn
|
|
2
|
+
from simplibs.exception import ParamError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def raise_create_needs_kind_error() -> NoReturn:
|
|
6
|
+
"""Raise ParamError when creation is requested without a kind."""
|
|
7
|
+
|
|
8
|
+
# 1. Raise structured ParamError wrapping a ValueError
|
|
9
|
+
raise ParamError(
|
|
10
|
+
error_name="CLI_CREATE_NEEDS_KIND",
|
|
11
|
+
label="create_if_missing",
|
|
12
|
+
value=True,
|
|
13
|
+
problem="A missing path cannot be created without knowing whether it is a file or a directory.",
|
|
14
|
+
expected="kind='file' or kind='dir' together with create_if_missing=True.",
|
|
15
|
+
how_to_fix=(
|
|
16
|
+
"Pass kind='file' or kind='dir'.",
|
|
17
|
+
"Drop create_if_missing and accept any path.",
|
|
18
|
+
),
|
|
19
|
+
exception=ValueError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_DESIGN_NOTES = """
|
|
24
|
+
# raise_create_needs_kind_error — Create What?
|
|
25
|
+
|
|
26
|
+
## Purpose
|
|
27
|
+
`kind="any"` accepts both a file and a directory, which is exactly what
|
|
28
|
+
creation cannot do. Guessing one of them (silently, for everybody who forgot
|
|
29
|
+
the argument) would create the wrong thing, so the choice is the caller's and
|
|
30
|
+
the missing one is a `ParamError`.
|
|
31
|
+
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Any, NoReturn
|
|
2
|
+
from simplibs.exception import ParamError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def raise_default_not_in_options_error(
|
|
6
|
+
default: Any,
|
|
7
|
+
options: dict[str, str],
|
|
8
|
+
) -> NoReturn:
|
|
9
|
+
"""Raise ParamError when a default is not one of the options.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
default: The rejected default (a key, or a tuple of keys).
|
|
13
|
+
options: The options it was supposed to belong to.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# 1. Raise structured ParamError wrapping a ValueError
|
|
17
|
+
raise ParamError(
|
|
18
|
+
error_name="CLI_DEFAULT_NOT_IN_OPTIONS",
|
|
19
|
+
label="default",
|
|
20
|
+
value=default,
|
|
21
|
+
problem="The default is not one of the options.",
|
|
22
|
+
expected=f"Only keys from the options: {', '.join(options)}.",
|
|
23
|
+
how_to_fix=(
|
|
24
|
+
"Use a key that exists in the options as the default.",
|
|
25
|
+
"Add the default to the options.",
|
|
26
|
+
),
|
|
27
|
+
exception=ValueError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_DESIGN_NOTES = """
|
|
32
|
+
# raise_default_not_in_options_error — A Default Nobody Could Type
|
|
33
|
+
|
|
34
|
+
## Purpose
|
|
35
|
+
Enter would return a key that the prompt itself would refuse. The promise of
|
|
36
|
+
`prompt_choice` is that the result is always an option, so the contradiction
|
|
37
|
+
is reported to the programmer instead of passed to the user.
|
|
38
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import NoReturn
|
|
2
|
+
from simplibs.exception import StateError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def raise_no_answer_available_error(
|
|
6
|
+
prompt: str,
|
|
7
|
+
caught_exc: EOFError,
|
|
8
|
+
) -> NoReturn:
|
|
9
|
+
"""Raise StateError when the input ended before the user answered.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
prompt: The question that could not be asked.
|
|
13
|
+
caught_exc: The `EOFError` raised by `input`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# 1. Raise structured StateError wrapping the caught EOFError instance
|
|
17
|
+
raise StateError(
|
|
18
|
+
error_name="CLI_NO_ANSWER_AVAILABLE",
|
|
19
|
+
label="prompt",
|
|
20
|
+
value=prompt.strip(),
|
|
21
|
+
problem=(
|
|
22
|
+
"The input ended before an answer was given.",
|
|
23
|
+
"Nobody is there to answer — the program probably runs without a terminal.",
|
|
24
|
+
),
|
|
25
|
+
expected="An interactive terminal, or the answer given in advance.",
|
|
26
|
+
how_to_fix=(
|
|
27
|
+
"Pass the value as a command-line option.",
|
|
28
|
+
"Run the program in a terminal.",
|
|
29
|
+
"Check with require_interactive() before asking.",
|
|
30
|
+
),
|
|
31
|
+
exception=caught_exc,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_DESIGN_NOTES = """
|
|
36
|
+
# raise_no_answer_available_error — The Input Is Gone
|
|
37
|
+
|
|
38
|
+
## Purpose
|
|
39
|
+
`input()` raises a bare `EOFError` with the message "EOF when reading a line",
|
|
40
|
+
which says neither what was being asked nor what to do. The card says both.
|
|
41
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import NoReturn
|
|
2
|
+
from simplibs.exception import ParamError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def raise_options_empty_error() -> NoReturn:
|
|
6
|
+
"""Raise ParamError when a choice prompt has no options."""
|
|
7
|
+
|
|
8
|
+
# 1. Raise structured ParamError wrapping a ValueError
|
|
9
|
+
raise ParamError(
|
|
10
|
+
error_name="CLI_OPTIONS_EMPTY",
|
|
11
|
+
label="options",
|
|
12
|
+
problem="There is nothing to choose from.",
|
|
13
|
+
expected="A mapping with at least one key -> description pair.",
|
|
14
|
+
how_to_fix=(
|
|
15
|
+
"Pass at least one option.",
|
|
16
|
+
"Skip the question when no option is available.",
|
|
17
|
+
),
|
|
18
|
+
exception=ValueError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_DESIGN_NOTES = """
|
|
23
|
+
# raise_options_empty_error — A Question With No Answer
|
|
24
|
+
|
|
25
|
+
## Purpose
|
|
26
|
+
With no options no key can ever match, so the prompt would ask forever.
|
|
27
|
+
Raised before the first question.
|
|
28
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import NoReturn
|
|
2
|
+
from simplibs.exception import ParamError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def raise_range_empty_error(
|
|
6
|
+
minimum: int,
|
|
7
|
+
maximum: int,
|
|
8
|
+
) -> NoReturn:
|
|
9
|
+
"""Raise ParamError when no number can satisfy both limits.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
minimum: The lower limit.
|
|
13
|
+
maximum: The upper limit, smaller than `minimum`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# 1. Raise structured ParamError wrapping a ValueError
|
|
17
|
+
raise ParamError(
|
|
18
|
+
error_name="CLI_RANGE_EMPTY",
|
|
19
|
+
label="minimum",
|
|
20
|
+
value=minimum,
|
|
21
|
+
problem=(
|
|
22
|
+
f"The minimum {minimum} is greater than the maximum {maximum}.",
|
|
23
|
+
"No number can satisfy both limits.",
|
|
24
|
+
),
|
|
25
|
+
expected="A minimum that is less than or equal to the maximum.",
|
|
26
|
+
how_to_fix=(
|
|
27
|
+
"Swap the two limits if they were mixed up.",
|
|
28
|
+
"Drop one of the limits if it is not needed.",
|
|
29
|
+
),
|
|
30
|
+
exception=ValueError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_DESIGN_NOTES = """
|
|
35
|
+
# raise_range_empty_error — Limits That Exclude Everything
|
|
36
|
+
|
|
37
|
+
## Purpose
|
|
38
|
+
With `minimum > maximum` every answer would be refused and the question
|
|
39
|
+
repeated forever. Raised before the first question, like the other wrong
|
|
40
|
+
calls.
|
|
41
|
+
"""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from simplibs.validate import validate_call
|
|
2
|
+
# Outers
|
|
3
|
+
from ..reporting import print_choices
|
|
4
|
+
# Inners
|
|
5
|
+
from ._helpers import format_default_hint, print_problem, read_answer
|
|
6
|
+
from ._validations import raise_default_not_in_options_error, raise_options_empty_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def prompt_choice(
|
|
11
|
+
message: str,
|
|
12
|
+
options: dict[str, str],
|
|
13
|
+
*,
|
|
14
|
+
default: str | None = None,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Ask the user to pick one key from described options.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
message: The question to display.
|
|
20
|
+
options: A mapping of key -> human-readable description, printed as
|
|
21
|
+
an aligned list before the question.
|
|
22
|
+
default: The key returned when the user presses Enter with no input.
|
|
23
|
+
It must be one of `options`. None means an explicit answer is
|
|
24
|
+
required.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The chosen key — always a member of `options`.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ParamError: If `options` is empty or `default` is not one of its keys.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
fmt = prompt_choice("Format", {"json": "Machine-readable", "text": "For people"}, default="text")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# 1. A wrong call fails before the first question
|
|
37
|
+
if not options:
|
|
38
|
+
raise_options_empty_error()
|
|
39
|
+
if default is not None and default not in options:
|
|
40
|
+
raise_default_not_in_options_error(default, options)
|
|
41
|
+
|
|
42
|
+
# 2. Show every option once
|
|
43
|
+
print_choices(options)
|
|
44
|
+
|
|
45
|
+
# 3. The hint shown after the message
|
|
46
|
+
hint = format_default_hint(default)
|
|
47
|
+
|
|
48
|
+
# 4. Ask until a key is recognized
|
|
49
|
+
while True:
|
|
50
|
+
|
|
51
|
+
# 4.1 Read the answer
|
|
52
|
+
answer = read_answer(f"{message}{hint}: ")
|
|
53
|
+
|
|
54
|
+
# 4.2 Empty — the default, or asked again
|
|
55
|
+
if not answer:
|
|
56
|
+
if default is not None:
|
|
57
|
+
return default
|
|
58
|
+
print_problem("Please enter one of the options above.")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# 4.3 A known key
|
|
62
|
+
if answer in options:
|
|
63
|
+
return answer
|
|
64
|
+
|
|
65
|
+
# 4.4 An unknown key
|
|
66
|
+
print_problem(f"'{answer}' is not a valid option. Please try again.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_DESIGN_NOTES = """
|
|
70
|
+
# prompt_choice — One Key From a List
|
|
71
|
+
|
|
72
|
+
## Purpose
|
|
73
|
+
Asks, lists and returns a key that is certain to exist in `options`.
|
|
74
|
+
|
|
75
|
+
## The Promise Is Kept
|
|
76
|
+
The result is always a member of `options`. An empty `options` (nothing could
|
|
77
|
+
ever match) and a `default` that is not one of the keys (Enter would return a
|
|
78
|
+
key the prompt itself refuses) are wrong calls, refused with a `ParamError`
|
|
79
|
+
before the first question.
|
|
80
|
+
|
|
81
|
+
## Keys Are Matched Exactly
|
|
82
|
+
No case folding and no numbers: a key is what the program defined, and
|
|
83
|
+
guessing would let two keys such as `a` and `A` collide.
|
|
84
|
+
|
|
85
|
+
## Return Type
|
|
86
|
+
`str`, never `None`; a missing answer is asked again.
|
|
87
|
+
"""
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from simplibs.validate import validate_call
|
|
2
|
+
# Inners
|
|
3
|
+
from ._helpers import print_problem, read_answer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@validate_call
|
|
7
|
+
def prompt_confirm(
|
|
8
|
+
message: str,
|
|
9
|
+
*,
|
|
10
|
+
default: bool | None = None,
|
|
11
|
+
) -> bool:
|
|
12
|
+
"""Ask a yes/no question, asking again until the answer is understood.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
message: The question to display. A `[y/n]` hint is appended, with
|
|
16
|
+
the default in upper case.
|
|
17
|
+
default: Returned when the user presses Enter with no input. None
|
|
18
|
+
means that an explicit answer is required.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
True for `y` or `yes`, False for `n` or `no` (any letter case).
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
if prompt_confirm("Overwrite the file?", default=False):
|
|
25
|
+
...
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# 1. The hint reflects the default
|
|
29
|
+
if default is True:
|
|
30
|
+
hint = "[Y/n]"
|
|
31
|
+
elif default is False:
|
|
32
|
+
hint = "[y/N]"
|
|
33
|
+
else:
|
|
34
|
+
hint = "[y/n]"
|
|
35
|
+
|
|
36
|
+
# 2. Ask until the answer is understood
|
|
37
|
+
while True:
|
|
38
|
+
|
|
39
|
+
# 2.1 Read the answer in lower case
|
|
40
|
+
answer = read_answer(f"{message} {hint}: ").lower()
|
|
41
|
+
|
|
42
|
+
# 2.2 Empty — the default, or asked again
|
|
43
|
+
if not answer:
|
|
44
|
+
if default is not None:
|
|
45
|
+
return default
|
|
46
|
+
print_problem("Please answer 'y' or 'n'.")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# 2.3 Understood
|
|
50
|
+
if answer in ("y", "yes"):
|
|
51
|
+
return True
|
|
52
|
+
if answer in ("n", "no"):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
# 2.4 Not understood
|
|
56
|
+
print_problem("Please answer 'y' or 'n'.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DESIGN_NOTES = """
|
|
60
|
+
# prompt_confirm — Yes or No
|
|
61
|
+
|
|
62
|
+
## Purpose
|
|
63
|
+
The most common prompt. A destructive action should pass `default=False`, so
|
|
64
|
+
that a hurried Enter means "no".
|
|
65
|
+
|
|
66
|
+
## Return Type
|
|
67
|
+
`bool`, never `None`, which is what a type checker needs for
|
|
68
|
+
`if prompt_confirm(...)`. A missing answer is asked again, not returned.
|
|
69
|
+
|
|
70
|
+
## Only `y`, `yes`, `n`, `no`
|
|
71
|
+
English words, because the library's messages are English. Other languages
|
|
72
|
+
are not guessed at: a program that needs them can ask with `prompt_choice`.
|
|
73
|
+
"""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from simplibs.validate import validate_call
|
|
2
|
+
# Inners
|
|
3
|
+
from ._helpers import describe_range, format_default_hint, print_problem, read_answer
|
|
4
|
+
from ._validations import raise_range_empty_error
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@validate_call
|
|
8
|
+
def prompt_int(
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
default: int | None = None,
|
|
12
|
+
minimum: int | None = None,
|
|
13
|
+
maximum: int | None = None,
|
|
14
|
+
) -> int:
|
|
15
|
+
"""Ask the user for a whole number, asking again until it is acceptable.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
message: The question to display.
|
|
19
|
+
default: Returned when the user presses Enter with no input. It goes
|
|
20
|
+
through the range check like a typed answer.
|
|
21
|
+
minimum: The smallest accepted number. None means no lower limit.
|
|
22
|
+
maximum: The largest accepted number. None means no upper limit.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The number, within `minimum` and `maximum` when they are given.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ParamError: If `minimum` is greater than `maximum`.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
workers = prompt_int("Workers", default=4, minimum=1, maximum=64)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# 1. A range nobody can satisfy fails before the first question
|
|
35
|
+
if minimum is not None and maximum is not None and minimum > maximum:
|
|
36
|
+
raise_range_empty_error(minimum, maximum)
|
|
37
|
+
|
|
38
|
+
# 2. What is asked for, said once and reused for every refusal
|
|
39
|
+
range_text = describe_range(minimum, maximum)
|
|
40
|
+
expectation = f"Please enter a whole number {range_text}." if range_text else "Please enter a whole number."
|
|
41
|
+
|
|
42
|
+
# 3. The hint shown after the message
|
|
43
|
+
hint = format_default_hint(default)
|
|
44
|
+
|
|
45
|
+
# 4. Ask until a number is acceptable
|
|
46
|
+
while True:
|
|
47
|
+
|
|
48
|
+
# 4.1 Read the answer; an empty one falls back to the default
|
|
49
|
+
answer = read_answer(f"{message}{hint}: ")
|
|
50
|
+
if not answer:
|
|
51
|
+
if default is None:
|
|
52
|
+
print_problem(expectation)
|
|
53
|
+
continue
|
|
54
|
+
answer = str(default)
|
|
55
|
+
|
|
56
|
+
# 4.2 It must be a whole number
|
|
57
|
+
try:
|
|
58
|
+
number = int(answer)
|
|
59
|
+
except ValueError:
|
|
60
|
+
print_problem(expectation)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# 4.3 It must lie within the limits
|
|
64
|
+
if minimum is not None and number < minimum:
|
|
65
|
+
print_problem(expectation)
|
|
66
|
+
continue
|
|
67
|
+
if maximum is not None and number > maximum:
|
|
68
|
+
print_problem(expectation)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# 4.4 Accepted
|
|
72
|
+
return number
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_DESIGN_NOTES = """
|
|
76
|
+
# prompt_int — A Whole Number Within Limits
|
|
77
|
+
|
|
78
|
+
## Purpose
|
|
79
|
+
Counts, ports, sizes: the answers that `prompt_text` would return as a string
|
|
80
|
+
and the caller would then have to parse and range-check, usually forgetting
|
|
81
|
+
one of the two.
|
|
82
|
+
|
|
83
|
+
## Whole Numbers Only
|
|
84
|
+
`3.5` and `1e3` are refused. A program that needs decimals can use
|
|
85
|
+
`prompt_text` with a rule of its own, which keeps this function's promise
|
|
86
|
+
("an `int` within the limits") simple.
|
|
87
|
+
|
|
88
|
+
## Python's Own Reading of Numbers
|
|
89
|
+
`int()` also accepts `+5`, `-5`, ` 5 ` and `1_000`. They are numbers, so they
|
|
90
|
+
are accepted instead of being second-guessed by a stricter parser.
|
|
91
|
+
|
|
92
|
+
## The Default Goes Through the Check
|
|
93
|
+
Like in `prompt_text`: a default outside the limits repeats the question
|
|
94
|
+
instead of raising, and what the function returns is always in range.
|
|
95
|
+
|
|
96
|
+
## The Same Sentence for Every Refusal
|
|
97
|
+
Not a number, too small, too large: the user needs one thing, the sentence
|
|
98
|
+
that says what is accepted ("from 1 to 64").
|
|
99
|
+
"""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from simplibs.validate import validate_call
|
|
2
|
+
# Outers
|
|
3
|
+
from ..reporting import print_choices
|
|
4
|
+
# Inners
|
|
5
|
+
from ._helpers import format_default_hint, parse_keys, print_problem, read_answer
|
|
6
|
+
from ._validations import raise_default_not_in_options_error, raise_options_empty_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def prompt_multi_choice(
|
|
11
|
+
message: str,
|
|
12
|
+
options: dict[str, str],
|
|
13
|
+
*,
|
|
14
|
+
default: tuple[str, ...] = (),
|
|
15
|
+
allow_empty: bool = False,
|
|
16
|
+
) -> tuple[str, ...]:
|
|
17
|
+
"""Ask the user to pick one or more comma-separated keys from described options.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
message: The question to display.
|
|
21
|
+
options: A mapping of key -> human-readable description.
|
|
22
|
+
default: The keys returned when the user presses Enter with no input.
|
|
23
|
+
Each must be one of `options`.
|
|
24
|
+
allow_empty: If True, an empty answer with no default is accepted
|
|
25
|
+
and returns `()`. Meant for "pick any extras" questions.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The chosen keys in the order given, without repeats, each a
|
|
29
|
+
member of `options`.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ParamError: If `options` is empty or a key of `default` is not in it.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
extras = prompt_multi_choice("Extras", {"tests": "Test folder", "docs": "Docs folder"}, allow_empty=True)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# 1. A wrong call fails before the first question
|
|
39
|
+
if not options:
|
|
40
|
+
raise_options_empty_error()
|
|
41
|
+
if any(key not in options for key in default):
|
|
42
|
+
raise_default_not_in_options_error(default, options)
|
|
43
|
+
|
|
44
|
+
# 2. Show every option once
|
|
45
|
+
print_choices(options)
|
|
46
|
+
|
|
47
|
+
# 3. The hint shown after the message
|
|
48
|
+
hint = format_default_hint(",".join(default))
|
|
49
|
+
|
|
50
|
+
# 4. Ask until every key is recognized
|
|
51
|
+
while True:
|
|
52
|
+
|
|
53
|
+
# 4.1 Read the answer
|
|
54
|
+
answer = read_answer(f"{message}{hint}: ")
|
|
55
|
+
|
|
56
|
+
# 4.2 Empty — the default, nothing, or asked again
|
|
57
|
+
if not answer:
|
|
58
|
+
if default:
|
|
59
|
+
return default
|
|
60
|
+
if allow_empty:
|
|
61
|
+
return ()
|
|
62
|
+
print_problem("Please enter at least one option.")
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# 4.3 Split into keys; an answer like "," holds none
|
|
66
|
+
keys = parse_keys(answer)
|
|
67
|
+
if not keys:
|
|
68
|
+
print_problem("Please enter at least one option.")
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# 4.4 Every key must exist
|
|
72
|
+
unknown = [key for key in keys if key not in options]
|
|
73
|
+
if unknown:
|
|
74
|
+
print_problem(f"Unknown option(s): {', '.join(unknown)}. Please try again.")
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# 4.5 Accepted
|
|
78
|
+
return keys
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_DESIGN_NOTES = """
|
|
82
|
+
# prompt_multi_choice — Several Keys From a List
|
|
83
|
+
|
|
84
|
+
## Purpose
|
|
85
|
+
The plural of `prompt_choice`: `tests,docs` answers "which of these".
|
|
86
|
+
|
|
87
|
+
## The Default Is Checked Up Front
|
|
88
|
+
Like in `prompt_choice`, every key of `default` must be one of `options`. A
|
|
89
|
+
wrong call fails with a `ParamError` before the first question, and a valid
|
|
90
|
+
default is returned as it is, not typed back through the parser.
|
|
91
|
+
|
|
92
|
+
## Repeats Collapse
|
|
93
|
+
`a,a` returns `("a",)`: a key counted twice is a typo, not a wish.
|
|
94
|
+
|
|
95
|
+
## `allow_empty`
|
|
96
|
+
Makes "none of them" a valid answer for questions that have no sensible
|
|
97
|
+
default ("pick any extras"). It applies only when there is no default: a
|
|
98
|
+
default always wins over emptiness.
|
|
99
|
+
|
|
100
|
+
## Always a Tuple
|
|
101
|
+
The return type is `tuple[str, ...]` and never `None`; "nothing chosen" is the
|
|
102
|
+
empty tuple.
|
|
103
|
+
|
|
104
|
+
## Order Is Kept
|
|
105
|
+
The keys come back in the order typed, so a caller may treat the order as the
|
|
106
|
+
user's priority.
|
|
107
|
+
"""
|