Skip to content

Commit 05deee3

Browse files
author
Jairo Llopis
committed
Allow multiline input
Fix #210. @Tecnativa TT23705
1 parent df33b29 commit 05deee3

4 files changed

Lines changed: 113 additions & 30 deletions

File tree

copier/config/user_data.py

Lines changed: 90 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
@@ -10,12 +11,17 @@
1011
import yaml
1112
from jinja2 import UndefinedError
1213
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
1521
from yamlinclude import YamlIncludeConstructor
1622

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

2127
__all__ = ("load_config_data", "query_user_data")
@@ -43,6 +49,73 @@ class InvalidTypeError(TypeError):
4349
pass
4450

4551

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+
46119
def load_yaml_data(
47120
conf_path: Path, quiet: bool = False, _warning: bool = True
48121
) -> AnyByStrDict:
@@ -196,21 +269,19 @@ def query_user_data(
196269
# Get default answer
197270
answer = last_answers_data.get(question, default)
198271
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+
)
214285
if answer != details.get("default", default):
215286
result[question] = cast_answer_type(answer, type_fn)
216287
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
@@ -26,13 +26,15 @@ copier = "copier.cli:CopierApp.run"
2626
"Bug Tracker" = "https://github.com/pykong/copier/issues"
2727

2828
[tool.poetry.dependencies]
29-
python = "^3.6"
29+
python = "^3.6.1"
3030
colorama = "^0.4.3"
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)