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

Dynamic choice variable #1768

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
84 changes: 46 additions & 38 deletions cookiecutter/prompt.py
Expand Up @@ -2,6 +2,7 @@
import functools
import json
from collections import OrderedDict
import ast

import click
from jinja2.exceptions import UndefinedError
Expand Down Expand Up @@ -159,7 +160,18 @@ def render_variable(env, raw, cookiecutter_dict):

template = env.from_string(raw)

return template.render(cookiecutter=cookiecutter_dict)
rendered_variable_str = template.render(cookiecutter=cookiecutter_dict)
try:
rendered_variable = ast.literal_eval(rendered_variable_str)
Copy link
Member

Choose a reason for hiding this comment

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

@jensens Should we worry about this?

Copy link
Contributor

Choose a reason for hiding this comment

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

I am not a fan of even literal_eval in code which is meant to run on cookiecutters fetched from Git.

see https://docs.python.org/3/library/ast.html#ast.literal_eval

This function had been documented as “safe” in the past without defining what that meant. That was misleading. This is specifically designed not to execute Python code, unlike the more general eval(). There is no namespace, no name lookups, or ability to call out. But it is not free from attack: A relatively small input can lead to memory exhaustion or to C stack exhaustion, crashing the process. There is also the possibility for excessive CPU consumption denial of service on some inputs. Calling it on untrusted data is thus not recommended.

except (
ValueError,
TypeError,
SyntaxError,
MemoryError,
RecursionError,
):
rendered_variable = rendered_variable_str
return rendered_variable


def prompt_choice_for_config(cookiecutter_dict, env, key, options, no_input):
Expand All @@ -186,57 +198,53 @@ def prompt_for_config(context, no_input=False):
# These must be done first because the dictionaries keys and
# values might refer to them.
for key, raw in context['cookiecutter'].items():
try:
rendered_raw = render_variable(env, raw, cookiecutter_dict)
except UndefinedError as err:
msg = f"Unable to render variable '{key}'"
raise UndefinedVariableInTemplate(msg, err, context) from err

if key.startswith('_') and not key.startswith('__'):
cookiecutter_dict[key] = raw
continue
elif key.startswith('__'):
cookiecutter_dict[key] = render_variable(env, raw, cookiecutter_dict)
cookiecutter_dict[key] = rendered_raw
continue

try:
if isinstance(raw, list):
# We are dealing with a choice variable
val = prompt_choice_for_config(
cookiecutter_dict, env, key, raw, no_input
)
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)
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)

cookiecutter_dict[key] = val
except UndefinedError as err:
msg = f"Unable to render variable '{key}'"
raise UndefinedVariableInTemplate(msg, err, context) from err
raw = rendered_raw
if isinstance(rendered_raw, list):
# We are dealing with a choice variable
val = prompt_choice_for_config(
cookiecutter_dict, env, key, rendered_raw, no_input
)
cookiecutter_dict[key] = val
elif isinstance(rendered_raw, bool):
# We are dealing with a boolean variable
if no_input:
cookiecutter_dict[key] = rendered_raw
else:
cookiecutter_dict[key] = read_user_yes_no(key, rendered_raw)
elif not isinstance(rendered_raw, dict):
# We are dealing with a regular variable
val = raw
if not no_input:
val = read_user_variable(key, rendered_raw)

cookiecutter_dict[key] = val

# 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
val = render_variable(env, raw, cookiecutter_dict)
if isinstance(raw, dict):
# We are dealing with a dict variable
val = render_variable(env, raw, cookiecutter_dict)

if not no_input and not key.startswith('__'):
val = read_user_dict(key, val)
if not no_input and not key.startswith('__'):
val = read_user_dict(key, val)

cookiecutter_dict[key] = val
except UndefinedError as err:
msg = f"Unable to render variable '{key}'"
raise UndefinedVariableInTemplate(msg, err, context) from err
cookiecutter_dict[key] = val

return cookiecutter_dict
43 changes: 35 additions & 8 deletions tests/test_prompt.py
Expand Up @@ -20,7 +20,7 @@ class TestRenderVariable:
@pytest.mark.parametrize(
'raw_var, rendered_var',
[
(1, '1'),
(1, 1),
(True, True),
('foo', 'foo'),
('{{cookiecutter.project}}', 'foobar'),
Expand Down Expand Up @@ -49,10 +49,10 @@ def test_convert_to_str(self, mocker, raw_var, rendered_var):
@pytest.mark.parametrize(
'raw_var, rendered_var',
[
({1: True, 'foo': False}, {'1': True, 'foo': False}),
({1: True, 'foo': False}, {1: True, 'foo': False}),
(
{'{{cookiecutter.project}}': ['foo', 1], 'bar': False},
{'foobar': ['foo', '1'], 'bar': False},
{'foobar': ['foo', 1], 'bar': False},
),
(['foo', '{{cookiecutter.project}}', None], ['foo', 'foobar', None]),
],
Expand Down Expand Up @@ -115,6 +115,33 @@ def test_should_render_dict(self):
'details': {'Slartibartfast': 'Slartibartfast'},
}

def test_should_render_dynamic_choice_variable(self):
"""
Verify dynamic choice variable, rendered correctly.

Added because issue #1774.
"""
context = {
'cookiecutter': {
"_site_locations": {
"zone1": ["SiteA", "SiteB", "SiteC"],
"zone2": ["SiteD", "SiteE"],
},
"zone": ["zone1", "zone2"],
"site": "{{ cookiecutter._site_locations.get(cookiecutter.zone) }}",
},
}

cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)
assert cookiecutter_dict == {
"_site_locations": {
"zone1": ["SiteA", "SiteB", "SiteC"],
"zone2": ["SiteD", "SiteE"],
},
"zone": "zone1",
"site": "SiteA",
}

def test_should_render_deep_dict(self):
"""Verify nested structures like dict in dict, rendered correctly."""
context = {
Expand Down Expand Up @@ -148,11 +175,11 @@ def test_should_render_deep_dict(self):
'project_name': "Slartibartfast",
'details': {
"key": "value",
"integer_key": "37",
"integer_key": 37,
"other_name": "Slartibartfast",
"dict_key": {
"deep_key": "deep_value",
"deep_integer": "42",
"deep_integer": 42,
"deep_other_name": "Slartibartfast",
"deep_list": ["deep value 1", "Slartibartfast", "deep value 3"],
},
Expand Down Expand Up @@ -223,13 +250,13 @@ def test_should_render_private_variables_with_two_underscores(self):
assert cookiecutter_dict == OrderedDict(
[
('foo', 'Hello world'),
('bar', '123'),
('bar', 123),
('rendered_foo', 'hello world'),
('rendered_bar', '123'),
('rendered_bar', 123),
('_hidden_foo', '{{ cookiecutter.foo|lower }}'),
('_hidden_bar', 123),
('__rendered_hidden_foo', 'hello world'),
('__rendered_hidden_bar', '123'),
('__rendered_hidden_bar', 123),
]
)

Expand Down