|
2 | 2 |
|
3 | 3 | import json |
4 | 4 | import re |
| 5 | +import sys |
5 | 6 | from collections import ChainMap |
6 | 7 | from functools import partial |
7 | 8 | from pathlib import Path |
|
10 | 11 | import yaml |
11 | 12 | from jinja2 import UndefinedError |
12 | 13 | from jinja2.sandbox import SandboxedEnvironment |
13 | | -from plumbum.cli.terminal import ask, choose, prompt |
14 | | -from plumbum.colors import bold, info, italics |
| 14 | +from plumbum.cli.terminal import ask, choose |
| 15 | +from plumbum.colors import bold, info, italics, reverse, warn |
| 16 | +from prompt_toolkit import prompt |
| 17 | +from prompt_toolkit.formatted_text import ANSI |
| 18 | +from prompt_toolkit.lexers import PygmentsLexer |
| 19 | +from prompt_toolkit.validation import Validator |
| 20 | +from pygments.lexers.data import JsonLexer, YamlLexer |
15 | 21 | from yamlinclude import YamlIncludeConstructor |
16 | 22 |
|
17 | | -from ..tools import get_jinja_env, printf_exception |
18 | | -from ..types import AnyByStrDict, Choices, OptStrOrPath, PathSeq, StrOrPath |
| 23 | +from ..tools import force_str_end, get_jinja_env, printf_exception, to_nice_yaml |
| 24 | +from ..types import AnyByStrDict, Choices, OptStr, OptStrOrPath, PathSeq, StrOrPath |
19 | 25 | from .objects import DEFAULT_DATA, EnvOps, UserMessageError |
20 | 26 |
|
21 | 27 | __all__ = ("load_config_data", "query_user_data") |
@@ -43,6 +49,73 @@ class InvalidTypeError(TypeError): |
43 | 49 | pass |
44 | 50 |
|
45 | 51 |
|
| 52 | +def ask_interactively( |
| 53 | + question: str, |
| 54 | + type_name: str, |
| 55 | + type_fn: Callable, |
| 56 | + secret: bool = False, |
| 57 | + placeholder: OptStr = None, |
| 58 | + default: Any = None, |
| 59 | + choices: Any = None, |
| 60 | + extra_help: OptStr = None, |
| 61 | +) -> Any: |
| 62 | + """Ask one question interactively to the user.""" |
| 63 | + # Generate message to ask the user |
| 64 | + message = "" |
| 65 | + if extra_help: |
| 66 | + message = force_str_end(f"\n{info & italics | extra_help}{message}") |
| 67 | + emoji = "🕵️" if secret else "🎤" |
| 68 | + message += f"{bold | question}? Format: {type_name}\n{emoji} " |
| 69 | + lexer_map = {"json": JsonLexer, "yaml": YamlLexer} |
| 70 | + lexer = lexer_map.get(type_name) |
| 71 | + # Use the correct method to ask |
| 72 | + if type_name == "bool": |
| 73 | + return ask(message, default) |
| 74 | + if choices: |
| 75 | + return choose(message, choices, default) |
| 76 | + # Hints for the multiline input user |
| 77 | + multiline = lexer is not None |
| 78 | + if multiline: |
| 79 | + message += f"- Finish with {reverse | 'Esc, ↵'} or {reverse | 'Meta + ↵'}\n" |
| 80 | + # Convert default to string |
| 81 | + to_str_map = {"json": lambda obj: json.dumps(obj, indent=2), "yaml": to_nice_yaml} |
| 82 | + to_str_fn = to_str_map.get(type_name, str) |
| 83 | + # Allow placeholder YAML comments |
| 84 | + default_str = to_str_fn(default) |
| 85 | + if placeholder and type_name == "yaml": |
| 86 | + prefixed_default_str = force_str_end(placeholder) + default_str |
| 87 | + if yaml.safe_load(prefixed_default_str) == default: |
| 88 | + default_str = prefixed_default_str |
| 89 | + else: |
| 90 | + print(warn | "Placeholder text alters value!", file=sys.stderr) |
| 91 | + return prompt( |
| 92 | + ANSI(message), |
| 93 | + default=default_str, |
| 94 | + is_password=secret, |
| 95 | + lexer=lexer and PygmentsLexer(lexer), |
| 96 | + mouse_support=True, |
| 97 | + multiline=multiline, |
| 98 | + validator=Validator.from_callable(abstract_validator(type_fn)), |
| 99 | + ) |
| 100 | + |
| 101 | + |
| 102 | +def abstract_validator(type_fn: Callable) -> Callable: |
| 103 | + """Produce a validator for the given type. |
| 104 | +
|
| 105 | + Params: |
| 106 | + type_fn: A callable that converts text into the expected type. |
| 107 | + """ |
| 108 | + |
| 109 | + def _validator(text: str): |
| 110 | + try: |
| 111 | + type_fn(text) |
| 112 | + return True |
| 113 | + except Exception: |
| 114 | + return False |
| 115 | + |
| 116 | + return _validator |
| 117 | + |
| 118 | + |
46 | 119 | def load_yaml_data( |
47 | 120 | conf_path: Path, quiet: bool = False, _warning: bool = True |
48 | 121 | ) -> AnyByStrDict: |
@@ -196,21 +269,19 @@ def query_user_data( |
196 | 269 | # Get default answer |
197 | 270 | answer = last_answers_data.get(question, default) |
198 | 271 | if ask_this: |
199 | | - # Generate message to ask the user |
200 | | - emoji = "🕵️" if details.get("secret", False) else "🎤" |
201 | | - message = f"\n{bold | question}? Format: {type_name}\n{emoji} " |
202 | | - if details.get("help"): |
203 | | - message = ( |
204 | | - f"\n{info & italics | _render_value(details['help'])}{message}" |
205 | | - ) |
206 | | - # Use the right method to ask |
207 | | - if type_fn is bool: |
208 | | - answer = ask(message, answer) |
209 | | - elif details.get("choices"): |
210 | | - choices = _render_choices(details["choices"]) |
211 | | - answer = choose(message, choices, answer) |
212 | | - else: |
213 | | - answer = prompt(message, type_fn, answer) |
| 272 | + extra_help = details.get("help") |
| 273 | + if extra_help: |
| 274 | + extra_help = _render_value(extra_help) |
| 275 | + answer = ask_interactively( |
| 276 | + question, |
| 277 | + type_name, |
| 278 | + type_fn, |
| 279 | + details.get("secret", False), |
| 280 | + _render_value(details.get("placeholder")), |
| 281 | + answer, |
| 282 | + _render_choices(details.get("choices")), |
| 283 | + _render_value(details.get("help")), |
| 284 | + ) |
214 | 285 | if answer != details.get("default", default): |
215 | 286 | result[question] = cast_answer_type(answer, type_fn) |
216 | 287 | return result |
0 commit comments