"""Functions for prompting the user for project info."""
from __future__ import annotations
import json
import os
import re
import sys
from collections import OrderedDict
from itertools import starmap
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Union
from jinja2.exceptions import UndefinedError
from rich.prompt import Confirm, InvalidResponse, Prompt, PromptBase
from typing_extensions import TypeAlias
from cookiecutter.exceptions import UndefinedVariableInTemplate
from cookiecutter.utils import create_env_with_context, rmtree
if TYPE_CHECKING:
from jinja2 import Environment
[docs]
def read_user_variable(var_name: str, default_value, prompts=None, prefix: str = ""):
"""Prompt user for variable and return the entered value or given default.
:param str var_name: Variable of the context to query the user
:param default_value: Value that will be returned if no input happens
"""
question = (
prompts[var_name]
if prompts and var_name in prompts and prompts[var_name]
else var_name
)
while True:
variable = Prompt.ask(f"{prefix}{question}", default=default_value)
if variable is not None:
break
return variable
[docs]
class YesNoPrompt(Confirm):
"""A prompt that returns a boolean for yes/no questions."""
yes_choices = ["1", "true", "t", "yes", "y", "on"]
no_choices = ["0", "false", "f", "no", "n", "off"]
[docs]
def process_response(self, value: str) -> bool:
"""Convert choices to a bool."""
value = value.strip().lower()
if value in self.yes_choices:
return True
elif value in self.no_choices:
return False
else:
raise InvalidResponse(self.validate_error_message)
[docs]
def read_user_yes_no(var_name, default_value, prompts=None, prefix: str = ""):
"""Prompt the user to reply with 'yes' or 'no' (or equivalent values).
- These input values will be converted to ``True``:
"1", "true", "t", "yes", "y", "on"
- These input values will be converted to ``False``:
"0", "false", "f", "no", "n", "off"
Actual parsing done by :func:`prompt`; Check this function codebase change in
case of unexpected behaviour.
:param str question: Question to the user
:param default_value: Value that will be returned if no input happens
"""
question = (
prompts[var_name]
if prompts and var_name in prompts and prompts[var_name]
else var_name
)
return YesNoPrompt.ask(f"{prefix}{question}", default=default_value)
[docs]
def read_repo_password(question: str) -> str:
"""Prompt the user to enter a password.
:param question: Question to the user
"""
return Prompt.ask(question, password=True)
[docs]
def read_user_choice(var_name: str, options: list, prompts=None, prefix: str = ""):
"""Prompt the user to choose from several options for the given variable.
The first item will be returned if no input happens.
:param var_name: Variable as specified in the context
:param list options: Sequence of options that are available to select from
:return: Exactly one item of ``options`` that has been chosen by the user
"""
if not options:
raise ValueError
choice_map = OrderedDict((f'{i}', value) for i, value in enumerate(options, 1))
choices = choice_map.keys()
question = f"Select {var_name}"
choice_lines: Iterator[str] = starmap(
" [bold magenta]{}[/] - [bold]{}[/]".format, choice_map.items()
)
# Handle if human-readable prompt is provided
if prompts and var_name in prompts:
if isinstance(prompts[var_name], str):
question = prompts[var_name]
else:
if "__prompt__" in prompts[var_name]:
question = prompts[var_name]["__prompt__"]
choice_lines = (
f" [bold magenta]{i}[/] - [bold]{prompts[var_name][p]}[/]"
if p in prompts[var_name]
else f" [bold magenta]{i}[/] - [bold]{p}[/]"
for i, p in choice_map.items()
)
prompt = '\n'.join(
(
f"{prefix}{question}",
"\n".join(choice_lines),
" Choose from",
)
)
user_choice = Prompt.ask(prompt, choices=list(choices), default=next(iter(choices)))
return choice_map[user_choice]
DEFAULT_DISPLAY = 'default'
[docs]
def process_json(user_value: str):
"""Load user-supplied value as a JSON dict.
:param user_value: User-supplied value to load as a JSON dict
"""
try:
user_dict = json.loads(user_value, object_pairs_hook=OrderedDict)
except Exception as error:
# Leave it up to click to ask the user again
raise InvalidResponse('Unable to decode to JSON.') from error
if not isinstance(user_dict, dict):
# Leave it up to click to ask the user again
raise InvalidResponse('Requires JSON dict.')
return user_dict
[docs]
class JsonPrompt(PromptBase[dict]):
"""A prompt that returns a dict from JSON string."""
default = None
response_type = dict
validate_error_message = "[prompt.invalid] Please enter a valid JSON string"
[docs]
@staticmethod
def process_response(value: str) -> dict[str, Any]:
"""Convert choices to a dict."""
return process_json(value)
[docs]
def read_user_dict(var_name: str, default_value, prompts=None, prefix: str = ""):
"""Prompt the user to provide a dictionary of data.
:param var_name: Variable as specified in the context
:param default_value: Value that will be returned if no input is provided
:return: A Python dictionary to use in the context.
"""
if not isinstance(default_value, dict):
raise TypeError
question = (
prompts[var_name]
if prompts and var_name in prompts and prompts[var_name]
else var_name
)
user_value = JsonPrompt.ask(
f"{prefix}{question} [cyan bold]({DEFAULT_DISPLAY})[/]",
default=default_value,
show_default=False,
)
return user_value
_Raw: TypeAlias = Union[bool, Dict["_Raw", "_Raw"], List["_Raw"], str, None]
[docs]
def render_variable(
env: Environment,
raw: _Raw,
cookiecutter_dict: dict[str, Any],
) -> str:
"""Render the next variable to be displayed in the user prompt.
Inside the prompting taken from the cookiecutter.json file, this renders
the next variable. For example, if a project_name is "Peanut Butter
Cookie", the repo_name could be be rendered with:
`{{ cookiecutter.project_name.replace(" ", "_") }}`.
This is then presented to the user as the default.
:param Environment env: A Jinja2 Environment object.
:param raw: The next value to be prompted for by the user.
:param dict cookiecutter_dict: The current context as it's gradually
being populated with variables.
:return: The rendered value for the default variable.
"""
if raw is None or isinstance(raw, bool):
return raw
elif isinstance(raw, dict):
return {
render_variable(env, k, cookiecutter_dict): render_variable(
env, v, cookiecutter_dict
)
for k, v in raw.items()
}
elif isinstance(raw, list):
return [render_variable(env, v, cookiecutter_dict) for v in raw]
elif not isinstance(raw, str):
raw = str(raw)
template = env.from_string(raw)
return template.render(cookiecutter=cookiecutter_dict)
def _prompts_from_options(options: dict) -> dict:
"""Process template options and return friendly prompt information."""
prompts = {"__prompt__": "Select a template"}
for option_key, option_value in options.items():
title = str(option_value.get("title", option_key))
description = option_value.get("description", option_key)
label = title if title == description else f"{title} ({description})"
prompts[option_key] = label
return prompts
[docs]
def prompt_choice_for_template(
key: str, options: dict, no_input: bool
) -> OrderedDict[str, Any]:
"""Prompt user with a set of options to choose from.
:param no_input: Do not prompt for user input and return the first available option.
"""
opts = list(options.keys())
prompts = {"templates": _prompts_from_options(options)}
return opts[0] if no_input else read_user_choice(key, opts, prompts, "")
[docs]
def prompt_choice_for_config(
cookiecutter_dict: dict[str, Any],
env: Environment,
key: str,
options,
no_input: bool,
prompts=None,
prefix: str = "",
) -> OrderedDict[str, Any] | str:
"""Prompt user with a set of options to choose from.
:param no_input: Do not prompt for user input and return the first available option.
"""
rendered_options = [render_variable(env, raw, cookiecutter_dict) for raw in options]
if no_input:
return rendered_options[0]
return read_user_choice(key, rendered_options, prompts, prefix)
[docs]
def prompt_for_config(
context: dict[str, Any], no_input: bool = False
) -> OrderedDict[str, Any]:
"""Prompt user to enter a new config.
:param dict context: Source for field names and sample values.
:param no_input: Do not prompt for user input and use only values from context.
"""
cookiecutter_dict = OrderedDict([])
env = create_env_with_context(context)
prompts = context['cookiecutter'].pop('__prompts__', {})
# First pass: Handle simple and raw variables, plus choices.
# These must be done first because the dictionaries keys and
# values might refer to them.
count = 0
all_prompts = context['cookiecutter'].items()
visible_prompts = [k for k, _ in all_prompts if not k.startswith("_")]
size = len(visible_prompts)
for key, raw in all_prompts:
if key.startswith('_') and not key.startswith('__'):
cookiecutter_dict[key] = raw
continue
elif key.startswith('__'):
cookiecutter_dict[key] = render_variable(env, raw, cookiecutter_dict)
continue
if not isinstance(raw, dict):
count += 1
prefix = f" [dim][{count}/{size}][/] "
try:
if isinstance(raw, list):
# We are dealing with a choice variable
val = prompt_choice_for_config(
cookiecutter_dict, env, key, raw, no_input, prompts, prefix
)
cookiecutter_dict[key] = val
elif isinstance(raw, bool):
# We are dealing with a boolean variable
if no_input:
cookiecutter_dict[key] = render_variable(
env, raw, cookiecutter_dict
)
else:
cookiecutter_dict[key] = read_user_yes_no(key, raw, prompts, prefix)
elif not isinstance(raw, dict):
# We are dealing with a regular variable
val = render_variable(env, raw, cookiecutter_dict)
if not no_input:
val = read_user_variable(key, val, prompts, prefix)
cookiecutter_dict[key] = val
except UndefinedError as err:
msg = f"Unable to render variable '{key}'"
raise UndefinedVariableInTemplate(msg, err, context) from err
# Second pass; handle the dictionaries.
for key, raw in context['cookiecutter'].items():
# Skip private type dicts not to be rendered.
if key.startswith('_') and not key.startswith('__'):
continue
try:
if isinstance(raw, dict):
# We are dealing with a dict variable
count += 1
prefix = f" [dim][{count}/{size}][/] "
val = render_variable(env, raw, cookiecutter_dict)
if not no_input and not key.startswith('__'):
val = read_user_dict(key, val, prompts, prefix)
cookiecutter_dict[key] = val
except UndefinedError as err:
msg = f"Unable to render variable '{key}'"
raise UndefinedVariableInTemplate(msg, err, context) from err
return cookiecutter_dict
[docs]
def choose_nested_template(
context: dict[str, Any], repo_dir: Path | str, no_input: bool = False
) -> str:
"""Prompt user to select the nested template to use.
:param context: Source for field names and sample values.
:param repo_dir: Repository directory.
:param no_input: Do not prompt for user input and use only values from context.
:returns: Path to the selected template.
"""
cookiecutter_dict: OrderedDict[str, Any] = OrderedDict([])
env = create_env_with_context(context)
prefix = ""
prompts = context['cookiecutter'].pop('__prompts__', {})
key = "templates"
config = context['cookiecutter'].get(key, {})
if config:
# Pass
val = prompt_choice_for_template(key, config, no_input)
template = config[val]["path"]
else:
# Old style
key = "template"
config = context['cookiecutter'].get(key, [])
val = prompt_choice_for_config(
cookiecutter_dict, env, key, config, no_input, prompts, prefix
)
template = re.search(r'\((.+)\)', val).group(1)
template = Path(template) if template else None
if not (template and not template.is_absolute()):
raise ValueError("Illegal template path")
repo_dir = Path(repo_dir).resolve()
template_path = (repo_dir / template).resolve()
# Return path as string
return f"{template_path}"
[docs]
def prompt_and_delete(path: Path | str, no_input: bool = False) -> bool:
"""
Ask user if it's okay to delete the previously-downloaded file/directory.
If yes, delete it. If no, checks to see if the old version should be
reused. If yes, it's reused; otherwise, Cookiecutter exits.
:param path: Previously downloaded zipfile.
:param no_input: Suppress prompt to delete repo and just delete it.
:return: True if the content was deleted
"""
# Suppress prompt if called via API
if no_input:
ok_to_delete = True
else:
question = (
f"You've downloaded {path} before. Is it okay to delete and re-download it?"
)
ok_to_delete = read_user_yes_no(question, 'yes')
if ok_to_delete:
if os.path.isdir(path):
rmtree(path)
else:
os.remove(path)
return True
else:
ok_to_reuse = read_user_yes_no(
"Do you want to re-use the existing version?", 'yes'
)
if ok_to_reuse:
return False
sys.exit()