Skip to content

Commit

Permalink
Use PyInquirer
Browse files Browse the repository at this point in the history
  • Loading branch information
Jairo Llopis committed Sep 24, 2020
1 parent de95399 commit 5fe0683
Show file tree
Hide file tree
Showing 7 changed files with 356 additions and 223 deletions.
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ repos:
# hooks running from local virtual environment
- repo: local
hooks:
- id: autoflake
name: autoflake
entry: poetry run autoflake
language: system
types: [python]
args: ["-i", "--remove-all-unused-imports", "--ignore-init-module-imports"]
- id: black
name: black
entry: poetry run black
Expand Down
251 changes: 246 additions & 5 deletions copier/config/objects.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
"""Pydantic models, exceptions and default values."""

import datetime
import json
from collections import ChainMap
from contextlib import suppress
from copy import deepcopy
from hashlib import sha512
from os import urandom
from pathlib import Path
from typing import Any, ChainMap as t_ChainMap, Sequence, Tuple, Union
from typing import (
Any,
Callable,
ChainMap as t_ChainMap,
Dict,
Iterable,
List,
Optional,
Sequence,
Tuple,
Union,
)

from pydantic import BaseModel, Extra, StrictBool, validator
from jinja2 import UndefinedError
from jinja2.sandbox import SandboxedEnvironment
from prompt_toolkit.lexers import PygmentsLexer
from prompt_toolkit.validation import Validator
from pydantic import BaseModel, Extra, Field, StrictBool, validator
from pygments.lexers.data import JsonLexer, YamlLexer
from PyInquirer.prompt import prompt

from ..tools import cast_answer_type, force_str_end, parse_yaml_string
from ..types import AnyByStrDict, OptStr, PathSeq, StrOrPathSeq, StrSeq

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

DEFAULT_TEMPLATES_SUFFIX = ".tmpl"

TYPE_COPIER2PYTHON: Dict[str, Callable] = {
"bool": bool,
"float": float,
"int": int,
"json": json.loads,
"str": str,
"yaml": parse_yaml_string,
}


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

pass


class NoSrcPathError(UserMessageError):
pass
Expand Down Expand Up @@ -152,3 +178,218 @@ def data(self) -> t_ChainMap[str, Any]:
class Config:
allow_mutation = False
anystr_strip_whitespace = True


class Question(BaseModel):
choices: Union[Dict[Any, Any], List[Any]] = Field(default_factory=list)
default: Any = None
help_text: str = ""
multiline: Optional[bool] = None
placeholder: str = ""
questionary: "Questionary"
secret: bool = False
type_name: str = ""
var_name: str
when: Union[str, bool] = True

class Config:
arbitrary_types_allowed = True

def __init__(self, **kwargs):
# Transform arguments that are named like python keywords
to_rename = (("help", "help_text"), ("type", "type_name"))
for from_, to in to_rename:
with suppress(KeyError):
kwargs.setdefault(to, kwargs.pop(from_))
# Infer type from default if missing
super().__init__(**kwargs)
self.questionary.questions.append(self)

def __repr__(self):
return f"Question({self.var_name})"

@validator("var_name")
def _check_var_name(cls, v):
if v in DEFAULT_DATA:
raise ValueError("Invalid question name")
return v

@validator("type_name", always=True)
def _check_type_name(cls, v, values):
if v == "":
default_type_name = type(values.get("default")).__name__
v = default_type_name if default_type_name in TYPE_COPIER2PYTHON else "yaml"
if v not in TYPE_COPIER2PYTHON:
raise ValueError("Invalid question type")
return v

def _iter_choices(self) -> Iterable[dict]:
choices = self.choices
if isinstance(self.choices, dict):
choices = list(self.choices.items())
for choice in choices:
# If a choice is a dict, it can be used raw
if isinstance(choice, dict):
yield choice
continue
# However, a choice can also be a single value...
name = value = choice
# ... or a value pair
if isinstance(choice, (tuple, list)):
name, value = choice
# The name must always be a str
name = str(name)
yield {"name": name, "value": value}

