Repository: audreyr/cookiecutter
Pull request #858 (2016-11-26 02:27:15)

Use click to process dict variable
Follow-up for #815. This PR uses a click feature to prompt the user for a valid JSON dict and asks again if they enter anything else. Currently an exception would be raised for non dict values.

```text
$ cookiecutter -o foooo/ tests/fake-repo-dict/
project_slug [fake-project-dict]: helloworld
file_types [default]: nope!
Error: Unable to decode to JSON.
file_types [default]: [1, 2, 3]
Error: Requires JSON dict.
file_types [default]: {"png":{"name":"Portable Network Graphic","library":"libpng","apps":["GIMP"]}}
```

Please let me know your thoughts! I would like to include this in the next release 😄 

@audreyr @pydanny @michaeljoseph @freakboy3742 


Patch:
diff --git a/cookiecutter/prompt.py b/cookiecutter/prompt.py
index 5affedf..7f4d12f 100644
--- a/cookiecutter/prompt.py
+++ b/cookiecutter/prompt.py
@@ -84,6 +84,30 @@ def read_user_choice(var_name, options):
     return choice_map[user_choice]
 
 
+def create_value_proc(default_display, default_value):
+    def process_json(user_value):
+        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:
+            # Leave it up to click to ask the user again
+            raise click.UsageError('Unable to decode to JSON.')
+
+        if not isinstance(user_dict, dict):
+            # Leave it up to click to ask the user again
+            raise click.UsageError('Requires JSON dict.')
+
+        return user_dict
+
+    return process_json
+
+
 def read_user_dict(var_name, default_value):
     """Prompt the user to provide a dictionary of data.
 
@@ -95,13 +119,17 @@ def read_user_dict(var_name, default_value):
     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
+    default_display = 'default'
 
-    return value
+    return click.prompt(
+        var_name,
+        default=default_display,
+        type=click.STRING,
+        value_proc=create_value_proc(
+            default_display,
+            default_value,
+        ),
+    )
 
 
 def render_variable(env, raw, cookiecutter_dict):


Test patch:
diff --git a/tests/test_read_user_dict.py b/tests/test_read_user_dict.py
index b2c35aa..fabd82f 100644
--- a/tests/test_read_user_dict.py
+++ b/tests/test_read_user_dict.py
@@ -10,60 +10,51 @@ 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}
+from cookiecutter.prompt import (
+    create_value_proc,
+    read_user_dict,
+)
+
+
+@pytest.fixture
+def value_proc():
+    return create_value_proc(
+        'default123',
+        {
+            'key': 1,
+        },
+    )
 
-    assert read_user_dict(VARIABLE, DEFAULT) == {}
 
-    click.prompt.assert_called_once_with(VARIABLE, default='default')
+def test_value_proc_default_display(value_proc):
+    assert value_proc('default123') == {'key': 1}
 
 
-def test_use_empty_default_dict(mocker):
-    prompt = mocker.patch('click.prompt')
-    prompt.return_value = 'default'
+def test_value_proc_invalid_json(value_proc):
+    with pytest.raises(click.UsageError) as exc_info:
+        value_proc('nope]')
 
-    VARIABLE = 'var'
-    DEFAULT = {}
+    assert str(exc_info.value) == 'Unable to decode to JSON.'
 
-    assert read_user_dict(VARIABLE, DEFAULT) == {}
 
-    click.prompt.assert_called_once_with(VARIABLE, default='default')
+def test_value_proc_non_dict(value_proc):
+    with pytest.raises(click.UsageError) as exc_info:
+        value_proc('[1, 2]')
 
+    assert str(exc_info.value) == 'Requires JSON dict.'
 
-def test_shallow_dict(mocker):
-    prompt = mocker.patch('click.prompt')
-    prompt.return_value = '{"key": 2}'
 
-    VARIABLE = 'var'
-    DEFAULT = {}
+def test_value_proc_valid_json(value_proc):
+    user_value = '{"name": "foobar", "bla": ["a", 1, "b", false]}'
 
-    assert read_user_dict(VARIABLE, DEFAULT) == {'key': 2}
-
-    click.prompt.assert_called_once_with(VARIABLE, default='default')
+    assert value_proc(user_value) == {
+        'name': 'foobar',
+        'bla': ['a', 1, 'b', False],
+    }
 
 
-def test_deep_dict(mocker):
-    prompt = mocker.patch('click.prompt')
-    prompt.return_value = '''{
+def test_value_proc_deep_dict(value_proc):
+    user_value = '''{
         "key": "value",
         "integer_key": 37,
         "dict_key": {
@@ -82,10 +73,7 @@ def test_deep_dict(mocker):
         ]
     }'''
 
-    VARIABLE = 'var'
-    DEFAULT = {}
-
-    assert read_user_dict(VARIABLE, DEFAULT) == {
+    assert value_proc(user_value) == {
         "key": "value",
         "integer_key": 37,
         "dict_key": {
@@ -104,17 +92,38 @@ def test_deep_dict(mocker):
         ]
     }
 
-    click.prompt.assert_called_once_with(VARIABLE, default='default')
 
+def test_should_raise_type_error(mocker):
+    prompt = mocker.patch('click.prompt')
 
-def test_raise_if_value_is_not_dict():
     with pytest.raises(TypeError):
-        read_user_dict('foo', 'NOT A LIST')
+        read_user_dict('name', 'russell')
+
+    assert not prompt.called
 
 
-def test_raise_if_value_not_valid_json(mocker):
+def test_should_call_prompt_with_value_proc(mocker):
+    """Test to make sure that create_value_proc is actually being used
+    to generate a processer for the user input."""
     prompt = mocker.patch('click.prompt')
-    prompt.return_value = '{'
 
-    with pytest.raises(ValueError):
-        read_user_dict('foo', {})
+    def process_json(user_value):
+        return user_value
+
+    create_value_proc = mocker.patch(
+        'cookiecutter.prompt.create_value_proc',
+        return_value=process_json,
+    )
+
+    read_user_dict('name', {'project_slug': 'pytest-plugin'})
+
+    assert create_value_proc.call_args == mocker.call(
+        'default',
+        {'project_slug': 'pytest-plugin'},
+    )
+    assert prompt.call_args == mocker.call(
+        'name',
+        type=click.STRING,
+        default='default',
+        value_proc=process_json,
+    )