diff --git a/copier/config/user_data.py b/copier/config/user_data.py index d0265e9ba..c2b6c93cf 100644 --- a/copier/config/user_data.py +++ b/copier/config/user_data.py @@ -2,6 +2,7 @@ import json import re +import sys from collections import ChainMap from functools import partial from pathlib import Path @@ -10,12 +11,17 @@ import yaml from jinja2 import UndefinedError from jinja2.sandbox import SandboxedEnvironment -from plumbum.cli.terminal import ask, choose, prompt -from plumbum.colors import bold, info, italics +from plumbum.cli.terminal import ask, choose +from plumbum.colors import bold, info, italics, reverse, warn +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.validation import Validator +from pygments.lexers.data import JsonLexer, YamlLexer from yamlinclude import YamlIncludeConstructor -from ..tools import get_jinja_env, printf_exception -from ..types import AnyByStrDict, Choices, OptStrOrPath, PathSeq, StrOrPath +from ..tools import force_str_end, get_jinja_env, printf_exception, to_nice_yaml +from ..types import AnyByStrDict, Choices, OptStr, OptStrOrPath, PathSeq, StrOrPath from .objects import DEFAULT_DATA, EnvOps, UserMessageError __all__ = ("load_config_data", "query_user_data") @@ -43,6 +49,73 @@ class InvalidTypeError(TypeError): pass +def ask_interactively( + question: str, + type_name: str, + type_fn: Callable, + secret: bool = False, + placeholder: OptStr = None, + default: Any = None, + choices: Any = None, + extra_help: OptStr = None, +) -> Any: + """Ask one question interactively to the user.""" + # Generate message to ask the user + message = "" + if extra_help: + message = force_str_end(f"\n{info & italics | extra_help}{message}") + emoji = "🕵️" if secret else "🎤" + message += f"{bold | question}? Format: {type_name}\n{emoji} " + lexer_map = {"json": JsonLexer, "yaml": YamlLexer} + lexer = lexer_map.get(type_name) + # Use the correct method to ask + if type_name == "bool": + return ask(message, default) + if choices: + return choose(message, choices, default) + # Hints for the multiline input user + multiline = lexer is not None + if multiline: + message += f"- Finish with {reverse | 'Esc, ↵'} or {reverse | 'Meta + ↵'}\n" + # Convert default to string + to_str_map = {"json": lambda obj: json.dumps(obj, indent=2), "yaml": to_nice_yaml} + to_str_fn = to_str_map.get(type_name, str) + # Allow placeholder YAML comments + default_str = to_str_fn(default) + if placeholder and type_name == "yaml": + prefixed_default_str = force_str_end(placeholder) + default_str + if yaml.safe_load(prefixed_default_str) == default: + default_str = prefixed_default_str + else: + print(warn | "Placeholder text alters value!", file=sys.stderr) + return prompt( + ANSI(message), + default=default_str, + is_password=secret, + lexer=lexer and PygmentsLexer(lexer), + mouse_support=True, + multiline=multiline, + validator=Validator.from_callable(abstract_validator(type_fn)), + ) + + +def abstract_validator(type_fn: Callable) -> Callable: + """Produce a validator for the given type. + + Params: + type_fn: A callable that converts text into the expected type. + """ + + def _validator(text: str): + try: + type_fn(text) + return True + except Exception: + return False + + return _validator + + def load_yaml_data( conf_path: Path, quiet: bool = False, _warning: bool = True ) -> AnyByStrDict: @@ -196,21 +269,19 @@ def query_user_data( # Get default answer answer = last_answers_data.get(question, default) if ask_this: - # Generate message to ask the user - emoji = "🕵️" if details.get("secret", False) else "🎤" - message = f"\n{bold | question}? Format: {type_name}\n{emoji} " - if details.get("help"): - message = ( - f"\n{info & italics | _render_value(details['help'])}{message}" - ) - # Use the right method to ask - if type_fn is bool: - answer = ask(message, answer) - elif details.get("choices"): - choices = _render_choices(details["choices"]) - answer = choose(message, choices, answer) - else: - answer = prompt(message, type_fn, answer) + extra_help = details.get("help") + if extra_help: + extra_help = _render_value(extra_help) + answer = ask_interactively( + question, + type_name, + type_fn, + details.get("secret", False), + _render_value(details.get("placeholder")), + answer, + _render_choices(details.get("choices")), + _render_value(details.get("help")), + ) if answer != details.get("default", default): result[question] = cast_answer_type(answer, type_fn) return result diff --git a/copier/tools.py b/copier/tools.py index fefa99f28..b5ac0763d 100644 --- a/copier/tools.py +++ b/copier/tools.py @@ -172,6 +172,18 @@ def normalize_str(text: StrOrPath, form: str = "NFD") -> str: return unicodedata.normalize(form, str(text)) +def force_str_end(original_str: str, end: str = "\n") -> str: + """Make sure a `original_str` ends with `end`. + + Params: + original_str: String that you want to ensure ending. + end: String that must exist at the end of `original_str` + """ + if not original_str.endswith(end): + return original_str + end + return original_str + + def create_path_filter(patterns: StrOrPathSeq) -> CheckPathFunc: """Returns a function that matches a path against given patterns.""" patterns = [normalize_str(p) for p in patterns] diff --git a/poetry.lock b/poetry.lock index 8832ef6ea..a6ef43581 100644 --- a/poetry.lock +++ b/poetry.lock @@ -688,13 +688,12 @@ python = "<3.7" version = "*" [[package]] -category = "dev" +category = "main" description = "Library for building powerful interactive command lines in Python" -marker = "python_version >= \"3.4\"" name = "prompt-toolkit" optional = false -python-versions = ">=3.6" -version = "3.0.3" +python-versions = ">=3.6.1" +version = "3.0.6" [package.dependencies] wcwidth = "*" @@ -1002,9 +1001,8 @@ docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sp testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] [[package]] -category = "dev" +category = "main" description = "Measures the displayed width of unicode strings in a terminal" -marker = "python_version >= \"3.4\"" name = "wcwidth" optional = false python-versions = "*" @@ -1027,9 +1025,9 @@ testing = ["jaraco.itertools", "func-timeout"] docs = ["mkdocstrings", "mkdocs-material", "pymdown-extensions"] [metadata] -content-hash = "3e912cd9956127a2e8019fe88e1d20b6643c37a19bffbbcd2df05a1fc7bc3a95" +content-hash = "6029d208164be0e6295fc8545a7a9a332c9a91ddf7e67ed1ee534fb7c5844eb8" lock-version = "1.0" -python-versions = "^3.6" +python-versions = "^3.6.1" [metadata.files] apipkg = [ @@ -1323,8 +1321,8 @@ pre-commit = [ {file = "pre_commit-2.1.1.tar.gz", hash = "sha256:f8d555e31e2051892c7f7b3ad9f620bd2c09271d87e9eedb2ad831737d6211eb"}, ] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.3-py3-none-any.whl", hash = "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"}, - {file = "prompt_toolkit-3.0.3.tar.gz", hash = "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e"}, + {file = "prompt_toolkit-3.0.6-py3-none-any.whl", hash = "sha256:683397077a64cd1f750b71c05afcfc6612a7300cb6932666531e5a54f38ea564"}, + {file = "prompt_toolkit-3.0.6.tar.gz", hash = "sha256:7630ab85a23302839a0f26b31cc24f518e6155dea1ed395ea61b42c45941b6a6"}, ] ptyprocess = [ {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, diff --git a/pyproject.toml b/pyproject.toml index 7920c2d99..97eb311b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,13 +26,15 @@ copier = "copier.cli:CopierApp.run" "Bug Tracker" = "https://github.com/pykong/copier/issues" [tool.poetry.dependencies] -python = "^3.6" +python = "^3.6.1" colorama = "^0.4.3" jinja2 = "^2.11.2" pathspec = "^0.8.0" plumbum = "^1.6.9" +prompt_toolkit = "^3.0.6" pydantic = "^1.5.1" regex = "^2020.6.8" +pygments = "^2.6.1" pyyaml = "^5.3.1" pyyaml-include = "^1.2" # packaging is needed when installing from PyPI