Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve style of prompts using rich #1901

Merged
merged 15 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/ISSUE_TEMPLATE/bug.yml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you remove this file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, that was not supposed to be pushed in this branch! Well spotted, I removed it @ericof

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@

name: 🐞 Bug Report
title: '🐞 '
description: Report a bug
labels: ['type: bug', 'status: needs triage']

body:
- type: markdown
attributes:
value: |
Please search for [existing issues](https://github.com/cookiecutter/cookiecutter/issues?q=is%3Aissue) about this problem first.

- type: input
id: cookiecutter-version
attributes:
label: Cookiecutter version
validations:
required: true

- type: input
id: python-version
attributes:
label: Python version
validations:
required: true

- type: input
id: os
attributes:
label: Operating System
validations:
required: true

- type: input
id: template-project-url
attributes:
label: Template project URL
description: URL to the project template with which the bug happens

- type: textarea
id: description
attributes:
label: Describe the bug
description: What are you trying to get done, what has happened, what went wrong, and what did you expect?
placeholder: Bug description
validations:
required: true

- type: textarea
id: reproduction
attributes:
label: Reproduction
description: A link to a reproduction repo or steps to reproduce the behaviour.
placeholder: |
Paste a log of command(s) you ran and cookiecutter's output, tracebacks, etc, here

- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Projects are generated to your current directory or to the target directory if s
```py
{{cookiecutter.repo_name}}/{{cookiecutter.repo_name}}/{{cookiecutter.repo_name}}.py
```
- Simply define your template variables in a `cookiecutter.json` file. You can also add human-readable questions and choices that will be prompted to the user for each variable using the `__prompts__` key.
- Simply define your template variables in a `cookiecutter.json` file. You can also add human-readable questions and choices that will be prompted to the user for each variable using the `__prompts__` key. Those human-readable questions supports [`rich` markup](https://rich.readthedocs.io/en/stable/markup.html) such as `[bold yellow]this is bold and yellow[/]`
For example:

```json
Expand All @@ -128,10 +128,10 @@ Projects are generated to your current directory or to the target directory if s
"version": "0.1.1",
"linting": ["ruff", "flake8", "none"],
"__prompts__": {
"full_name": "Provide your full name",
"email": "Provide your email",
"full_name": "Provide your [bold yellow]full name[/]",
"email": "Provide your [bold yellow]email[/]",
"linting": {
"__prompt__": "Which linting tool do you want to use?",
"__prompt__": "Which [bold yellow]linting tool[/] do you want to use?",
"ruff": "Ruff",
"flake8": "Flake8",
"none": "No linting tool"
Expand Down
108 changes: 68 additions & 40 deletions cookiecutter/prompt.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
"""Functions for prompting the user for project info."""
import functools
import json
from collections import OrderedDict

import click
from rich.prompt import Prompt, Confirm, PromptBase, InvalidResponse
from jinja2.exceptions import UndefinedError

from cookiecutter.environment import StrictEnvironment
from cookiecutter.exceptions import UndefinedVariableInTemplate


def read_user_variable(var_name, default_value, prompts=None):
def read_user_variable(var_name, default_value, prompts=None, prefix=""):
"""Prompt user for variable and return the entered value or given default.

:param str var_name: Variable of the context to query the user
Expand All @@ -21,18 +20,35 @@ def read_user_variable(var_name, default_value, prompts=None):
if prompts and var_name in prompts.keys() and prompts[var_name]
else var_name
)
return click.prompt(question, default=default_value)
return Prompt.ask(f"{prefix}{question}", default=default_value)


def read_user_yes_no(var_name, default_value, prompts=None):
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"]

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)


def read_user_yes_no(var_name, default_value, prompts=None, prefix=""):
"""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:`click.prompt`; Check this function codebase change in
Actual parsing done by :func:`prompt`; Check this function codebase change in
case of unexpected behaviour.

:param str question: Question to the user
Expand All @@ -43,18 +59,18 @@ def read_user_yes_no(var_name, default_value, prompts=None):
if prompts and var_name in prompts.keys() and prompts[var_name]
else var_name
)
return click.prompt(question, default=default_value, type=click.BOOL)
return YesNoPrompt.ask(f"{prefix}{question}", default=default_value)


def read_repo_password(question):
"""Prompt the user to enter a password.

:param str question: Question to the user
"""
return click.prompt(question, hide_input=True)
return Prompt.ask(question, password=True)


def read_user_choice(var_name, options, prompts=None):
def read_user_choice(var_name, options, prompts=None, prefix=""):
"""Prompt the user to choose from several options for the given variable.

The first item will be returned if no input happens.
Expand All @@ -71,9 +87,11 @@ def read_user_choice(var_name, options, prompts=None):

choice_map = OrderedDict((f'{i}', value) for i, value in enumerate(options, 1))
choices = choice_map.keys()
default = '1'

question = f"Select {var_name}"
choice_lines = ['{} - {}'.format(*c) for c in choice_map.items()]
choice_lines = [
' [bold magenta]{}[/] - [bold]{}[/]'.format(*c) for c in choice_map.items()
]

# Handle if human-readable prompt is provided
if prompts and var_name in prompts.keys():
Expand All @@ -83,23 +101,21 @@ def read_user_choice(var_name, options, prompts=None):
if "__prompt__" in prompts[var_name]:
question = prompts[var_name]["__prompt__"]
choice_lines = [
f"{i} - {prompts[var_name][p]}"
f" [bold magenta]{i}[/] - [bold]{prompts[var_name][p]}[/]"
if p in prompts[var_name]
else f"{i} - {p}"
else f" [bold magenta]{i}[/] - [bold]{p}[/]"
for i, p in choice_map.items()
]

prompt = '\n'.join(
(
f"{question}:",
f"{prefix}{question}",
"\n".join(choice_lines),
f"Choose from {', '.join(choices)}",
" Choose from",
)
)

user_choice = click.prompt(
prompt, type=click.Choice(choices), default=default, show_choices=False
)
user_choice = Prompt.ask(prompt, choices=list(choices), default=list(choices)[0])
return choice_map[user_choice]


Expand All @@ -111,24 +127,32 @@ def process_json(user_value, default_value=None):

:param str user_value: User-supplied value to load as a JSON dict
"""
if user_value == DEFAULT_DISPLAY:
# Return the given default w/o any processing
return default_value

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 click.UsageError('Unable to decode to JSON.') from error
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 click.UsageError('Requires JSON dict.')
raise InvalidResponse('Requires JSON dict.')

return user_dict


def read_user_dict(var_name, default_value, prompts=None):
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"

def process_response(self, value: str) -> dict:
"""Convert choices to a dict."""
return process_json(value, self.default)


def read_user_dict(var_name, default_value, prompts=None, prefix=""):
"""Prompt the user to provide a dictionary of data.

:param str var_name: Variable as specified in the context
Expand All @@ -143,16 +167,11 @@ def read_user_dict(var_name, default_value, prompts=None):
if prompts and var_name in prompts.keys() and prompts[var_name]
else var_name
)
user_value = click.prompt(
question,
default=DEFAULT_DISPLAY,
type=click.STRING,
value_proc=functools.partial(process_json, default_value=default_value),
user_value = JsonPrompt.ask(
f"{prefix}{question} [cyan bold]({DEFAULT_DISPLAY})[/]",
default=default_value,
show_default=False,
)

if click.__version__.startswith("7.") and user_value == DEFAULT_DISPLAY:
# click 7.x does not invoke value_proc on the default value.
return default_value # pragma: no cover
return user_value


Expand Down Expand Up @@ -193,7 +212,7 @@ def render_variable(env, raw, cookiecutter_dict):


def prompt_choice_for_config(
cookiecutter_dict, env, key, options, no_input, prompts=None
cookiecutter_dict, env, key, options, no_input, prompts=None, prefix=""
):
"""Prompt user with a set of options to choose from.

Expand All @@ -202,7 +221,7 @@ def prompt_choice_for_config(
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)
return read_user_choice(key, rendered_options, prompts, prefix)


def prompt_for_config(context, no_input=False):
Expand All @@ -222,6 +241,9 @@ def prompt_for_config(context, no_input=False):
# 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
size = len(context['cookiecutter'].items())
for key, raw in context['cookiecutter'].items():
if key.startswith('_') and not key.startswith('__'):
cookiecutter_dict[key] = raw
Expand All @@ -230,11 +252,15 @@ def prompt_for_config(context, no_input=False):
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
cookiecutter_dict, env, key, raw, no_input, prompts, prefix
)
cookiecutter_dict[key] = val
elif isinstance(raw, bool):
Expand All @@ -244,13 +270,13 @@ def prompt_for_config(context, no_input=False):
env, raw, cookiecutter_dict
)
else:
cookiecutter_dict[key] = read_user_yes_no(key, raw, prompts)
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)
val = read_user_variable(key, val, prompts, prefix)

cookiecutter_dict[key] = val
except UndefinedError as err:
Expand All @@ -266,10 +292,12 @@ def prompt_for_config(context, no_input=False):
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)
val = read_user_dict(key, val, prompts, prefix)

cookiecutter_dict[key] = val
except UndefinedError as err:
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def _get_version() -> str:
'python-slugify>=4.0.0',
'requests>=2.23.0',
'arrow',
'rich',
]

setup(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cookiecutter_local_with_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_cookiecutter_local_with_input(monkeypatch):
"""Verify simple cookiecutter run results, without extra_context provided."""
monkeypatch.setattr(
'cookiecutter.prompt.read_user_variable',
lambda var, default, prompts: default,
lambda var, default, prompts, prefix: default,
)
main.cookiecutter('tests/fake-repo-pre/', no_input=False)
assert os.path.isdir('tests/fake-repo-pre/{{cookiecutter.repo_name}}')
Expand All @@ -36,7 +36,7 @@ def test_cookiecutter_input_extra_context(monkeypatch):
"""Verify simple cookiecutter run results, with extra_context provided."""
monkeypatch.setattr(
'cookiecutter.prompt.read_user_variable',
lambda var, default, prompts: default,
lambda var, default, prompts, prefix: default,
)
main.cookiecutter(
'tests/fake-repo-pre',
Expand Down