From 4f56621fb6de42480f55838e8aeff154999b87db Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 1 Sep 2016 07:26:11 +0800 Subject: [PATCH 1/2] Fixed #479 - Added support for dictionary data in configurations. --- cookiecutter/prompt.py | 60 ++++++++++- docs/advanced/dict_variables.rst | 63 ++++++++++++ docs/advanced/index.rst | 1 + tests/test_prompt.py | 171 +++++++++++++++++++++++++++++++ tests/test_read_user_dict.py | 120 ++++++++++++++++++++++ 5 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 docs/advanced/dict_variables.rst create mode 100644 tests/test_read_user_dict.py diff --git a/cookiecutter/prompt.py b/cookiecutter/prompt.py index 173ecb595..310ef90eb 100644 --- a/cookiecutter/prompt.py +++ b/cookiecutter/prompt.py @@ -8,6 +8,7 @@ """ from collections import OrderedDict +import json import click from past.builtins import basestring @@ -83,11 +84,43 @@ def read_user_choice(var_name, options): return choice_map[user_choice] +def read_user_dict(var_name, default_value): + """Prompt the user to provide a dictionary of data. + + :param str 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. + """ + # Please see http://click.pocoo.org/4/api/#click.prompt + if not isinstance(default_value, dict): + raise TypeError + + raw = click.prompt(var_name, default='default') + if raw != 'default': + value = json.loads(raw, object_hook=OrderedDict) + else: + value = default_value + + return value + + def render_variable(env, raw, cookiecutter_dict): if raw is None: return None - if not isinstance(raw, basestring): + 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, basestring): raw = str(raw) + template = env.from_string(raw) rendered_template = template.render(cookiecutter=cookiecutter_dict) @@ -117,6 +150,9 @@ def prompt_for_config(context, no_input=False): cookiecutter_dict = {} env = StrictEnvironment(context=context) + # First pass: Handle simple and raw variables, plus choices. + # These must be done first because the dictionaries keys and + # values might refer to them. for key, raw in iteritems(context[u'cookiecutter']): if key.startswith(u'_'): cookiecutter_dict[key] = raw @@ -128,15 +164,33 @@ def prompt_for_config(context, no_input=False): val = prompt_choice_for_config( cookiecutter_dict, env, key, raw, no_input ) - else: + cookiecutter_dict[key] = val + 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 = "Unable to render variable '{}'".format(key) + raise UndefinedVariableInTemplate(msg, err, context) + + # Second pass; handle the dictionaries. + for key, raw in iteritems(context[u'cookiecutter']): + + try: + if isinstance(raw, dict): + # We are dealing with a dict variable + val = render_variable(env, raw, cookiecutter_dict) + + if not no_input: + val = read_user_dict(key, val) + + cookiecutter_dict[key] = val except UndefinedError as err: msg = "Unable to render variable '{}'".format(key) raise UndefinedVariableInTemplate(msg, err, context) - cookiecutter_dict[key] = val return cookiecutter_dict diff --git a/docs/advanced/dict_variables.rst b/docs/advanced/dict_variables.rst new file mode 100644 index 000000000..28565f720 --- /dev/null +++ b/docs/advanced/dict_variables.rst @@ -0,0 +1,63 @@ +.. _dict-variables: + +Dictionary Variables (1.1+) +--------------------- + +Dictionary variables provide a way to define deep structured information when +rendering a template. + +Basic Usage +~~~~~~~~~~~ + +Dictionary variables are, as the name suggests, dictionaries of key-value +pairs. The dictionary values can, themselves, be other dictionaries and lists +- the data structure can be as deep as you need. + +For example, you could provide the following dictionary variable in your +``cookiecutter.json``:: + + { + "file_types": { + "png": { + "name": "Portable Network Graphic", + "library": "libpng" + "apps": [ + "GIMP", + ] + }, + "bmp": { + "name": "Bitmap", + "library": "libbmp", + "apps": [ + "Paint", + "GIMP", + ] + } + } + } + +The above ``file_type`` dictionary variable creates +``cookiecutter.file_types``, which can be used like this:: + + {% for extension, details in cookiecutter.file_types.items %} +
+
Format name:
+
{{ details.name }}
+ +
Extension:
+
{{ extension }}
+ +
Applications:
+
+
    + {% for app in details.apps %} +
  • {{ details.name }}
  • +