def get_default(self, autocast: bool) -> Any:
try:
result = self.questionary.answers_forced.get(
self.var_name, self.questionary.answers_last[self.var_name]
)
except KeyError:
result = self.render_value(self.default)
result = cast_answer_type(result, self.get_type_fn())
if not autocast:
return result
if self.type_name == "bool":
return bool(result)
if result is None:
return ""
return str(result)

def get_choices(self) -> List[AnyByStrDict]:
result = []
for choice in self._iter_choices():
formatted_choice = {
key: self.render_value(value) for key, value in choice.items()
}
result.append(formatted_choice)
return result

def get_filter(self, answer) -> Any:
if answer == self.get_default(autocast=True):
return self.get_default()
return cast_answer_type(answer, self.get_type_fn())

def get_message(self) -> str:
message = ""
if self.help_text:
rendered_help = self.render_value(self.help_text)
message = force_str_end(rendered_help)
message += f"{self.var_name}? Format: {self.type_name}"
return message

def get_placeholder(self) -> str:
return self.render_value(self.placeholder)

def get_pyinquirer_structure(self):
lexer = None
result = {
"default": self.get_default(autocast=True),
"filter": self.get_filter,
"message": self.get_message(),
"mouse_support": True,
"name": self.var_name,
"qmark": "🕵️" if self.secret else "🎤",
"validator": Validator.from_callable(self.get_validator),
"when": self.get_when,
}
multiline = self.multiline
pyinquirer_type = "input"
if self.type_name == "bool":
pyinquirer_type = "confirm"
if self.choices:
pyinquirer_type = "list"
result["choices"] = self.get_choices()
if pyinquirer_type == "input":
if self.secret:
pyinquirer_type = "password"
elif self.type_name == "yaml":
lexer = PygmentsLexer(YamlLexer)
elif self.type_name == "json":
lexer = PygmentsLexer(JsonLexer)
placeholder = self.get_placeholder()
if placeholder:
result["placeholder"] = placeholder
multiline = multiline or (
multiline is None and self.type_name in {"yaml", "json"}
)
result.update({"type": pyinquirer_type, "lexer": lexer, "multiline": multiline})
return result

def get_type_fn(self) -> Callable:
return TYPE_COPIER2PYTHON.get(self.type_name, parse_yaml_string)

def get_validator(self, document) -> bool:
type_fn = self.get_type_fn()
try:
type_fn(document)
return True
except Exception:
return False

def get_when(self, answers) -> bool:
if (
# Skip on --force
not self.questionary.ask_user
# Skip on --data=this_question=some_answer
or self.var_name in self.questionary.answers_forced
):
return False
when = self.when
when = self.render_value(when)
when = cast_answer_type(when, parse_yaml_string)
return bool(when)

def render_value(self, value: Any) -> str:
"""Render a single templated value using Jinja.
If the value cannot be used as a template, it will be returned as is.
"""
try:
template = self.questionary.env.from_string(value)
except TypeError:
# value was not a string
return value
try:
return template.render(**self.questionary.get_best_answers())
except UndefinedError as error:
raise UserMessageError(str(error)) from error


class Questionary(BaseModel):
answers_forced: AnyByStrDict = Field(default_factory=dict)
answers_last: AnyByStrDict = Field(default_factory=dict)
answers_user: AnyByStrDict = Field(default_factory=dict)
ask_user: bool = True
env: SandboxedEnvironment
questions: List[Question] = Field(default_factory=list)

class Config:
arbitrary_types_allowed = True

def __init__(self, **kwargs):
super().__init__(**kwargs)

def get_best_answers(self) -> t_ChainMap[str, Any]:
return ChainMap(self.answers_user, self.answers_last, self.answers_forced)

def get_answers(self) -> AnyByStrDict:
if self.ask_user:
prompt(
(question.get_pyinquirer_structure() for question in self.questions),
answers=self.answers_user,
raise_keyboard_interrupt=True,
)
else:
# Avoid prompting to not requiring a TTy when --force
self.answers_user.update(
{
question.var_name: question.get_default(autocast=False)
for question in self.questions
}
)
return self.answers_user


Question.update_forward_refs()
Loading

0 comments on commit 5fe0683

Please sign in to comment.