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.
Files changed (42) hide show
  1. simplibs/cli/__init__.py +87 -0
  2. simplibs/cli/interaction/__init__.py +21 -0
  3. simplibs/cli/interaction/_helpers/__init__.py +15 -0
  4. simplibs/cli/interaction/_helpers/is_terminal.py +34 -0
  5. simplibs/cli/interaction/_validations/__init__.py +15 -0
  6. simplibs/cli/interaction/_validations/raise_not_interactive_error.py +42 -0
  7. simplibs/cli/interaction/is_interactive.py +41 -0
  8. simplibs/cli/interaction/require_interactive.py +41 -0
  9. simplibs/cli/prompts/__init__.py +43 -0
  10. simplibs/cli/prompts/_helpers/__init__.py +27 -0
  11. simplibs/cli/prompts/_helpers/build_rejection_message.py +43 -0
  12. simplibs/cli/prompts/_helpers/describe_range.py +36 -0
  13. simplibs/cli/prompts/_helpers/format_default_hint.py +33 -0
  14. simplibs/cli/prompts/_helpers/parse_keys.py +30 -0
  15. simplibs/cli/prompts/_helpers/print_problem.py +23 -0
  16. simplibs/cli/prompts/_helpers/read_answer.py +44 -0
  17. simplibs/cli/prompts/_helpers/unquote_answer.py +32 -0
  18. simplibs/cli/prompts/_validations/__init__.py +23 -0
  19. simplibs/cli/prompts/_validations/raise_create_needs_kind_error.py +31 -0
  20. simplibs/cli/prompts/_validations/raise_default_not_in_options_error.py +38 -0
  21. simplibs/cli/prompts/_validations/raise_no_answer_available_error.py +41 -0
  22. simplibs/cli/prompts/_validations/raise_options_empty_error.py +28 -0
  23. simplibs/cli/prompts/_validations/raise_range_empty_error.py +41 -0
  24. simplibs/cli/prompts/prompt_choice.py +87 -0
  25. simplibs/cli/prompts/prompt_confirm.py +73 -0
  26. simplibs/cli/prompts/prompt_int.py +99 -0
  27. simplibs/cli/prompts/prompt_multi_choice.py +107 -0
  28. simplibs/cli/prompts/prompt_path.py +153 -0
  29. simplibs/cli/prompts/prompt_secret.py +71 -0
  30. simplibs/cli/prompts/prompt_text.py +90 -0
  31. simplibs/cli/prompts/resolve_or_prompt.py +74 -0
  32. simplibs/cli/py.typed +1 -0
  33. simplibs/cli/reporting/__init__.py +18 -0
  34. simplibs/cli/reporting/print_choices.py +58 -0
  35. simplibs/cli/reporting/report_failures.py +64 -0
  36. simplibs/cli/verbosity/__init__.py +16 -0
  37. simplibs/cli/verbosity/resolve_verbosity.py +76 -0
  38. simplibs_cli-0.1.0.dist-info/METADATA +436 -0
  39. simplibs_cli-0.1.0.dist-info/RECORD +42 -0
  40. simplibs_cli-0.1.0.dist-info/WHEEL +5 -0
  41. simplibs_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  42. 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
+ """