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,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
+ """