Skip to content

Commit 4e1430e

Browse files
author
Jairo Llopis
committed
Use PyInquirer
1 parent de95399 commit 4e1430e

File tree

7 files changed

+381
-219
lines changed

7 files changed

+381
-219
lines changed

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ repos:
1111
# hooks running from local virtual environment
1212
- repo: local
1313
hooks:
14+
- id: autoflake
15+
name: autoflake
16+
entry: poetry run autoflake
17+
language: system
18+
types: [python]
19+
args: ["-i", "--remove-all-unused-imports", "--ignore-init-module-imports"]
1420
- id: black
1521
name: black
1622
entry: poetry run black

copier/config/factory.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,58 @@
2121
__all__ = ("make_config",)
2222

2323

24+
# def build_question(
25+
# question: str,
26+
# type_name: str,
27+
# type_fn: Callable,
28+
# secret: bool = False,
29+
# placeholder: OptStr = None,
30+
# default: Any = None,
31+
# choices: Any = None,
32+
# extra_help: OptStr = None,
33+
# ) -> AnyByStrDict:
34+
# """Build one question definition.
35+
36+
# Get the question from copier.yml and return a question definition in the
37+
# format expected by PyInquirer.
38+
# """
39+
# # Generate message to ask the user
40+
# emoji = "🕵️" if secret else "🎤"
41+
# lexer_map = {"json": JsonLexer, "yaml": YamlLexer}
42+
# lexer = lexer_map.get(type_name)
43+
# question_type = "input"
44+
# if type_name == "bool":
45+
# question_type = "confirm"
46+
# elif choices:
47+
# question_type = "list"
48+
# elif secret:
49+
# question_type = "password"
50+
# # Hints for the multiline input user
51+
# multiline = lexer is not None
52+
# # Convert default to string
53+
# to_str_map: Dict[str, Callable[[Any], str]] = {
54+
# "json": lambda obj: json.dumps(obj, indent=2),
55+
# "yaml": to_nice_yaml,
56+
# }
57+
# to_str_fn = to_str_map.get(type_name, str)
58+
# # Allow placeholder YAML comments
59+
# default_str = to_str_fn(default)
60+
# question_dict = {
61+
# "type": question_type,
62+
# "name": question,
63+
# "message": extra_help,
64+
# "default": default_str,
65+
# # "lexer": lexer and PygmentsLexer(lexer),
66+
# "mouse_support": True,
67+
# "multiline": multiline,
68+
# "validator": Validator.from_callable(abstract_validator(type_fn)),
69+
# "qmark": emoji,
70+
# }
71+
# if choices:
72+
# question_dict["choices"] = choices
73+
# return prompt([question_dict])
74+
75+
2476
def filter_config(data: AnyByStrDict) -> Tuple[AnyByStrDict, AnyByStrDict]:
2577
"""Separates config and questions data."""
2678
conf_data: AnyByStrDict = {"secret_questions": set()}
@@ -47,6 +99,10 @@ def verify_minimum_version(version_str: str) -> None:
4799
# so instead we do a lazy import here
48100
from .. import __version__
49101