+
+
+ + {% endfor %} + +Cookiecutter is using `Jinja2's for expression `_ to iterate over the items in the dictionary. + + diff --git a/docs/advanced/index.rst b/docs/advanced/index.rst index 172f2d3d4..b93f1d5f9 100644 --- a/docs/advanced/index.rst +++ b/docs/advanced/index.rst @@ -18,4 +18,5 @@ Various advanced topics regarding cookiecutter usage. replay cli_options choice_variables + dict_variables template_extensions diff --git a/tests/test_prompt.py b/tests/test_prompt.py index a100ab46f..4d881f6d0 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -70,6 +70,147 @@ def test_prompt_for_config_unicode(self, monkeypatch): cookiecutter_dict = prompt.prompt_for_config(context) assert cookiecutter_dict == {'full_name': u'Pizzä ïs Gööd'} + def test_prompt_for_config_empty_dict(self, monkeypatch): + monkeypatch.setattr( + 'cookiecutter.prompt.read_user_dict', + lambda var, default: {} + ) + context = {'cookiecutter': {'details': {}}} + + cookiecutter_dict = prompt.prompt_for_config(context) + assert cookiecutter_dict == {'details': {}} + + def test_prompt_for_config_dict(self, monkeypatch): + monkeypatch.setattr( + 'cookiecutter.prompt.read_user_dict', + lambda var, default: {"key": "value", "integer": 37} + ) + context = {'cookiecutter': {'details': {}}} + + cookiecutter_dict = prompt.prompt_for_config(context) + assert cookiecutter_dict == { + 'details': { + 'key': u'value', + 'integer': 37 + } + } + + def test_prompt_for_config_deep_dict(self, monkeypatch): + monkeypatch.setattr( + 'cookiecutter.prompt.read_user_dict', + lambda var, default: { + "key": "value", + "integer_key": 37, + "dict_key": { + "deep_key": "deep_value", + "deep_integer": 42, + "deep_list": [ + "deep value 1", + "deep value 2", + "deep value 3", + ] + }, + "list_key": [ + "value 1", + "value 2", + "value 3", + ] + } + ) + context = {'cookiecutter': {'details': {}}} + + cookiecutter_dict = prompt.prompt_for_config(context) + assert cookiecutter_dict == { + 'details': { + "key": "value", + "integer_key": 37, + "dict_key": { + "deep_key": "deep_value", + "deep_integer": 42, + "deep_list": [ + "deep value 1", + "deep value 2", + "deep value 3", + ] + }, + "list_key": [ + "value 1", + "value 2", + "value 3", + ] + } + } + + def test_should_render_dict(self): + context = { + 'cookiecutter': { + 'project_name': 'Slartibartfast', + 'details': { + 'other_name': '{{cookiecutter.project_name}}' + } + } + } + + cookiecutter_dict = prompt.prompt_for_config(context, no_input=True) + assert cookiecutter_dict == { + 'project_name': 'Slartibartfast', + 'details': { + 'other_name': u'Slartibartfast', + } + } + + def test_should_render_deep_dict(self): + context = { + 'cookiecutter': { + 'project_name': "Slartibartfast", + 'details': { + "key": "value", + "integer_key": 37, + "other_name": '{{cookiecutter.project_name}}', + "dict_key": { + "deep_key": "deep_value", + "deep_integer": 42, + "deep_other_name": '{{cookiecutter.project_name}}', + "deep_list": [ + "deep value 1", + "{{cookiecutter.project_name}}", + "deep value 3", + ] + }, + "list_key": [ + "value 1", + "{{cookiecutter.project_name}}", + "value 3", + ] + } + } + } + + cookiecutter_dict = prompt.prompt_for_config(context, no_input=True) + assert cookiecutter_dict == { + 'project_name': "Slartibartfast", + 'details': { + "key": "value", + "integer_key": "37", + "other_name": "Slartibartfast", + "dict_key": { + "deep_key": "deep_value", + "deep_integer": "42", + "deep_other_name": "Slartibartfast", + "deep_list": [ + "deep value 1", + "Slartibartfast", + "deep value 3", + ] + }, + "list_key": [ + "value 1", + "Slartibartfast", + "value 3", + ] + } + } + def test_unicode_prompt_for_config_unicode(self, monkeypatch): monkeypatch.setattr( 'cookiecutter.prompt.read_user_variable', @@ -279,3 +420,33 @@ def test_undefined_variable_in_cookiecutter_dict_with_choices(): error = err.value assert error.message == "Unable to render variable 'foo'" assert error.context == context + + +def test_undefined_variable_in_cookiecutter_dict_with_dict_key(): + context = { + 'cookiecutter': { + 'hello': 'world', + 'foo': {'{{cookiecutter.nope}}': 'value'} + } + } + with pytest.raises(exceptions.UndefinedVariableInTemplate) as err: + prompt.prompt_for_config(context, no_input=True) + + error = err.value + assert error.message == "Unable to render variable 'foo'" + assert error.context == context + + +def test_undefined_variable_in_cookiecutter_dict_with_key_value(): + context = { + 'cookiecutter': { + 'hello': 'world', + 'foo': {'key': '{{cookiecutter.nope}}'} + } + } + with pytest.raises(exceptions.UndefinedVariableInTemplate) as err: + prompt.prompt_for_config(context, no_input=True) + + error = err.value + assert error.message == "Unable to render variable 'foo'" + assert error.context == context diff --git a/tests/test_read_user_dict.py b/tests/test_read_user_dict.py new file mode 100644 index 000000000..b2c35aa05 --- /dev/null +++ b/tests/test_read_user_dict.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +""" +test_read_user_dict +------------------- +""" + +from __future__ import unicode_literals + +import click +import pytest + +from cookiecutter.prompt import read_user_dict + + +def test_use_default_dict(mocker): + prompt = mocker.patch('click.prompt') + prompt.return_value = 'default' + + VARIABLE = 'var' + DEFAULT = {"key": 1} + + assert read_user_dict(VARIABLE, DEFAULT) == {'key': 1} + + click.prompt.assert_called_once_with(VARIABLE, default='default') + + +def test_empty_input_dict(mocker): + prompt = mocker.patch('click.prompt') + prompt.return_value = '{}' + + VARIABLE = 'var' + DEFAULT = {"key": 1} + + assert read_user_dict(VARIABLE, DEFAULT) == {} + + click.prompt.assert_called_once_with(VARIABLE, default='default') + + +def test_use_empty_default_dict(mocker): + prompt = mocker.patch('click.prompt') + prompt.return_value = 'default' + + VARIABLE = 'var' + DEFAULT = {} + + assert read_user_dict(VARIABLE, DEFAULT) == {} + + click.prompt.assert_called_once_with(VARIABLE, default='default') + + +def test_shallow_dict(mocker): + prompt = mocker.patch('click.prompt') + prompt.return_value = '{"key": 2}' + + VARIABLE = 'var' + DEFAULT = {} + + assert read_user_dict(VARIABLE, DEFAULT) == {'key': 2} + + click.prompt.assert_called_once_with(VARIABLE, default='default') + + +def test_deep_dict(mocker): + prompt = mocker.patch('click.prompt') + prompt.return_value = '''{ + "key": "value", + "integer_key": 37, + "dict_key": { + "deep_key": "deep_value", + "deep_integer": 42, + "deep_list": [ + "deep value 1", + "deep value 2", + "deep value 3" + ] + }, + "list_key": [ + "value 1", + "value 2", + "value 3" + ] + }''' + + VARIABLE = 'var' + DEFAULT = {} + + assert read_user_dict(VARIABLE, DEFAULT) == { + "key": "value", + "integer_key": 37, + "dict_key": { + "deep_key": "deep_value", + "deep_integer": 42, + "deep_list": [ + "deep value 1", + "deep value 2", + "deep value 3", + ] + }, + "list_key": [ + "value 1", + "value 2", + "value 3", + ] + } + + click.prompt.assert_called_once_with(VARIABLE, default='default') + + +def test_raise_if_value_is_not_dict(): + with pytest.raises(TypeError): + read_user_dict('foo', 'NOT A LIST') + + +def test_raise_if_value_not_valid_json(mocker): + prompt = mocker.patch('click.prompt') + prompt.return_value = '{' + + with pytest.raises(ValueError): + read_user_dict('foo', {}) From 495f0bc373c9c82fb1e56ba62629f2996f983181 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 5 Sep 2016 19:23:29 +0800 Subject: [PATCH 2/2] Corrected the version number that the feature would be added. --- docs/advanced/dict_variables.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/advanced/dict_variables.rst b/docs/advanced/dict_variables.rst index 28565f720..d0d166ba6 100644 --- a/docs/advanced/dict_variables.rst +++ b/docs/advanced/dict_variables.rst @@ -1,7 +1,7 @@ .. _dict-variables: -Dictionary Variables (1.1+) ---------------------- +Dictionary Variables (1.5+) +--------------------------- Dictionary variables provide a way to define deep structured information when rendering a template. @@ -60,4 +60,3 @@ The above ``file_type`` dictionary variable creates Cookiecutter is using `Jinja2's for expression `_ to iterate over the items in the dictionary. -