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,87 @@
1
+ from .interaction import is_interactive, require_interactive
2
+ from .prompts import (
3
+ prompt_choice,
4
+ prompt_confirm,
5
+ prompt_int,
6
+ prompt_multi_choice,
7
+ prompt_path,
8
+ prompt_secret,
9
+ prompt_text,
10
+ resolve_or_prompt,
11
+ )
12
+ from .reporting import print_choices, report_failures
13
+ from .verbosity import resolve_verbosity
14
+
15
+ __all__ = [
16
+ # prompts — ask a person
17
+ "prompt_text",
18
+ "prompt_int",
19
+ "prompt_secret",
20
+ "prompt_confirm",
21
+ "prompt_choice",
22
+ "prompt_multi_choice",
23
+ "prompt_path",
24
+ "resolve_or_prompt",
25
+ # interaction — is there a person to ask
26
+ "is_interactive",
27
+ "require_interactive",
28
+ # reporting — say something that is not a question
29
+ "print_choices",
30
+ "report_failures",
31
+ # verbosity — flags to a level
32
+ "resolve_verbosity",
33
+ ]
34
+
35
+
36
+ _DESIGN_NOTES = """
37
+ # simplibs-cli — Package Root
38
+
39
+ ## Purpose
40
+ The small, repeatable parts of a command-line program that talks to a person:
41
+ questions that ask again until the answer is usable, the check whether anyone
42
+ can answer, a clear list of options, a summary of what failed, and the
43
+ translation of `--quiet` / `-v` into a logging level.
44
+
45
+ from simplibs.cli import prompt_confirm, prompt_path, resolve_or_prompt
46
+
47
+ ## What the Root Exports
48
+ Every function, from one import line. The package has no classes, rules or
49
+ actions: a prompt is an interaction, not a step of a data pipeline, so there
50
+ would be nothing for an `Action` to do.
51
+
52
+ ## What It Does Not Do
53
+ * **Parse arguments.** `argparse`, `click` and `typer` do it well; this
54
+ library starts where they hand over the values (`resolve_or_prompt`).
55
+ * **Configure logging.** `resolve_verbosity` returns a level, and the logging
56
+ library applies it.
57
+
58
+ ## Layers
59
+ `interaction`, `reporting` and `verbosity` stand alone; `prompts` uses
60
+ `reporting`. Every layer imports only from those before it.
61
+
62
+ ## Dependencies
63
+ `simplibs-exception` (cards), `simplibs-validate` (`validate_call`),
64
+ `simplibs-path` (`prompt_path`), `simplibs-style` (colors that disappear
65
+ outside a terminal) and `simplibs-rules` (the message of a refusing `Rule`).
66
+
67
+ ## `__all__`
68
+ Defined here only; the sub-packages export by plain imports.
69
+
70
+ ## Internal Components Registry (Root Exports)
71
+
72
+ | Component | Type | Description |
73
+ | :-------------------- | :------- | :------------------------------------------------------------------- |
74
+ | `prompt_text` | Function | A line of text, optionally checked by a rule. |
75
+ | `prompt_int` | Function | A whole number, optionally within limits. |
76
+ | `prompt_secret` | Function | A line typed without being shown. |
77
+ | `prompt_confirm` | Function | A yes/no question. |
78
+ | `prompt_choice` | Function | One key from a described set of options. |
79
+ | `prompt_multi_choice` | Function | One or more comma-separated keys from a described set. |
80
+ | `prompt_path` | Function | A normalized path of a given kind, existing or created. |
81
+ | `resolve_or_prompt` | Function | Uses a command-line value, asks only when it is missing or refused. |
82
+ | `is_interactive` | Function | True when both input and output are a terminal. |
83
+ | `require_interactive` | Function | Raises when there is nobody to ask. |
84
+ | `print_choices` | Function | Prints an aligned `key - description` list. |
85
+ | `report_failures` | Function | Prints the failed items of a batch, returns whether all succeeded. |
86
+ | `resolve_verbosity` | Function | Resolves `--quiet` / `-v` / `-vv` into a logging level. |
87
+ """
@@ -0,0 +1,21 @@
1
+ from .is_interactive import is_interactive
2
+ from .require_interactive import require_interactive
3
+
4
+
5
+ _DESIGN_NOTES = """
6
+ # CLI Interaction Sub-Package
7
+
8
+ ## Purpose
9
+ Answers one question before a program asks anything: is there somebody to
10
+ ask? A prompt on a redirected input waits forever or fails far from its cause,
11
+ so the check is a function of its own that the caller can use first.
12
+
13
+ ## Internal Components Registry
14
+
15
+ | Component | Type | Description |
16
+ | :-------------------- | :------- | :------------------------------------------------------------------ |
17
+ | `is_interactive` | Function | True when both input and output are a terminal. |
18
+ | `require_interactive` | Function | Raises `StateError` when `is_interactive()` is False. |
19
+ | `_helpers` | Package | `is_terminal` — one stream asked whether it is a terminal. |
20
+ | `_validations` | Package | `raise_not_interactive_error`. |
21
+ """
@@ -0,0 +1,15 @@
1
+ from .is_terminal import is_terminal
2
+
3
+
4
+ _DESIGN_NOTES = """
5
+ # CLI Interaction Internal Helpers Sub-Package
6
+
7
+ ## Purpose
8
+ Building blocks private to `interaction/`.
9
+
10
+ ## Internal Components Registry
11
+
12
+ | Component | Type | Description |
13
+ | :------------ | :------- | :------------------------------------------------- |
14
+ | `is_terminal` | Function | Asks one stream whether it is a terminal, safely. |
15
+ """
@@ -0,0 +1,34 @@
1
+ from typing import Any
2
+
3
+
4
+ def is_terminal(
5
+ stream: Any,
6
+ ) -> bool:
7
+ """Return whether a stream is a terminal, without ever raising.
8
+
9
+ Args:
10
+ stream: A file-like object, or `None`.
11
+
12
+ Returns:
13
+ True only for a stream that answers `isatty()` with True.
14
+ """
15
+
16
+ # 1. No stream at all (a windowed interpreter has no stdin)
17
+ if stream is None:
18
+ return False
19
+
20
+ # 2. Ask the stream; a closed file or a stub without `isatty` is no terminal
21
+ try:
22
+ return bool(stream.isatty())
23
+ except (AttributeError, ValueError):
24
+ return False
25
+
26
+
27
+ _DESIGN_NOTES = """
28
+ # is_terminal — One Stream, One Safe Answer
29
+
30
+ ## Purpose
31
+ `sys.stdin` can be `None` (a program started without a console), closed, or
32
+ replaced by an object that has no `isatty`. All three mean "not a terminal",
33
+ and none of them should surface as an exception from a yes/no question.
34
+ """
@@ -0,0 +1,15 @@
1
+ from .raise_not_interactive_error import raise_not_interactive_error
2
+
3
+
4
+ _DESIGN_NOTES = """
5
+ # CLI Interaction Validations Sub-Package
6
+
7
+ ## Purpose
8
+ Diagnostic raisers for the interaction check.
9
+
10
+ ## Internal Components Registry
11
+
12
+ | Component | Type | Description |
13
+ | :---------------------------- | :------- | :----------------------------------------------------------- |
14
+ | `raise_not_interactive_error` | Function | Raises `StateError` when there is no terminal to ask in. |
15
+ """
@@ -0,0 +1,42 @@
1
+ from typing import NoReturn
2
+ from simplibs.exception import StateError
3
+
4
+
5
+ def raise_not_interactive_error(
6
+ reason: str | None,
7
+ ) -> NoReturn:
8
+ """Raise StateError when an answer is needed but there is no terminal.
9
+
10
+ Args:
11
+ reason: What was missing, or None.
12
+ """
13
+
14
+ # 1. The reason, when there is one, becomes the last line of the problem
15
+ problem = (
16
+ "An answer is needed, but there is no terminal to ask in.",
17
+ "Input or output is redirected (a pipe, a file, a scheduler or CI).",
18
+ )
19
+ if reason:
20
+ problem = problem + (reason,)
21
+
22
+ # 2. Raise structured StateError wrapping a RuntimeError
23
+ raise StateError(
24
+ error_name="CLI_NOT_INTERACTIVE",
25
+ label="terminal",
26
+ problem=problem,
27
+ expected="Input and output connected to a terminal, or every value given in advance.",
28
+ how_to_fix=(
29
+ "Pass the missing value as a command-line option.",
30
+ "Run the program in a terminal.",
31
+ ),
32
+ exception=RuntimeError,
33
+ )
34
+
35
+
36
+ _DESIGN_NOTES = """
37
+ # raise_not_interactive_error — Nobody to Ask
38
+
39
+ ## Purpose
40
+ Raised by `require_interactive`. It is a `StateError` and not a `ParamError`:
41
+ the arguments are fine, the environment the program runs in is not.
42
+ """
@@ -0,0 +1,41 @@
1
+ import sys
2
+ # Inners
3
+ from ._helpers import is_terminal
4
+
5
+
6
+ def is_interactive() -> bool:
7
+ """Return whether the program can talk to a person.
8
+
9
+ Returns:
10
+ True when both standard input and standard output are terminals.
11
+ False in a pipe, behind a redirect, in a scheduler or in CI.
12
+
13
+ Example:
14
+ if not is_interactive():
15
+ print("Pass --name, there is nobody to ask.")
16
+ """
17
+
18
+ # 1. A person needs a way to answer and a way to read the question
19
+ return is_terminal(sys.stdin) and is_terminal(sys.stdout)
20
+
21
+
22
+ _DESIGN_NOTES = """
23
+ # is_interactive — Is There Somebody to Ask?
24
+
25
+ ## Purpose
26
+ `resolve_or_prompt` falls back to a question when an option is missing. In a
27
+ script that fallback must not happen, and this is the check for it.
28
+
29
+ ## Both Streams
30
+ Input alone is not enough: with output redirected, the question would be
31
+ written into a file where nobody reads it. Both must be terminals.
32
+
33
+ ## The Streams Are Read on Every Call
34
+ `sys.stdin` and `sys.stdout` are looked up when the function runs, not at
35
+ import, so a program (or a test) that replaces them is noticed.
36
+
37
+ ## Not Checked Inside the Prompts
38
+ The prompts do not call this. Piped answers (`echo y | tool`) are a legitimate
39
+ way to drive a program, and tests feed answers the same way. Whether a missing
40
+ answer is an error is the caller's decision: `require_interactive()`.
41
+ """
@@ -0,0 +1,41 @@
1
+ from simplibs.validate import validate_call
2
+ # Inners
3
+ from .is_interactive import is_interactive
4
+ from ._validations import raise_not_interactive_error
5
+
6
+
7
+ @validate_call
8
+ def require_interactive(
9
+ reason: str | None = None,
10
+ ) -> None:
11
+ """Raise when the program cannot ask a person anything.
12
+
13
+ Args:
14
+ reason: What the program wanted to ask, added to the error so the
15
+ user sees why an answer was needed (e.g. `"--output is missing"`).
16
+
17
+ Raises:
18
+ StateError: If input or output is not a terminal.
19
+
20
+ Example:
21
+ if args.output is None:
22
+ require_interactive("--output is missing")
23
+ args.output = prompt_path("Output folder")
24
+ """
25
+
26
+ # 1. Fail with a readable card instead of waiting on a dead input
27
+ if not is_interactive():
28
+ raise_not_interactive_error(reason)
29
+
30
+
31
+ _DESIGN_NOTES = """
32
+ # require_interactive — Fail Early, Say Why
33
+
34
+ ## Purpose
35
+ The assertion form of `is_interactive`: one line in front of a prompt that
36
+ turns "the program hangs in CI" into a card naming the missing option.
37
+
38
+ ## The Reason
39
+ Without it the card could only say "no terminal". With the reason the user
40
+ reads what the program was missing and passes it as an option instead.
41
+ """
@@ -0,0 +1,43 @@
1
+ from .prompt_choice import prompt_choice
2
+ from .prompt_confirm import prompt_confirm
3
+ from .prompt_multi_choice import prompt_multi_choice
4
+ from .prompt_path import prompt_path
5
+ from .prompt_text import prompt_text
6
+ from .resolve_or_prompt import resolve_or_prompt
7
+ from .prompt_int import prompt_int
8
+ from .prompt_secret import prompt_secret
9
+
10
+
11
+ _DESIGN_NOTES = """
12
+ # CLI Prompts Sub-Package
13
+
14
+ ## Purpose
15
+ Questions to a person at a terminal. Every prompt asks again until the answer
16
+ is acceptable, so what it returns needs no second check.
17
+
18
+ ## Shared Behavior
19
+ * **An empty answer** returns the default when there is one. Without a
20
+ default it is refused and asked again (`prompt_multi_choice`, `prompt_text`
21
+ and `prompt_secret` can allow it).
22
+ * **A refused answer** is explained in one warning line, then the question
23
+ is repeated. Nothing is raised for a typo.
24
+ * **A wrong call** (an empty option list, a default that is not an option,
25
+ limits that exclude everything) is the programmer's mistake and raises a
26
+ `ParamError` at once, before the first question.
27
+ * **No input at all** (the stream ended) raises `StateError`.
28
+
29
+ ## Internal Components Registry
30
+
31
+ | Component | Type | Description |
32
+ | :------------------- | :------- | :-------------------------------------------------------------------- |
33
+ | `prompt_text` | Function | A line of text, optionally checked by a rule. |
34
+ | `prompt_int` | Function | A whole number, optionally within limits. |
35
+ | `prompt_secret` | Function | A line typed without being shown. |
36
+ | `prompt_confirm` | Function | A yes/no question. |
37
+ | `prompt_choice` | Function | One key from a described set of options. |
38
+ | `prompt_multi_choice`| Function | One or more comma-separated keys from a described set. |
39
+ | `prompt_path` | Function | A normalized path, optionally of a given kind, existing or created. |
40
+ | `resolve_or_prompt` | Function | Uses a value from the command line, asks only when it is missing. |
41
+ | `_helpers` | Package | Reading answers, warning lines, hints and parsing. |
42
+ | `_validations` | Package | Diagnostic raisers for wrong calls and a dead input. |
43
+ """
@@ -0,0 +1,27 @@
1
+ from .build_rejection_message import build_rejection_message
2
+ from .format_default_hint import format_default_hint
3
+ from .parse_keys import parse_keys
4
+ from .print_problem import print_problem
5
+ from .unquote_answer import unquote_answer
6
+ from .describe_range import describe_range
7
+ from .read_answer import read_answer
8
+
9
+
10
+ _DESIGN_NOTES = """
11
+ # CLI Prompts Internal Helpers Sub-Package
12
+
13
+ ## Purpose
14
+ Building blocks shared by the prompts and private to them.
15
+
16
+ ## Internal Components Registry
17
+
18
+ | Component | Type | Description |
19
+ | :------------------------ | :------- | :--------------------------------------------------------------- |
20
+ | `read_answer` | Function | The one place that reads a visible line from the user. |
21
+ | `print_problem` | Function | Prints a refusal as a warning line. |
22
+ | `format_default_hint` | Function | Builds the ` [default]` text shown after a question. |
23
+ | `build_rejection_message` | Function | The line shown when a rule refuses an answer. |
24
+ | `describe_range` | Function | Words for the limits of a number: `from 1 to 64`. |
25
+ | `parse_keys` | Function | Splits comma-separated keys, dropping empties and repeats. |
26
+ | `unquote_answer` | Function | Removes one pair of surrounding quotes from an answer. |
27
+ """
@@ -0,0 +1,43 @@
1
+ from typing import Any
2
+ from simplibs.rules import Rule
3
+
4
+
5
+ def build_rejection_message(
6
+ rule: Any,
7
+ invalid_message: str | None,
8
+ ) -> str:
9
+ """Choose the line shown when a rule refuses an answer.
10
+
11
+ Args:
12
+ rule: The rule or callable that refused.
13
+ invalid_message: The caller's own message, or None.
14
+
15
+ Returns:
16
+ `invalid_message` when given; otherwise a line built from
17
+ `rule.describe()` for a `Rule`, or a plain one for any other callable.
18
+ """
19
+
20
+ # 1. The caller knows best
21
+ if invalid_message is not None:
22
+ return invalid_message
23
+
24
+ # 2. A Rule can say what it expects
25
+ if isinstance(rule, Rule):
26
+ return f"The value is not valid. Expected: {rule.describe()}."
27
+
28
+ # 3. A plain callable cannot
29
+ return "The value is not valid."
30
+
31
+
32
+ _DESIGN_NOTES = """
33
+ # build_rejection_message — Saying Why a Rule Said No
34
+
35
+ ## Purpose
36
+ `prompt_text` and `prompt_path` both take a rule and both need the same
37
+ sentence when it refuses.
38
+
39
+ ## `describe()` as the Source
40
+ A `Rule` carries a short noun phrase for what it requires, written for exactly
41
+ this kind of message. A lambda carries nothing, which is why `invalid_message`
42
+ exists.
43
+ """
@@ -0,0 +1,36 @@
1
+ def describe_range(
2
+ minimum: int | None,
3
+ maximum: int | None,
4
+ ) -> str:
5
+ """Describe the accepted numbers in a few words.
6
+
7
+ Args:
8
+ minimum: The lower limit, or None.
9
+ maximum: The upper limit, or None.
10
+
11
+ Returns:
12
+ `"from 1 to 64"`, `"1 or more"`, `"64 or less"`, or an empty
13
+ string when there are no limits.
14
+ """
15
+
16
+ # 1. Both limits
17
+ if minimum is not None and maximum is not None:
18
+ return f"from {minimum} to {maximum}"
19
+
20
+ # 2. One limit
21
+ if minimum is not None:
22
+ return f"{minimum} or more"
23
+ if maximum is not None:
24
+ return f"{maximum} or less"
25
+
26
+ # 3. No limits
27
+ return ""
28
+
29
+
30
+ _DESIGN_NOTES = """
31
+ # describe_range — The Limits in Words
32
+
33
+ ## Purpose
34
+ The part of the refusal sentence of `prompt_int` that changes with the limits,
35
+ kept out of the prompt so that the loop reads as questions and decisions only.
36
+ """
@@ -0,0 +1,33 @@
1
+ from typing import Any
2
+
3
+
4
+ def format_default_hint(
5
+ default: Any,
6
+ ) -> str:
7
+ """Build the ` [default]` text shown after a question.
8
+
9
+ Args:
10
+ default: The default value, or None.
11
+
12
+ Returns:
13
+ `" [value]"`, or an empty string when there is no default or it
14
+ is empty.
15
+ """
16
+
17
+ # 1. Text of the default; None and "" show nothing
18
+ text = "" if default is None else str(default)
19
+
20
+ # 2. Leading space included, so it joins the message directly
21
+ return f" [{text}]" if text else ""
22
+
23
+
24
+ _DESIGN_NOTES = """
25
+ # format_default_hint — What Enter Will Do
26
+
27
+ ## Purpose
28
+ The same bracket after every question, built once.
29
+
30
+ ## An Empty Default Shows Nothing
31
+ `[]` tells the user nothing. A default of `""` (accepted by `prompt_text`)
32
+ looks the same as no default, which is also how it behaves.
33
+ """
@@ -0,0 +1,30 @@
1
+ def parse_keys(
2
+ raw: str,
3
+ ) -> tuple[str, ...]:
4
+ """Split a comma-separated answer into keys.
5
+
6
+ Args:
7
+ raw: The answer, e.g. `"a, b,,a"`.
8
+
9
+ Returns:
10
+ The keys in the order given, stripped, without empty items and
11
+ without repeats: `("a", "b")`.
12
+ """
13
+
14
+ # 1. Split, strip, and drop empty items
15
+ keys = [part.strip() for part in raw.split(",") if part.strip()]
16
+
17
+ # 2. Drop repeats, keeping the first occurrence of each
18
+ return tuple(dict.fromkeys(keys))
19
+
20
+
21
+ _DESIGN_NOTES = """
22
+ # parse_keys — The Answer as a List
23
+
24
+ ## Purpose
25
+ The parsing step of `prompt_multi_choice`, apart from the validation, so the
26
+ loop reads as questions and decisions only.
27
+
28
+ ## Repeats
29
+ `dict.fromkeys` removes them and keeps the order, which a `set` would not.
30
+ """
@@ -0,0 +1,23 @@
1
+ from simplibs.style import WARNING
2
+
3
+
4
+ def print_problem(
5
+ message: str,
6
+ ) -> None:
7
+ """Print a refusal of an answer as a warning line.
8
+
9
+ Args:
10
+ message: What was wrong with the answer.
11
+ """
12
+
13
+ # 1. One styled line; plain text when styles are off
14
+ print(WARNING(message))
15
+
16
+
17
+ _DESIGN_NOTES = """
18
+ # print_problem — How a Prompt Complains
19
+
20
+ ## Purpose
21
+ Every "please try again" has the same look, and the look can be changed here.
22
+ Plain text when the output is not a terminal, because `simplibs-style` decides.
23
+ """
@@ -0,0 +1,44 @@
1
+ # Outers
2
+ from .._validations import raise_no_answer_available_error
3
+
4
+
5
+ def read_answer(
6
+ prompt: str,
7
+ ) -> str:
8
+ """Read one visible line from the user and strip surrounding whitespace.
9
+
10
+ Args:
11
+ prompt: The text shown before the cursor.
12
+
13
+ Returns:
14
+ The answer, possibly empty.
15
+
16
+ Raises:
17
+ StateError: If the input ended before an answer was given.
18
+ """
19
+
20
+ # 1. Ask; a dead input becomes a card instead of a bare EOFError
21
+ try:
22
+ return input(prompt).strip()
23
+ except EOFError as caught_exc:
24
+ raise_no_answer_available_error(prompt, caught_exc)
25
+
26
+
27
+ _DESIGN_NOTES = """
28
+ # read_answer — The One Place That Reads
29
+
30
+ ## Purpose
31
+ Every prompt that shows what is typed reads through this function, so there is
32
+ exactly one `input` call for them. A test that feeds answers patches
33
+ `builtins.input` once, and the end-of-input behavior is the same everywhere.
34
+ (`prompt_secret` reads through `getpass` and shares only the card.)
35
+
36
+ ## End of Input
37
+ A program started with a closed or exhausted input (`tool < /dev/null`, a CI
38
+ job) gets `EOFError` from `input`. It is turned into a `StateError` that says
39
+ nobody is there to answer.
40
+
41
+ ## Ctrl+C Is Left Alone
42
+ `KeyboardInterrupt` is the user saying "stop", and a prompt that swallowed it
43
+ would be a trap. It goes up unchanged.
44
+ """
@@ -0,0 +1,32 @@
1
+ def unquote_answer(
2
+ answer: str,
3
+ ) -> str:
4
+ """Remove one pair of matching quotes around an answer.
5
+
6
+ Args:
7
+ answer: The answer, already stripped.
8
+
9
+ Returns:
10
+ The answer without the outer `"` or `'` pair, stripped again.
11
+ An answer without such a pair is returned unchanged.
12
+ """
13
+
14
+ # 1. Only a matching pair around the whole answer is a quote
15
+ if len(answer) >= 2 and answer[0] == answer[-1] and answer[0] in "\"'":
16
+ return answer[1:-1].strip()
17
+
18
+ # 2. Anything else is part of the answer
19
+ return answer
20
+
21
+
22
+ _DESIGN_NOTES = """
23
+ # unquote_answer — Paths Dragged Into a Console
24
+
25
+ ## Purpose
26
+ A file manager pastes a path with spaces in quotes. `normalize_path` would
27
+ read the quotes as part of the name and report a path that does not exist.
28
+
29
+ ## Only a Matching Pair
30
+ `"a` or `a"` or `it's` are left alone; the answer is changed only when the
31
+ same quote starts and ends it.
32
+ """
@@ -0,0 +1,23 @@
1
+ from .raise_create_needs_kind_error import raise_create_needs_kind_error
2
+ from .raise_default_not_in_options_error import raise_default_not_in_options_error
3
+ from .raise_no_answer_available_error import raise_no_answer_available_error
4
+ from .raise_options_empty_error import raise_options_empty_error
5
+ from .raise_range_empty_error import raise_range_empty_error
6
+
7
+
8
+ _DESIGN_NOTES = """
9
+ # CLI Prompts Validations Sub-Package
10
+
11
+ ## Purpose
12
+ Diagnostic raisers for wrong calls of the prompts and for a dead input.
13
+
14
+ ## Internal Components Registry
15
+
16
+ | Component | Type | Description |
17
+ | :------------------------------------- | :------- | :--------------------------------------------------------------------- |
18
+ | `raise_create_needs_kind_error` | Function | Raises `ParamError` for `create_if_missing` with `kind="any"`. |
19
+ | `raise_default_not_in_options_error` | Function | Raises `ParamError` when a default is not one of the options. |
20
+ | `raise_no_answer_available_error` | Function | Raises `StateError` when the input ended before an answer. |
21
+ | `raise_options_empty_error` | Function | Raises `ParamError` when there is nothing to choose from. |
22
+ | `raise_range_empty_error` | Function | Raises `ParamError` when `minimum` is greater than `maximum`. |
23
+ """