Skip to content

Commit de95399

Browse files
author
Jairo Llopis
committed
Allow multiline input
Fix #210.
1 parent 56bddd0 commit de95399

File tree

4 files changed

+118
-30
lines changed

4 files changed

+118
-30
lines changed

copier/config/user_data.py

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import re
5+
import sys
56
from collections import ChainMap
67
from functools import partial
78
from pathlib import Path
@@ -11,12 +12,17 @@
1112
from iteration_utilities import deepflatten
1213
from jinja2 import UndefinedError
1314
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
1622
from yamlinclude import YamlIncludeConstructor
1723

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
2026
from .objects import DEFAULT_DATA, EnvOps, UserMessageError
2127

2228
__all__ = ("load_config_data", "query_user_data")
@@ -44,6 +50,78 @@ class InvalidTypeError(TypeError):
4450
pass
4551

4652

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+
47125
def load_yaml_data(conf_path: Path, quiet: bool = False) -> AnyByStrDict:
48126
"""Load the `copier.yml` file.
49127
@@ -217,21 +295,19 @@ def query_user_data(
217295
# Get default answer
218296
answer = last_answers_data.get(question, default)
219297
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+
)
235311
if answer != details.get("default", default):
236312
result[question] = cast_answer_type(answer, type_fn)
237313
return result

copier/tools.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,18 @@ def normalize_str(text: StrOrPath, form: str = "NFD") -> str:
172172
return unicodedata.normalize(form, str(text))
173173

174174

175+
def force_str_end(original_str: str, end: str = "\n") -> str:
176+
"""Make sure a `original_str` ends with `end`.
177+
178+
Params:
179+
original_str: String that you want to ensure ending.
180+
end: String that must exist at the end of `original_str`
181+
"""
182+
if not original_str.endswith(end):
183+
return original_str + end
184+
return original_str
185+
186+
175187
def create_path_filter(patterns: StrOrPathSeq) -> CheckPathFunc:
176188
"""Returns a function that matches a path against given patterns."""
177189
patterns = [normalize_str(p) for p in patterns]

poetry.lock

Lines changed: 8 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ copier = "copier.cli:CopierApp.run"
2525
"Bug Tracker" = "https://github.com/pykong/copier/issues"
2626

2727
[tool.poetry.dependencies]
28-
python = "^3.6"
28+
python = "^3.6.1"
2929
colorama = "^0.4.3"
3030
iteration_utilities = "^0.10.1"
3131
jinja2 = "^2.11.2"
3232
pathspec = "^0.8.0"
3333
plumbum = "^1.6.9"
34+
prompt_toolkit = "^3.0.6"
3435
pydantic = "^1.5.1"
3536
regex = "^2020.6.8"
37+
pygments = "^2.6.1"
3638
pyyaml = "^5.3.1"
3739
pyyaml-include = "^1.2"
3840
# packaging is needed when installing from PyPI

0 commit comments

Comments
 (0)