|
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 |
|
11 | 12 | from iteration_utilities import deepflatten |
12 | 13 | from jinja2 import UndefinedError |
13 | 14 | from jinja2.sandbox import SandboxedEnvironment |
14 | | -from plumbum.cli.terminal import ask, choose, prompt |
15 | | -from plumbum.colors import bold, info, italics |
| 15 | +from plumbum.cli.terminal import ask, choose |
| 16 | +from plumbum.colors import bold, info, italics, reverse, warn |
| 17 | +from prompt_toolkit import prompt |
| 18 | +from prompt_toolkit.formatted_text import ANSI |
| 19 | +from prompt_toolkit.lexers import PygmentsLexer |
| 20 | +from prompt_toolkit.validation import Validator |
| 21 | +from pygments.lexers.data import JsonLexer, YamlLexer |
16 | 22 | from yamlinclude import YamlIncludeConstructor |
17 | 23 |
|
18 | | -from ..tools import get_jinja_env, printf_exception |
19 | | -from ..types import AnyByStrDict, Choices, OptStrOrPath, PathSeq, StrOrPath |
| 24 | +from ..tools import force_str_end, get_jinja_env, printf_exception, to_nice_yaml |
| 25 | +from ..types import AnyByStrDict, Choices, OptStr, OptStrOrPath, PathSeq, StrOrPath |
20 | 26 | from .objects import DEFAULT_DATA, EnvOps, UserMessageError |
21 | 27 |
|
22 | 28 | __all__ = ("load_config_data", "query_user_data") |
@@ -44,6 +50,78 @@ class InvalidTypeError(TypeError): |
44 | 50 | pass |
45 | 51 |
|
46 | 52 |
|
| 53 | +def ask_interactively( |
| 54 | + question: str, |
| 55 | + type_name: str, |
| 56 | + type_fn: Callable, |
| 57 | + secret: bool = False, |
| 58 | + placeholder: OptStr = None, |
| 59 | + default: Any = None, |
| 60 | + choices: Any = None, |
| 61 | + extra_help: OptStr = None, |
| 62 | +) -> Any: |
| 63 | + """Ask one question interactively to the user.""" |
| 64 | + # Generate message to ask the user |
| 65 | + message = "" |
| 66 | + if extra_help: |
| 67 | + message = force_str_end(f"\n{info & italics | extra_help}{message}") |
| 68 | + emoji = "🕵️" if secret else "🎤" |
| 69 | + message += f"{bold | question}? Format: {type_name}\n{emoji} " |
| 70 | + lexer_map = {"json": JsonLexer, "yaml": YamlLexer} |
| 71 | + lexer = lexer_map.get(type_name) |
| 72 | + # HACK https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1071 |
| 73 | + # FIXME When fixed, use prompt toolkit too for choices and bools |
| 74 | + # Use the correct method to ask |
| 75 | + if type_name == "bool": |
| 76 | + return ask(message, default) |
| 77 | + if choices: |
| 78 | + return choose(message, choices, default) |
| 79 | + # Hints for the multiline input user |
| 80 | + multiline = lexer is not None |
| 81 | + if multiline: |
| 82 | + message += f"- Finish with {reverse | 'Esc, ↵'} or {reverse | 'Meta + ↵'}\n" |
| 83 | + # Convert default to string |
| 84 | + to_str_map: Dict[str, Callable[[Any], str]] = { |
| 85 | + "json": lambda obj: json.dumps(obj, indent=2), |
| 86 | + "yaml": to_nice_yaml, |
| 87 | + } |
| 88 | + to_str_fn = to_str_map.get(type_name, str) |
| 89 | + # Allow placeholder YAML comments |
| 90 | + default_str = to_str_fn(default) |
| 91 | + if placeholder and type_name == "yaml": |
| 92 | + prefixed_default_str = force_str_end(placeholder) + default_str |
| 93 | + if yaml.safe_load(prefixed_default_str) == default: |
| 94 | + default_str = prefixed_default_str |
| 95 | + else: |
| 96 | + print(warn | "Placeholder text alters value!", file=sys.stderr) |
| 97 | + return prompt( |
| 98 | + ANSI(message), |
| 99 | + default=default_str, |
| 100 | + is_password=secret, |
| 101 | + lexer=lexer and PygmentsLexer(lexer), |
| 102 | + mouse_support=True, |
| 103 | + multiline=multiline, |
| 104 | + validator=Validator.from_callable(abstract_validator(type_fn)), |
| 105 | + ) |
| 106 | + |
| 107 | + |
| 108 | +def abstract_validator(type_fn: Callable) -> Callable: |
| 109 | + """Produce a validator for the given type. |
| 110 | +
|
| 111 | + Params: |
| 112 | + type_fn: A callable that converts text into the expected type. |
| 113 | + """ |
| 114 | + |
| 115 | + def _validator(text: str): |
| 116 | + try: |
| 117 | + type_fn(text) |
| 118 | + return True |
| 119 | + except Exception: |
| 120 | + return False |
| 121 | + |
| 122 | + return _validator |
| 123 | + |
| 124 | + |
47 | 125 | def load_yaml_data(conf_path: Path, quiet: bool = False) -> AnyByStrDict: |
48 | 126 | """Load the `copier.yml` file. |
49 | 127 |
|
@@ -217,21 +295,19 @@ def query_user_data( |
217 | 295 | # Get default answer |
218 | 296 | answer = last_answers_data.get(question, default) |
219 | 297 | if ask_this: |
220 | | - # Generate message to ask the user |
221 | | - emoji = "🕵️" if details.get("secret", False) else "🎤" |
222 | | - message = f"\n{bold | question}? Format: {type_name}\n{emoji} " |
223 | | - if details.get("help"): |
224 | | - message = ( |
225 | | - f"\n{info & italics | _render_value(details['help'])}{message}" |
226 | | - ) |
227 | | - # Use the right method to ask |
228 | | - if type_fn is bool: |
229 | | - answer = ask(message, answer) |
230 | | - elif details.get("choices"): |
231 | | - choices = _render_choices(details["choices"]) |
232 | | - answer = choose(message, choices, answer) |
233 | | - else: |
234 | | - answer = prompt(message, type_fn, answer) |
| 298 | + extra_help = details.get("help") |
| 299 | + if extra_help: |
| 300 | + extra_help = _render_value(extra_help) |
| 301 | + answer = ask_interactively( |
| 302 | + question, |
| 303 | + type_name, |
| 304 | + type_fn, |
| 305 | + details.get("secret", False), |
| 306 | + _render_value(details.get("placeholder")), |
| 307 | + answer, |
| 308 | + _render_choices(details.get("choices")), |
| 309 | + _render_value(details.get("help")), |
| 310 | + ) |
235 | 311 | if answer != details.get("default", default): |
236 | 312 | result[question] = cast_answer_type(answer, type_fn) |
237 | 313 | return result |
0 commit comments