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,153 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from simplibs.exception import AccessError, StateError
|
|
4
|
+
from simplibs.path import PathLike, PathPredicate, ensure_dir, ensure_file, normalize_path
|
|
5
|
+
from simplibs.validate import validate_call
|
|
6
|
+
# Inners
|
|
7
|
+
from ._helpers import (
|
|
8
|
+
build_rejection_message,
|
|
9
|
+
format_default_hint,
|
|
10
|
+
print_problem,
|
|
11
|
+
read_answer,
|
|
12
|
+
unquote_answer,
|
|
13
|
+
)
|
|
14
|
+
from ._validations import raise_create_needs_kind_error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@validate_call
|
|
18
|
+
def prompt_path(
|
|
19
|
+
message: str,
|
|
20
|
+
*,
|
|
21
|
+
kind: Literal["file", "dir", "any"] = "any",
|
|
22
|
+
must_exist: bool = False,
|
|
23
|
+
create_if_missing: bool = False,
|
|
24
|
+
default: PathLike | None = None,
|
|
25
|
+
rule: PathPredicate | None = None,
|
|
26
|
+
invalid_message: str | None = None,
|
|
27
|
+
) -> Path:
|
|
28
|
+
"""Ask the user for a path, asking again until it is acceptable.
|
|
29
|
+
|
|
30
|
+
The answer is normalized (`~` expanded, made absolute) and surrounding
|
|
31
|
+
quotes, as pasted by a file manager, are removed.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
message: The question to display.
|
|
35
|
+
kind: `"file"` or `"dir"` makes an existing path of the other kind
|
|
36
|
+
unacceptable. `"any"` accepts both.
|
|
37
|
+
must_exist: If True, a missing path is refused.
|
|
38
|
+
create_if_missing: If True, a missing path is created — as a file
|
|
39
|
+
for `kind="file"`, as a directory for `kind="dir"`. It takes
|
|
40
|
+
precedence over `must_exist`.
|
|
41
|
+
default: Used when the user presses Enter with no input. It goes
|
|
42
|
+
through every check like a typed answer.
|
|
43
|
+
rule: A `Rule` from `simplibs.path.rules` (or any `(Path) -> bool`
|
|
44
|
+
callable) that the final path must satisfy.
|
|
45
|
+
invalid_message: What to say when `rule` refuses a path. Without it,
|
|
46
|
+
`rule.describe()` is used when `rule` is a `Rule`.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The normalized path.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ParamError: If `create_if_missing` is combined with `kind="any"`.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
source = prompt_path("Source file", kind="file", must_exist=True)
|
|
56
|
+
target = prompt_path("Output folder", kind="dir", create_if_missing=True)
|
|
57
|
+
notes = prompt_path("Notes", rule=HasSuffix(".md") & IsFile())
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# 1. Creating needs to know what to create
|
|
61
|
+
if create_if_missing and kind == "any":
|
|
62
|
+
raise_create_needs_kind_error()
|
|
63
|
+
|
|
64
|
+
# 2. The hint shown after the message
|
|
65
|
+
hint = format_default_hint(default)
|
|
66
|
+
|
|
67
|
+
# 3. Ask until a path is acceptable
|
|
68
|
+
while True:
|
|
69
|
+
|
|
70
|
+
# 3.1 Read the answer; an empty one falls back to the default
|
|
71
|
+
answer = unquote_answer(read_answer(f"{message}{hint}: "))
|
|
72
|
+
if not answer:
|
|
73
|
+
if default is None:
|
|
74
|
+
print_problem("Please enter a path.")
|
|
75
|
+
continue
|
|
76
|
+
answer = str(default)
|
|
77
|
+
|
|
78
|
+
# 3.2 Normalize; the path does not have to exist
|
|
79
|
+
try:
|
|
80
|
+
path = normalize_path(answer)
|
|
81
|
+
except AccessError:
|
|
82
|
+
print_problem(f"'{answer}' cannot be used as a path.")
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# 3.3 An existing path must be of the requested kind
|
|
86
|
+
if path.exists():
|
|
87
|
+
if kind == "file" and not path.is_file():
|
|
88
|
+
print_problem(f"Not a file: {path}")
|
|
89
|
+
continue
|
|
90
|
+
if kind == "dir" and not path.is_dir():
|
|
91
|
+
print_problem(f"Not a directory: {path}")
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# 3.4 A missing path is created, refused, or let through
|
|
95
|
+
elif create_if_missing:
|
|
96
|
+
try:
|
|
97
|
+
path = ensure_file(path) if kind == "file" else ensure_dir(path)
|
|
98
|
+
except (StateError, AccessError):
|
|
99
|
+
print_problem(f"Cannot create '{path}'. Is the location writable?")
|
|
100
|
+
continue
|
|
101
|
+
elif must_exist:
|
|
102
|
+
print_problem(f"Path does not exist: {path}")
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# 3.5 The rule judges the final path
|
|
106
|
+
if rule is not None and not rule(path):
|
|
107
|
+
print_problem(build_rejection_message(rule, invalid_message))
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# 3.6 Accepted
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_DESIGN_NOTES = """
|
|
115
|
+
# prompt_path — A Path That Is Safe to Use
|
|
116
|
+
|
|
117
|
+
## Purpose
|
|
118
|
+
Asks for a path and returns one that has passed every check the caller named.
|
|
119
|
+
|
|
120
|
+
## `kind` Is Checked on Existing Paths
|
|
121
|
+
`kind` is `"file"`, `"dir"` or `"any"`. It decides what is created and it also
|
|
122
|
+
judges a path that already exists, so a folder is never accepted where a file
|
|
123
|
+
was asked for.
|
|
124
|
+
|
|
125
|
+
## `rule` Covers the Rest
|
|
126
|
+
`kind` and `must_exist` cover the common cases; everything else is a rule from
|
|
127
|
+
`simplibs.path.rules` (`HasSuffix(".md") & IsPathWritable()`), the same objects
|
|
128
|
+
that filter scans.
|
|
129
|
+
|
|
130
|
+
## Failures Re-ask
|
|
131
|
+
A path that cannot be resolved (`AccessError`) and a creation that fails
|
|
132
|
+
(`StateError` or `AccessError` from `ensure_file` / `ensure_dir`) are reported
|
|
133
|
+
in one line and the question is asked again. Nothing ends the program in the
|
|
134
|
+
middle of a question.
|
|
135
|
+
|
|
136
|
+
## Quotes Are Removed
|
|
137
|
+
A path dragged into a Windows console arrives as
|
|
138
|
+
`"C:\\My Files\\a.txt"`, quotes included, so one surrounding pair is removed
|
|
139
|
+
before the path is normalized.
|
|
140
|
+
|
|
141
|
+
## `create_if_missing` and `rule`
|
|
142
|
+
The rule runs last, on the final path. A rule that refuses a path that was
|
|
143
|
+
created a moment ago leaves it on the disk, so rules for a creating prompt
|
|
144
|
+
should judge the text of the path (suffix, name, location), not its content.
|
|
145
|
+
|
|
146
|
+
## `create_if_missing` Needs a Kind
|
|
147
|
+
Creating has to know what to create, so `create_if_missing=True` with
|
|
148
|
+
`kind="any"` is a `ParamError` before the first question.
|
|
149
|
+
|
|
150
|
+
## A Default Is Not Special
|
|
151
|
+
It is checked like an answer. A default the checks refuse repeats the question
|
|
152
|
+
instead of raising.
|
|
153
|
+
"""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
# Inners
|
|
4
|
+
from ._helpers import print_problem
|
|
5
|
+
from ._validations import raise_no_answer_available_error
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@validate_call
|
|
9
|
+
def prompt_secret(
|
|
10
|
+
message: str,
|
|
11
|
+
*,
|
|
12
|
+
allow_empty: bool = False,
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Ask the user for a secret (a password, a token) without showing it.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
message: The question to display.
|
|
18
|
+
allow_empty: If True, an empty answer is accepted and returned as `""`.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The answer exactly as typed. Surrounding whitespace is kept, because
|
|
22
|
+
it can be part of a secret.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
StateError: If the input ended before an answer was given.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
token = prompt_secret("API token")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# 1. Ask until an answer is acceptable
|
|
32
|
+
while True:
|
|
33
|
+
|
|
34
|
+
# 1.1 Read without echo; a dead input becomes a card
|
|
35
|
+
try:
|
|
36
|
+
answer = getpass.getpass(f"{message}: ")
|
|
37
|
+
except EOFError as caught_exc:
|
|
38
|
+
raise_no_answer_available_error(message, caught_exc)
|
|
39
|
+
|
|
40
|
+
# 1.2 Empty — allowed, or asked again
|
|
41
|
+
if not answer and not allow_empty:
|
|
42
|
+
print_problem("Please enter a value.")
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# 1.3 Accepted
|
|
46
|
+
return answer
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_DESIGN_NOTES = """
|
|
50
|
+
# prompt_secret — Typing That Is Not Shown
|
|
51
|
+
|
|
52
|
+
## Purpose
|
|
53
|
+
A token typed with `prompt_text` stays on the screen and in the scrollback.
|
|
54
|
+
`getpass` hides it, and this prompt adds the repeat-until-given loop the other
|
|
55
|
+
prompts have.
|
|
56
|
+
|
|
57
|
+
## Its Own Read
|
|
58
|
+
The only prompt that does not read through `read_answer`: that helper strips
|
|
59
|
+
whitespace and uses `input`, which echoes. The end-of-input card is shared.
|
|
60
|
+
|
|
61
|
+
## No Default, No Rule, No Hint
|
|
62
|
+
A default would have to be printed to be useful, which defeats the purpose.
|
|
63
|
+
A secret is checked by whatever uses it, and a rule that said "too short"
|
|
64
|
+
would also tell a bystander something about it.
|
|
65
|
+
|
|
66
|
+
## Not on a Terminal
|
|
67
|
+
`getpass` reads from the terminal device when it can and falls back to
|
|
68
|
+
standard input with a warning when it cannot. Piped answers therefore work,
|
|
69
|
+
only unhidden. A program that must never read a secret from a pipe checks
|
|
70
|
+
`is_interactive()` first.
|
|
71
|
+
"""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
# Inners
|
|
4
|
+
from ._helpers import (
|
|
5
|
+
build_rejection_message,
|
|
6
|
+
format_default_hint,
|
|
7
|
+
print_problem,
|
|
8
|
+
read_answer,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@validate_call
|
|
13
|
+
def prompt_text(
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
default: str | None = None,
|
|
17
|
+
allow_empty: bool = False,
|
|
18
|
+
rule: Callable[[str], bool] | None = None,
|
|
19
|
+
invalid_message: str | None = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Ask the user for a line of text, asking again until it is acceptable.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
message: The question to display.
|
|
25
|
+
default: Returned when the user presses Enter with no input. It goes
|
|
26
|
+
through `rule` like any other answer. None means "no default".
|
|
27
|
+
allow_empty: If True, an empty answer (with no default) is accepted
|
|
28
|
+
and returned as `""`. It is not passed to `rule`.
|
|
29
|
+
rule: A `Rule` or any `(str) -> bool` callable that an answer must
|
|
30
|
+
satisfy.
|
|
31
|
+
invalid_message: What to say when `rule` refuses an answer. Without
|
|
32
|
+
it, the text of `rule.describe()` is used when `rule` is a `Rule`.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The answer, stripped of surrounding whitespace.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
name = prompt_text("Project name", rule=IsValidFilename())
|
|
39
|
+
tag = prompt_text("Tag", default="latest")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# 1. The hint shown after the message
|
|
43
|
+
hint = format_default_hint(default)
|
|
44
|
+
|
|
45
|
+
# 2. Ask until an answer is acceptable
|
|
46
|
+
while True:
|
|
47
|
+
|
|
48
|
+
# 2.1 Read the answer; an empty one falls back to the default
|
|
49
|
+
answer = read_answer(f"{message}{hint}: ")
|
|
50
|
+
if not answer and default is not None:
|
|
51
|
+
answer = default
|
|
52
|
+
|
|
53
|
+
# 2.2 Still empty — allowed, or asked again
|
|
54
|
+
if not answer:
|
|
55
|
+
if allow_empty:
|
|
56
|
+
return ""
|
|
57
|
+
print_problem("Please enter a value.")
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# 2.3 The rule judges the answer
|
|
61
|
+
if rule is not None and not rule(answer):
|
|
62
|
+
print_problem(build_rejection_message(rule, invalid_message))
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# 2.4 Accepted
|
|
66
|
+
return answer
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_DESIGN_NOTES = """
|
|
70
|
+
# prompt_text — The Plain Question
|
|
71
|
+
|
|
72
|
+
## Purpose
|
|
73
|
+
The prompt for free text. A name, a tag or a comment is asked here, so
|
|
74
|
+
`prompt_path` and every program that asks for a name need no loop of their own.
|
|
75
|
+
|
|
76
|
+
## The Default Goes Through the Rule
|
|
77
|
+
An answer and a default are one value as far as the rule is concerned, so what
|
|
78
|
+
the function returns always satisfies `rule`. A default the rule refuses is a
|
|
79
|
+
programmer's mistake that shows up as a repeated question, not as a card; the
|
|
80
|
+
alternative, checking the default up front, would run the rule once more on
|
|
81
|
+
every call.
|
|
82
|
+
|
|
83
|
+
## `allow_empty`
|
|
84
|
+
For optional answers ("Comment (optional)"). It applies only when there is no
|
|
85
|
+
default: a default always wins over emptiness.
|
|
86
|
+
|
|
87
|
+
## Rules Are Not Cleaned Up
|
|
88
|
+
A `Rule` or a callable is called as it is, and anything it raises goes up.
|
|
89
|
+
String rules from `simplibs-rules` answer True or False.
|
|
90
|
+
"""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import Callable, TypeVar
|
|
2
|
+
# Inners
|
|
3
|
+
from ._helpers import print_problem
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_or_prompt(
|
|
10
|
+
value: T | None,
|
|
11
|
+
*,
|
|
12
|
+
is_valid: Callable[[T], bool],
|
|
13
|
+
prompt: Callable[[], T],
|
|
14
|
+
invalid_message: str | None = None,
|
|
15
|
+
) -> T:
|
|
16
|
+
"""Use a value from the command line if it is good, otherwise ask for it.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
value: The candidate, typically from parsed arguments. None means
|
|
20
|
+
"not given" and is never reported.
|
|
21
|
+
is_valid: Decides whether `value` can be used as it is. A `Rule`
|
|
22
|
+
fits.
|
|
23
|
+
prompt: A zero-argument callable that asks the user, called only when
|
|
24
|
+
`value` is missing or refused.
|
|
25
|
+
invalid_message: A line printed before asking when `value` was given
|
|
26
|
+
but refused. Not printed for a missing value.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
`value` unchanged if it passed, otherwise what `prompt()` returns.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
folder = resolve_or_prompt(
|
|
33
|
+
args.folder,
|
|
34
|
+
is_valid=IsDir(),
|
|
35
|
+
prompt=lambda: prompt_path("Folder", kind="dir", must_exist=True),
|
|
36
|
+
invalid_message=f"'{args.folder}' is not a folder.",
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# 1. A given value that passes is used as it is
|
|
41
|
+
if value is not None and is_valid(value):
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
# 2. A given value that does not pass is mentioned before asking
|
|
45
|
+
if value is not None and invalid_message:
|
|
46
|
+
print_problem(invalid_message)
|
|
47
|
+
|
|
48
|
+
# 3. Ask
|
|
49
|
+
return prompt()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_DESIGN_NOTES = """
|
|
53
|
+
# resolve_or_prompt — The Command Line First, the Question Second
|
|
54
|
+
|
|
55
|
+
## Purpose
|
|
56
|
+
The usual shape of an optional option: given and good, use it; given and bad,
|
|
57
|
+
say so and ask; not given, ask. The program reads the same either way.
|
|
58
|
+
|
|
59
|
+
## Not Validated by `validate_call`
|
|
60
|
+
The result has the type of the value and of the prompt (`T`), which the
|
|
61
|
+
decorator cannot express. The two callables fail loudly enough on their own:
|
|
62
|
+
passing the result of a call instead of the callable ends in a `TypeError`
|
|
63
|
+
naming it.
|
|
64
|
+
|
|
65
|
+
## No Terminal
|
|
66
|
+
The function does not check that anybody can answer. A script that must never
|
|
67
|
+
wait calls `require_interactive("...")` before it, or passes `prompt` that
|
|
68
|
+
raises.
|
|
69
|
+
|
|
70
|
+
## Typed
|
|
71
|
+
`value: T | None` and `prompt: Callable[[], T]` tie the three parts together
|
|
72
|
+
for a type checker: what goes in and what the prompt returns are the same `T`,
|
|
73
|
+
so what comes out is a `T` as well.
|
|
74
|
+
"""
|
simplibs/cli/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .print_choices import print_choices
|
|
2
|
+
from .report_failures import report_failures
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_DESIGN_NOTES = """
|
|
6
|
+
# CLI Reporting Sub-Package
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
Output that is not a question: a list of options and the summary of a batch
|
|
10
|
+
that partly failed.
|
|
11
|
+
|
|
12
|
+
## Internal Components Registry
|
|
13
|
+
|
|
14
|
+
| Component | Type | Description |
|
|
15
|
+
| :---------------- | :------- | :-------------------------------------------------------------------- |
|
|
16
|
+
| `print_choices` | Function | Prints an aligned `key - description` list, optionally with a title. |
|
|
17
|
+
| `report_failures` | Function | Prints the failed items of a batch and returns whether all succeeded. |
|
|
18
|
+
"""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from simplibs.style import BOLD, CYAN
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@validate_call
|
|
6
|
+
def print_choices(
|
|
7
|
+
options: dict[str, str],
|
|
8
|
+
*,
|
|
9
|
+
title: str | None = None,
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Print an aligned list of `key - description` pairs.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
options: A mapping of key -> human-readable description.
|
|
15
|
+
title: A heading printed above the list. None prints no heading.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
print_choices({"json": "Machine-readable", "text": "For people"}, title="Formats:")
|
|
19
|
+
# Formats:
|
|
20
|
+
# json - Machine-readable
|
|
21
|
+
# text - For people
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# 1. Heading, when there is one
|
|
25
|
+
if title is not None:
|
|
26
|
+
print(BOLD(title))
|
|
27
|
+
|
|
28
|
+
# 2. Width of the longest key, so the dashes line up
|
|
29
|
+
width = max((len(key) for key in options), default=0)
|
|
30
|
+
|
|
31
|
+
# 3. One line per option — padded first, styled after (codes have no width)
|
|
32
|
+
for key, description in options.items():
|
|
33
|
+
print(f" {CYAN(key.ljust(width))} - {description}")
|
|
34
|
+
|
|
35
|
+
# 4. Blank line after the list
|
|
36
|
+
print()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_DESIGN_NOTES = """
|
|
40
|
+
# print_choices — A List the Eye Can Scan
|
|
41
|
+
|
|
42
|
+
## Purpose
|
|
43
|
+
The one place that formats options. `prompt_choice` and `prompt_multi_choice`
|
|
44
|
+
call it before they ask, so a list looks the same in a prompt and on its own.
|
|
45
|
+
|
|
46
|
+
## Width From the Data
|
|
47
|
+
The column is as wide as the longest key, so the dashes always line up,
|
|
48
|
+
whatever the keys are.
|
|
49
|
+
|
|
50
|
+
## Padded Before Styled
|
|
51
|
+
An escape code has no width on screen but counts as characters in
|
|
52
|
+
`ljust`. The key is padded first and colored after; the other order would
|
|
53
|
+
shorten the column by the length of the code.
|
|
54
|
+
|
|
55
|
+
## Title Is Optional
|
|
56
|
+
The prompts print the list without a heading, and a stand-alone list may have
|
|
57
|
+
one. `title=None` prints no heading at all.
|
|
58
|
+
"""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
from simplibs.style import ERROR
|
|
3
|
+
from simplibs.validate import validate_call
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@validate_call
|
|
7
|
+
def report_failures(
|
|
8
|
+
results: list[tuple[Any, bool]],
|
|
9
|
+
*,
|
|
10
|
+
describe: Callable[[Any], str],
|
|
11
|
+
title: str = "The following items failed:",
|
|
12
|
+
) -> bool:
|
|
13
|
+
"""Print the failed items of a batch and say whether everything succeeded.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
results: `(item, succeeded)` pairs from a batch operation.
|
|
17
|
+
describe: Turns one failed item into a line of the report.
|
|
18
|
+
title: Heading printed above the list of failures.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
True if every item succeeded (nothing is printed), False if at
|
|
22
|
+
least one failed.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
ok = report_failures(results, describe=lambda path: f"{path.name}")
|
|
26
|
+
raise SystemExit(0 if ok else 1)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# 1. Collect the failed items
|
|
30
|
+
failed = [item for item, succeeded in results if not succeeded]
|
|
31
|
+
if not failed:
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
# 2. Heading, then one line per failure
|
|
35
|
+
print(f"\n{ERROR(title)}")
|
|
36
|
+
for item in failed:
|
|
37
|
+
print(f" {describe(item)}")
|
|
38
|
+
|
|
39
|
+
# 3. At least one item failed
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_DESIGN_NOTES = """
|
|
44
|
+
# report_failures — The Summary of a Partly Failed Batch
|
|
45
|
+
|
|
46
|
+
## Purpose
|
|
47
|
+
A batch keeps going after an error and reports at the end. The return value
|
|
48
|
+
is the program's exit decision, so the call sits next to `SystemExit`.
|
|
49
|
+
|
|
50
|
+
## Failures Only
|
|
51
|
+
The function prints the failed items and prints nothing when everything
|
|
52
|
+
succeeded. The name says "failures" for that reason, and the heading is the
|
|
53
|
+
`title` keyword.
|
|
54
|
+
|
|
55
|
+
## Standard Output
|
|
56
|
+
The report goes to the same stream as the prompts. `simplibs-style` decides
|
|
57
|
+
about colors by standard output, so a message on another stream could carry
|
|
58
|
+
escape codes into a redirected file.
|
|
59
|
+
|
|
60
|
+
## Items Stay Opaque
|
|
61
|
+
The function never looks inside an item: `describe` is the only code that
|
|
62
|
+
knows what an item is, which is why the pairs may hold paths, names or
|
|
63
|
+
objects alike.
|
|
64
|
+
"""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .resolve_verbosity import resolve_verbosity
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_DESIGN_NOTES = """
|
|
5
|
+
# CLI Verbosity Sub-Package
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
Turns the flags of a command line into a decision that the rest of the
|
|
9
|
+
program can use.
|
|
10
|
+
|
|
11
|
+
## Internal Components Registry
|
|
12
|
+
|
|
13
|
+
| Component | Type | Description |
|
|
14
|
+
| :------------------ | :------- | :------------------------------------------------------------ |
|
|
15
|
+
| `resolve_verbosity` | Function | Resolves `--quiet` and `-v` / `-vv` into one logging level. |
|
|
16
|
+
"""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
# Outers
|
|
3
|
+
from simplibs.validate import validate_call
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@validate_call
|
|
7
|
+
def resolve_verbosity(
|
|
8
|
+
*,
|
|
9
|
+
quiet: bool = False,
|
|
10
|
+
verbose: int = 0,
|
|
11
|
+
default_level: int = logging.WARNING,
|
|
12
|
+
) -> int:
|
|
13
|
+
"""Resolve `--quiet` and `-v` / `-vv` into one logging level.
|
|
14
|
+
|
|
15
|
+
`quiet` wins over `verbose` when both are given — a documented rule,
|
|
16
|
+
not an accident of the order of the checks.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
quiet: If True, the result is `logging.ERROR` (only errors shown).
|
|
20
|
+
verbose: How many times the verbose flag was given. `0` changes
|
|
21
|
+
nothing, `1` shows `INFO`, `2` or more shows `DEBUG`. A bool
|
|
22
|
+
works as `0` or `1`; a negative number counts as `0`.
|
|
23
|
+
default_level: The level returned when no flag is set. A single
|
|
24
|
+
`-v` never makes the output quieter than this.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The resolved `logging` level.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
parser.add_argument("-v", "--verbose", action="count", default=0)
|
|
31
|
+
level = resolve_verbosity(quiet=args.quiet, verbose=args.verbose)
|
|
32
|
+
logging.basicConfig(level=level)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# 1. Quiet wins over verbose
|
|
36
|
+
if quiet:
|
|
37
|
+
return logging.ERROR
|
|
38
|
+
|
|
39
|
+
# 2. Two or more steps show everything
|
|
40
|
+
if verbose >= 2:
|
|
41
|
+
return logging.DEBUG
|
|
42
|
+
|
|
43
|
+
# 3. One step shows progress, but never less than the default already did
|
|
44
|
+
if verbose == 1:
|
|
45
|
+
return min(default_level, logging.INFO)
|
|
46
|
+
|
|
47
|
+
# 4. No flag — the caller's default
|
|
48
|
+
return default_level
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DESIGN_NOTES = """
|
|
52
|
+
# resolve_verbosity — Flags to a Level
|
|
53
|
+
|
|
54
|
+
## Purpose
|
|
55
|
+
`--quiet` and `-v` are values in the parsed arguments and a number in the
|
|
56
|
+
logging module. This is the one place where the rule between them lives.
|
|
57
|
+
|
|
58
|
+
## `verbose` Is a Count
|
|
59
|
+
It has the shape that `argparse` (`action="count"`) and `click`
|
|
60
|
+
(`count=True`) produce: `-v` is `1`, `-vv` is `2`. One step shows `INFO`, two
|
|
61
|
+
or more show `DEBUG`. A bool also works, as `0` or `1`, so `verbose=True` means
|
|
62
|
+
`INFO`; a program that wants `DEBUG` from a plain switch passes `verbose=2`.
|
|
63
|
+
|
|
64
|
+
## Quiet Wins
|
|
65
|
+
Both flags together is a contradiction. Refusing it would turn a harmless
|
|
66
|
+
shell alias (`alias tool="tool --verbose"`) plus a `-q` into an error, so the
|
|
67
|
+
quieter wish is honored.
|
|
68
|
+
|
|
69
|
+
## One Step Never Mutes
|
|
70
|
+
`min(default_level, INFO)`: with a default of `DEBUG` a single `-v` keeps
|
|
71
|
+
`DEBUG`. A flag that asks for more detail must not take detail away.
|
|
72
|
+
|
|
73
|
+
## It Only Decides
|
|
74
|
+
The function returns a level and configures nothing. Setting up logging is
|
|
75
|
+
the job of whatever logging the program uses.
|
|
76
|
+
"""
|