102+
# Disable check when running copier as editable installation
103+
if __version__ == "0.0.0":
104+
return
105+
50106
if version.parse(__version__) < version.parse(version_str):
51107
raise UserMessageError(
52108
f"This template requires Copier version >= {version_str}, "

copier/config/objects.py

Lines changed: 235 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
"""Pydantic models, exceptions and default values."""
2-
32
import datetime
3+
import json
44
from collections import ChainMap
5+
from contextlib import suppress
56
from copy import deepcopy
67
from hashlib import sha512
78
from os import urandom
89
from pathlib import Path
9-
from typing import Any, ChainMap as t_ChainMap, Sequence, Tuple, Union
10+
from typing import (
11+
Any,
12+
Callable,
13+
ChainMap as t_ChainMap,
14+
Dict,
15+
Iterable,
16+
List,
17+
Optional,
18+
Sequence,
19+
Tuple,
20+
Union,
21+
)
1022

11-
from pydantic import BaseModel, Extra, StrictBool, validator
23+
from jinja2 import UndefinedError
24+
from jinja2.sandbox import SandboxedEnvironment
25+
from prompt_toolkit.lexers import PygmentsLexer
26+
from prompt_toolkit.validation import Validator
27+
from pydantic import BaseModel, Extra, Field, StrictBool, validator
28+
from pygments.lexers.data import JsonLexer, YamlLexer
29+
from PyInquirer.prompt import prompt
1230

31+
from ..tools import cast_answer_type, force_str_end, parse_yaml_string
1332
from ..types import AnyByStrDict, OptStr, PathSeq, StrOrPathSeq, StrSeq
1433

1534
# Default list of files in the template to exclude from the rendered project
@@ -31,12 +50,19 @@
3150

3251
DEFAULT_TEMPLATES_SUFFIX = ".tmpl"
3352

53+
TYPE_COPIER2PYTHON: Dict[str, Callable] = {
54+
"bool": bool,
55+
"float": float,
56+
"int": int,
57+
"json": json.loads,
58+
"str": str,
59+
"yaml": parse_yaml_string,
60+
}
61+
3462

3563
class UserMessageError(Exception):
3664
"""Exit the program giving a message to the user."""
3765

38-
pass
39-
4066

4167
class NoSrcPathError(UserMessageError):
4268
pass
@@ -152,3 +178,207 @@ def data(self) -> t_ChainMap[str, Any]:
152178
class Config:
153179
allow_mutation = False
154180
anystr_strip_whitespace = True
181+
182+
183+
class Question(BaseModel):
184+
choices: Union[Dict[Any, Any], List[Any]] = Field(default_factory=list)
185+
default: Any = None
186+
help_text: str = ""
187+
multiline: Optional[bool] = None
188+
placeholder: str = ""
189+
questionary: "Questionary"
190+
secret: bool = False
191+
type_name: str = ""
192+
var_name: str
193+
when: Union[str, bool] = True
194+
195+
class Config:
196+
arbitrary_types_allowed = True
197+
198+
def __init__(self, **kwargs):
199+
# Transform arguments that are named like python keywords
200+
to_rename = (("help", "help_text"), ("type", "type_name"))
201+
for from_, to in to_rename:
202+
with suppress(KeyError):
203+
kwargs.setdefault(to, kwargs.pop(from_))
204+
# Infer type from default if missing
205+
super().__init__(**kwargs)
206+
self.questionary.questions.append(self)
207+
208+
def __repr__(self):
209+
return f"Question({self.var_name})"
210+
211+
@validator("var_name")
212+
def _check_var_name(cls, v):
213+
if v in DEFAULT_DATA:
214+
raise ValueError("Invalid question name")
215+
return v
216+
217+
@validator("type_name", always=True)
218+
def _check_type_name(cls, v, values):
219+
if v == "":
220+
default_type_name = type(values.get("default")).__name__
221+
v = default_type_name if default_type_name in TYPE_COPIER2PYTHON else "yaml"
222+
if v not in TYPE_COPIER2PYTHON:
223+
raise ValueError("Invalid question type")
224+
return v
225+
226+
def _iter_choices(self) -> Iterable[dict]:
227+
choices = self.choices
228+
if isinstance(self.choices, dict):
229+
choices = list(self.choices.items())
230+
for choice in choices:
231+
# If a choice is a dict, it can be used raw
232+
if isinstance(choice, dict):
233+
yield choice
234+
continue
235+
# However, a choice can also be a single value...
236+
name = value = choice
237+
# ... or a value pair
238+
if isinstance(choice, (tuple, list)):
239+
name, value = choice
240+
# The name must always be a str
241+
name = str(name)
242+
yield {"name": name, "value": value}
243+
244+
def get_default(self) -> Union[str, bool]:
245+
try:
246+
result = self.questionary.answers_forced.get(
247+
self.var_name, self.questionary.answers_last[self.var_name]
248+
)
249+
except KeyError:
250+
result = self.render_value(self.default)
251+
result = cast_answer_type(result, self.get_type_fn())
252+
if self.type_name == "bool":
253+
return bool(result)
254+
if result is None:
255+
return ""
256+
return str(result)
257+
258+
def get_choices(self) -> List[AnyByStrDict]:
259+
result = []
260+
for choice in self._iter_choices():
261+
formatted_choice = {
262+
key: self.render_value(value) for key, value in choice.items()
263+
}
264+
result.append(formatted_choice)
265+
return result
266+
267+
def get_filter(self, answer) -> Any:
268+
return cast_answer_type(answer, self.get_type_fn())
269+
270+
def get_message(self) -> str:
271+
message = ""
272+
if self.help_text:
273+
rendered_help = self.render_value(self.help_text)
274+
message = force_str_end(rendered_help)
275+
message += f"{self.var_name}? Format: {self.type_name}"
276+
return message
277+
278+
def get_placeholder(self) -> str:
279+
return self.render_value(self.placeholder)
280+
281+
def get_pyinquirer_structure(self):
282+
lexer = None
283+
result = {
284+
"default": self.get_default(),
285+
"filter": self.get_filter,
286+
"message": self.get_message(),
287+
"mouse_support": True,
288+
"name": self.var_name,
289+
"qmark": "🕵️" if self.secret else "🎤",
290+
"validator": Validator.from_callable(self.get_validator),
291+
"when": self.get_when,
292+
}
293+
multiline = self.multiline
294+
pyinquirer_type = "input"
295+
if self.type_name == "bool":
296+
pyinquirer_type = "confirm"
297+
if self.choices:
298+
pyinquirer_type = "list"
299+
result["choices"] = self.get_choices()
300+
if pyinquirer_type == "input":
301+
if self.secret:
302+
pyinquirer_type = "password"
303+
elif self.type_name == "yaml":
304+
lexer = PygmentsLexer(YamlLexer)
305+
elif self.type_name == "json":
306+
lexer = PygmentsLexer(JsonLexer)
307+
placeholder = self.get_placeholder()
308+
if placeholder:
309+
result["placeholder"] = placeholder
310+
multiline = multiline or (
311+
multiline is None and self.type_name in {"yaml", "json"}
312+
)
313+
result.update({"type": pyinquirer_type, "lexer": lexer, "multiline": multiline})
314+
from pprint import pprint
315+
316+
pprint(result)
317+
return result
318+
319+
def get_type_fn(self) -> Callable:
320+
return TYPE_COPIER2PYTHON.get(self.type_name, parse_yaml_string)
321+
322+
def get_validator(self, document) -> bool:
323+
type_fn = self.get_type_fn()
324+
try:
325+
type_fn(document)
326+
return True
327+
except Exception:
328+
return False
329+
330+
def get_when(self, answers) -> bool:
331+
if (
332+
# Skip on --force
333+
not self.questionary.ask_user
334+
# Skip on --data=this_question=some_answer
335+
or self.var_name in self.questionary.answers_forced
336+
):
337+
return False
338+
when = self.when
339+
when = self.render_value(when)
340+
when = cast_answer_type(when, parse_yaml_string)
341+
return bool(when)
342+
343+
def render_value(self, value: Any) -> str:
344+
"""Render a single templated value using Jinja.
345+
346+
If the value cannot be used as a template, it will be returned as is.
347+
"""
348+
try:
349+
template = self.questionary.env.from_string(value)
350+
except TypeError:
351+
# value was not a string
352+
return value
353+
try:
354+
return template.render(**self.questionary.get_best_answers())
355+
except UndefinedError as error:
356+
raise UserMessageError(str(error)) from error
357+
358+
359+
class Questionary(BaseModel):
360+
answers_forced: AnyByStrDict = Field(default_factory=dict)
361+
answers_last: AnyByStrDict = Field(default_factory=dict)
362+
answers_user: AnyByStrDict = Field(default_factory=dict)
363+
ask_user: bool = True
364+
env: SandboxedEnvironment
365+
questions: List[Question] = Field(default_factory=list)
366+
367+
class Config:
368+
arbitrary_types_allowed = True
369+
370+
def __init__(self, **kwargs):
371+
super().__init__(**kwargs)
372+
373+
def get_best_answers(self) -> t_ChainMap[str, Any]:
374+
return ChainMap(self.answers_user, self.answers_last, self.answers_forced)
375+
376+
def get_answers(self) -> AnyByStrDict:
377+
return prompt(
378+
(question.get_pyinquirer_structure() for question in self.questions),
379+
answers=self.answers_user,
380+
raise_keyboard_interrupt=True,
381+
)
382+
383+
384+
Question.update_forward_refs()

0 commit comments

Comments
 (0)