Repository: audreyr/cookiecutter
Pull request #501 (2015-08-16 21:27:07)

Add replay feature
This PR adds a great feature to **Cookiecutter**! :smile_cat: 

Users can now *replay* their previous invocation without being prompted for input again by adding ``--replay`` on the cli or setting the according kwarg when using the api. This feature is comes in handy if you want to create a new project from an updated template. Cookiecutter now dumps a json  to ``~/.cookiecutter_replay/`` when you call it (it overwrites per name).

```bash
[raphael@hackebook ~]$ tree .cookiecutter_replay/
.cookiecutter_replay/
├── cookiecutter-django.json
└── cookiedozer.git.json
```
Cookiecutter uses these *replay files* if you pass the aforementioned cli option.

@ionelmc and I had a chat on this feature at EuroPython, so I decided to spend some time on it. 

**Note**: I am going to update this PR in the upcoming days with an according section in our advanced usage docs.

Please let me know your thoughts! Any feedback is welcome :bow: 




Patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
index cbcbff8..987cd6e 100755
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -18,7 +18,9 @@ import click
 
 from cookiecutter import __version__
 from cookiecutter.main import cookiecutter
-from cookiecutter.exceptions import OutputDirExistsException
+from cookiecutter.exceptions import (
+    OutputDirExistsException, InvalidModeException
+)
 
 logger = logging.getLogger(__name__)
 
@@ -54,7 +56,12 @@ def print_version(context, param, value):
     '-v', '--verbose',
     is_flag=True, help='Print debug information', default=False
 )
-def main(template, no_input, checkout, verbose):
+@click.option(
+    '--replay', is_flag=True,
+    help='Do not prompt for parameters and only use information entered '
+         'previously',
+)
+def main(template, no_input, checkout, verbose, replay):
     """Create a project from a Cookiecutter project template (TEMPLATE)."""
     if verbose:
         logging.basicConfig(
@@ -69,8 +76,8 @@ def main(template, no_input, checkout, verbose):
         )
 
     try:
-        cookiecutter(template, checkout, no_input)
-    except OutputDirExistsException as e:
+        cookiecutter(template, checkout, no_input, replay=replay)
+    except (OutputDirExistsException, InvalidModeException) as e:
         click.echo(e)
         sys.exit(1)
 
diff --git a/cookiecutter/config.py b/cookiecutter/config.py
index 82e47fe..840fea0 100755
--- a/cookiecutter/config.py
+++ b/cookiecutter/config.py
@@ -24,6 +24,7 @@ logger = logging.getLogger(__name__)
 
 DEFAULT_CONFIG = {
     'cookiecutters_dir': os.path.expanduser('~/.cookiecutters/'),
+    'replay_dir': os.path.expanduser('~/.cookiecutter_replay/'),
     'default_context': {}
 }
 
diff --git a/cookiecutter/exceptions.py b/cookiecutter/exceptions.py
index c1d626c..0ad6b58 100755
--- a/cookiecutter/exceptions.py
+++ b/cookiecutter/exceptions.py
@@ -74,3 +74,10 @@ class OutputDirExistsException(CookiecutterException):
     """
     Raised when the output directory of the project exists already.
     """
+
+
+class InvalidModeException(CookiecutterException):
+    """
+    Raised when cookiecutter is called with both `no_input==True` and
+    `replay==True` at the same time.
+    """
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
index a5cde54..2771e88 100755
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -16,9 +16,11 @@ import logging
 import os
 
 from .config import get_user_config
+from .exceptions import InvalidModeException
 from .prompt import prompt_for_config
 from .generate import generate_context, generate_files
 from .vcs import clone
+from .replay import dump, load
 
 logger = logging.getLogger(__name__)
 
@@ -52,7 +54,9 @@ def expand_abbreviations(template, config_dict):
     return template
 
 
-def cookiecutter(template, checkout=None, no_input=False, extra_context=None):
+def cookiecutter(
+        template,
+        checkout=None, no_input=False, extra_context=None, replay=False):
     """
     API equivalent to using Cookiecutter at the command line.
 
@@ -63,6 +67,12 @@ def cookiecutter(template, checkout=None, no_input=False, extra_context=None):
     :param extra_context: A dictionary of context that overrides default
         and user configuration.
     """
+    if replay and ((no_input is not False) or (extra_context is not None)):
+        err_msg = (
+            "You can not use both replay and no_input or extra_context "
+            "at the same time."
+        )
+        raise InvalidModeException(err_msg)
 
     # Get user config from ~/.cookiecutterrc or equivalent
     # If no config file, sensible defaults from config.DEFAULT_CONFIG are used
@@ -83,18 +93,25 @@ def cookiecutter(template, checkout=None, no_input=False, extra_context=None):
         # cookiecutters_dir
         repo_dir = template
 
-    context_file = os.path.join(repo_dir, 'cookiecutter.json')
-    logging.debug('context_file is {0}'.format(context_file))
+    template_name = os.path.basename(template)
 
-    context = generate_context(
-        context_file=context_file,
-        default_context=config_dict['default_context'],
-        extra_context=extra_context,
-    )
+    if replay:
+        context = load(template_name)
+    else:
+        context_file = os.path.join(repo_dir, 'cookiecutter.json')
+        logging.debug('context_file is {0}'.format(context_file))
+
+        context = generate_context(
+            context_file=context_file,
+            default_context=config_dict['default_context'],
+            extra_context=extra_context,
+        )
+
+        # prompt the user to manually configure at the command line.
+        # except when 'no-input' flag is set
+        context['cookiecutter'] = prompt_for_config(context, no_input)
 
-    # prompt the user to manually configure at the command line.
-    # except when 'no-input' flag is set
-    context['cookiecutter'] = prompt_for_config(context, no_input)
+        dump(template_name, context)
 
     # Create project from local context and project template.
     generate_files(
diff --git a/cookiecutter/replay.py b/cookiecutter/replay.py
new file mode 100644
index 0000000..0643290
--- /dev/null
+++ b/cookiecutter/replay.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+
+"""
+cookiecutter.replay
+-------------------
+"""
+
+from __future__ import unicode_literals
+
+import json
+import os
+
+from .compat import is_string
+from .config import get_user_config
+from .utils import make_sure_path_exists
+
+
+def get_file_name(replay_dir, template_name):
+    file_name = '{}.json'.format(template_name)
+    return os.path.join(replay_dir, file_name)
+
+
+def dump(template_name, context):
+    if not is_string(template_name):
+        raise TypeError('Template name is required to be of type str')
+
+    if not isinstance(context, dict):
+        raise TypeError('Context is required to be of type dict')
+
+    if 'cookiecutter' not in context:
+        raise ValueError('Context is required to contain a cookiecutter key')
+
+    replay_dir = get_user_config()['replay_dir']
+
+    if not make_sure_path_exists(replay_dir):
+        raise IOError('Unable to create replay dir at {}'.format(replay_dir))
+
+    replay_file = get_file_name(replay_dir, template_name)
+
+    with open(replay_file, 'w') as outfile:
+        json.dump(context, outfile)
+
+
+def load(template_name):
+    if not is_string(template_name):
+        raise TypeError('Template name is required to be of type str')
+
+    replay_dir = get_user_config()['replay_dir']
+    replay_file = get_file_name(replay_dir, template_name)
+
+    with open(replay_file, 'r') as infile:
+        context = json.load(infile)
+
+    if 'cookiecutter' not in context:
+        raise ValueError('Context is required to contain a cookiecutter key')
+
+    return context
diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst
index 5c5e915..cbe8bfc 100644
--- a/docs/advanced_usage.rst
+++ b/docs/advanced_usage.rst
@@ -192,6 +192,48 @@ To avoid rendering directories and files of a cookiecutter mould, the `_copy_wit
         ]
     }
 
+
+Replay Project Generation
+-------------------------
+
+*New in Cookiecutter 1.1*
+
+On invocation **Cookiecutter** dumps a json file to ``~/.cookiecutter_replay/`` which enables you to *replay* later on.
+
+In other words, it persists your **input** for a template and fetches it when you run the same template again.
+
+Example for a replay file (which was created via ``cookiecutter gh:hackebrot/cookiedozer``)::
+
+    {
+        "cookiecutter": {
+            "app_class_name": "FooBarApp",
+            "app_title": "Foo Bar",
+            "email": "raphael@hackebrot.de",
+            "full_name": "Raphael Pierzina",
+            "github_username": "hackebrot",
+            "kivy_version": "1.8.0",
+            "repo_name": "foobar",
+            "short_description": "A sleek slideshow app that supports swipe gestures.",
+            "version": "0.1.0",
+            "year": "2015"
+        }
+    }
+
+To fetch this context data without being prompted on the command line you can use either of the following methods.
+
+Pass the according option on the CLI::
+
+    cookiecutter --replay gh:hackebrot/cookiedozer
+
+
+Or use the Python API::
+
+    from cookiecutter.main import cookiecutter
+    cookiecutter('gh:hackebrot/cookiedozer', replay=True)
+
+
+This feature is comes in handy if, for instance, you want to create a new project from an updated template.
+
 .. _command_line_options:
 
 Command Line Options
diff --git a/docs/cookiecutter.rst b/docs/cookiecutter.rst
index 131725d..0ff329a 100644
--- a/docs/cookiecutter.rst
+++ b/docs/cookiecutter.rst
@@ -76,6 +76,14 @@ cookiecutter.prompt module
     :undoc-members:
     :show-inheritance:
 
+cookiecutter.replay module
+--------------------------
+
+.. automodule:: cookiecutter.replay
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 cookiecutter.utils module
 -------------------------
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 201990b..6407206 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,41 @@ import shutil
 from cookiecutter import utils
 
 
+def backup_dir(original_dir, backup_dir):
+    # If the default original_dir is pre-existing, move it to a temp location
+    if not os.path.isdir(original_dir):
+        return False
+
+    # Remove existing backups before backing up. If they exist, they're stale.
+    if os.path.isdir(backup_dir):
+        utils.rmtree(backup_dir)
+
+    shutil.copytree(original_dir, backup_dir)
+    return True
+
+
+def restore_backup_dir(original_dir, backup_dir, original_dir_found):
+    # Carefully delete the created original_dir only in certain
+    # conditions.
+    original_dir_is_dir = os.path.isdir(original_dir)
+    if original_dir_found:
+        # Delete the created original_dir as long as a backup
+        # exists
+        if original_dir_is_dir and os.path.isdir(backup_dir):
+            utils.rmtree(original_dir)
+    else:
+        # Delete the created original_dir.
+        # There's no backup because it never existed
+        if original_dir_is_dir:
+            utils.rmtree(original_dir)
+
+    # Restore the user's default original_dir contents
+    if os.path.isdir(backup_dir):
+        shutil.copytree(backup_dir, original_dir)
+    if os.path.isdir(original_dir):
+        utils.rmtree(backup_dir)
+
+
 @pytest.fixture(scope='function')
 def clean_system(request):
     """
@@ -31,16 +66,22 @@ def clean_system(request):
 
     * Back up the `~/.cookiecutterrc` config file to `~/.cookiecutterrc.backup`
     * Back up the `~/.cookiecutters/` dir to `~/.cookiecutters.backup/`
+    * Back up the `~/.cookiecutter_replay/` dir to
+      `~/.cookiecutter_replay.backup/`
     * Starts off a test case with no pre-existing `~/.cookiecutterrc` or
-      `~/.cookiecutters/`
+      `~/.cookiecutters/` or `~/.cookiecutter_replay/`
 
     During teardown:
 
     * Delete `~/.cookiecutters/` only if a backup is present at
       `~/.cookiecutters.backup/`
+    * Delete `~/.cookiecutter_replay/` only if a backup is present at
+      `~/.cookiecutter_replay.backup/`
     * Restore the `~/.cookiecutterrc` config file from
       `~/.cookiecutterrc.backup`
     * Restore the `~/.cookiecutters/` dir from `~/.cookiecutters.backup/`
+    * Restore the `~/.cookiecutter_replay/` dir from
+      `~/.cookiecutter_replay.backup/`
 
     """
 
@@ -60,17 +101,19 @@ def clean_system(request):
     # temp location
     cookiecutters_dir = os.path.expanduser('~/.cookiecutters')
     cookiecutters_dir_backup = os.path.expanduser('~/.cookiecutters.backup')
-    if os.path.isdir(cookiecutters_dir):
-        cookiecutters_dir_found = True
-
-        # Remove existing backups before backing up. If they exist, they're
-        # stale.
-        if os.path.isdir(cookiecutters_dir_backup):
-            utils.rmtree(cookiecutters_dir_backup)
+    cookiecutters_dir_found = backup_dir(
+        cookiecutters_dir, cookiecutters_dir_backup
+    )
 
-        shutil.copytree(cookiecutters_dir, cookiecutters_dir_backup)
-    else:
-        cookiecutters_dir_found = False
+    # If the default cookiecutter_replay_dir is pre-existing, move it to a
+    # temp location
+    cookiecutter_replay_dir = os.path.expanduser('~/.cookiecutter_replay')
+    cookiecutter_replay_dir_backup = os.path.expanduser(
+        '~/.cookiecutter_replay.backup'
+    )
+    cookiecutter_replay_dir_found = backup_dir(
+        cookiecutter_replay_dir, cookiecutter_replay_dir_backup
+    )
 
     def restore_backup():
         # If it existed, restore ~/.cookiecutterrc
@@ -81,22 +124,18 @@ def clean_system(request):
 
         # Carefully delete the created ~/.cookiecutters dir only in certain
         # conditions.
-        cookie_dir_is_dir = os.path.isdir(cookiecutters_dir)
-        if cookiecutters_dir_found:
-            # Delete the created ~/.cookiecutters dir as long as a backup
-            # exists
-            if cookie_dir_is_dir and os.path.isdir(cookiecutters_dir_backup):
-                utils.rmtree(cookiecutters_dir)
-        else:
-            # Delete the created ~/.cookiecutters dir.
-            # There's no backup because it never existed
-            if cookie_dir_is_dir:
-                utils.rmtree(cookiecutters_dir)
-
-        # Restore the user's default cookiecutters_dir contents
-        if os.path.isdir(cookiecutters_dir_backup):
-            shutil.copytree(cookiecutters_dir_backup, cookiecutters_dir)
-        if os.path.isdir(cookiecutters_dir):
-            utils.rmtree(cookiecutters_dir_backup)
+        restore_backup_dir(
+            cookiecutters_dir,
+            cookiecutters_dir_backup,
+            cookiecutters_dir_found
+        )
+
+        # Carefully delete the created ~/.cookiecutter_replay dir only in
+        # certain conditions.
+        restore_backup_dir(
+            cookiecutter_replay_dir,
+            cookiecutter_replay_dir_backup,
+            cookiecutter_replay_dir_found
+        )
 
     request.addfinalizer(restore_backup)
diff --git a/tests/replay/conftest.py b/tests/replay/conftest.py
new file mode 100644
index 0000000..296f63a
--- /dev/null
+++ b/tests/replay/conftest.py
@@ -0,0 +1,31 @@
+import pytest
+
+from cookiecutter import config
+
+
+@pytest.fixture
+def context():
+    """Fixture to return a valid context as known from a cookiecutter.json."""
+    return {
+        u'cookiecutter': {
+            u'email': u'raphael@hackebrot.de',
+            u'full_name': u'Raphael Pierzina',
+            u'github_username': u'hackebrot',
+            u'version': u'0.1.0',
+        }
+    }
+
+
+@pytest.fixture
+def replay_test_dir():
+    return 'tests/test-replay/'
+
+
+@pytest.fixture
+def mock_user_config(mocker, replay_test_dir):
+    user_config = config.DEFAULT_CONFIG
+    user_config.update({'replay_dir': replay_test_dir})
+
+    return mocker.patch(
+        'cookiecutter.replay.get_user_config', return_value=user_config
+    )
diff --git a/tests/replay/test_dump.py b/tests/replay/test_dump.py
new file mode 100644
index 0000000..0a0d067
--- /dev/null
+++ b/tests/replay/test_dump.py
@@ -0,0 +1,101 @@
+# -*- coding: utf-8 -*-
+
+"""
+test_dump
+---------
+"""
+
+import json
+import os
+import pytest
+
+from cookiecutter import replay
+
+
+@pytest.fixture
+def template_name():
+    """Fixture to return a valid template_name."""
+    return 'cookiedozer'
+
+
+@pytest.fixture
+def replay_file(replay_test_dir, template_name):
+    """Fixture to return a actual file name of the dump."""
+    file_name = '{}.json'.format(template_name)
+    return os.path.join(replay_test_dir, file_name)
+
+
+@pytest.fixture(autouse=True)
+def remove_replay_dump(request, replay_file):
+    """Remove the replay file created by tests."""
+    def fin_remove_replay_file():
+        if os.path.exists(replay_file):
+            os.remove(replay_file)
+    request.addfinalizer(fin_remove_replay_file)
+
+
+def test_type_error_if_no_template_name(context):
+    """Test that replay.dump raises if the tempate_name is not a valid str."""
+    with pytest.raises(TypeError):
+        replay.dump(None, context)
+
+
+def test_type_error_if_not_dict_context(template_name):
+    """Test that replay.dump raises if the context is not of type dict."""
+    with pytest.raises(TypeError):
+        replay.dump(template_name, 'not_a_dict')
+
+
+def test_value_error_if_key_missing_in_context(template_name):
+    """Test that replay.dump raises if the context does not contain a key
+    named 'cookiecutter'.
+    """
+    with pytest.raises(ValueError):
+        replay.dump(template_name, {'foo': 'bar'})
+
+
+@pytest.fixture
+def mock_ensure_failure(mocker):
+    return mocker.patch(
+        'cookiecutter.replay.make_sure_path_exists',
+        return_value=False
+    )
+
+
+@pytest.fixture
+def mock_ensure_success(mocker):
+    return mocker.patch(
+        'cookiecutter.replay.make_sure_path_exists',
+        return_value=True
+    )
+
+
+def test_ioerror_if_replay_dir_creation_fails(
+        mock_ensure_failure, mock_user_config, replay_test_dir):
+    """Test that replay.dump raises when the replay_dir cannot be created."""
+
+    with pytest.raises(IOError):
+        replay.dump('foo', {'cookiecutter': {'hello': 'world'}})
+
+    mock_ensure_failure.assert_called_once_with(replay_test_dir)
+
+
+def test_run_json_dump(mocker, mock_ensure_success, mock_user_config,
+                       template_name, context, replay_test_dir, replay_file):
+    """Test that replay.dump runs json.dump under the hood and that the context
+    is correctly written to the expected file in the replay_dir.
+    """
+    spy_get_replay_file = mocker.spy(replay, 'get_file_name')
+
+    mock_json_dump = mocker.patch('json.dump', side_effect=json.dump)
+
+    replay.dump(template_name, context)
+
+    assert mock_user_config.call_count == 1
+    mock_ensure_success.assert_called_once_with(replay_test_dir)
+    spy_get_replay_file.assert_called_once_with(replay_test_dir, template_name)
+
+    assert mock_json_dump.call_count == 1
+    (dumped_context, outfile_handler), kwargs = mock_json_dump.call_args
+    assert outfile_handler.name == replay_file
+    assert dumped_context == context
diff --git a/tests/replay/test_load.py b/tests/replay/test_load.py
new file mode 100644
index 0000000..5876096
--- /dev/null
+++ b/tests/replay/test_load.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+"""
+test_load
+-----------
+"""
+
+import json
+import os
+import pytest
+
+from cookiecutter import replay
+
+
+@pytest.fixture
+def template_name():
+    """Fixture to return a valid template_name."""
+    return 'cookiedozer_load'
+
+
+@pytest.fixture
+def replay_file(replay_test_dir, template_name):
+    """Fixture to return a actual file name of the dump."""
+    file_name = '{}.json'.format(template_name)
+    return os.path.join(replay_test_dir, file_name)
+
+
+def test_type_error_if_no_template_name():
+    """Test that replay.load raises if the tempate_name is not a valid str."""
+    with pytest.raises(TypeError):
+        replay.load(None)
+
+
+def test_value_error_if_key_missing_in_context(mocker):
+    """Test that replay.load raises if the loaded context does not contain
+    'cookiecutter'.
+    """
+    with pytest.raises(ValueError):
+        replay.load('invalid_replay')
+
+
+def test_io_error_if_no_replay_file(mocker, mock_user_config):
+    """Test that replay.load raises if it cannot find a replay file."""
+    with pytest.raises(IOError):
+        replay.load('no_replay')
+
+
+def test_run_json_load(mocker, mock_user_config, template_name,
+                       context, replay_test_dir, replay_file):
+    """Test that replay.load runs json.load under the hood and that the context
+    is correctly loaded from the file in replay_dir.
+    """
+    spy_get_replay_file = mocker.spy(replay, 'get_file_name')
+
+    mock_json_load = mocker.patch('json.load', side_effect=json.load)
+
+    loaded_context = replay.load(template_name)
+
+    assert mock_user_config.call_count == 1
+    spy_get_replay_file.assert_called_once_with(replay_test_dir, template_name)
+
+    assert mock_json_load.call_count == 1
+    (infile_handler,), kwargs = mock_json_load.call_args
+    assert infile_handler.name == replay_file
+    assert loaded_context == context
diff --git a/tests/replay/test_replay.py b/tests/replay/test_replay.py
new file mode 100644
index 0000000..004626d
--- /dev/null
+++ b/tests/replay/test_replay.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+"""
+test_replay
+-----------
+"""
+
+import os
+import pytest
+
+from cookiecutter import replay, main, exceptions
+
+
+def test_get_replay_file_name():
+    """Make sure that replay.get_file_name generates a valid json file path."""
+    exp_replay_file_name = os.path.join('foo', 'bar.json')
+    assert replay.get_file_name('foo', 'bar') == exp_replay_file_name
+
+
+@pytest.fixture(params=[
+    {'no_input': True},
+    {'extra_context': {}},
+    {'no_input': True, 'extra_context': {}},
+])
+def invalid_kwargs(request):
+    return request.param
+
+
+def test_raise_on_invalid_mode(invalid_kwargs):
+    with pytest.raises(exceptions.InvalidModeException):
+        main.cookiecutter('foo', replay=True, **invalid_kwargs)
+
+
+def test_main_does_not_invoke_dump_but_load(mocker):
+    mock_prompt = mocker.patch('cookiecutter.main.prompt_for_config')
+    mock_gen_context = mocker.patch('cookiecutter.main.generate_context')
+    mock_gen_files = mocker.patch('cookiecutter.main.generate_files')
+    mock_replay_dump = mocker.patch('cookiecutter.main.dump')
+    mock_replay_load = mocker.patch('cookiecutter.main.load')
+
+    main.cookiecutter('foobar', replay=True)
+
+    assert not mock_prompt.called
+    assert not mock_gen_context.called
+    assert not mock_replay_dump.called
+    assert mock_replay_load.called
+    assert mock_gen_files.called
+
+
+def test_main_does_not_invoke_load_but_dump(mocker):
+    mock_prompt = mocker.patch('cookiecutter.main.prompt_for_config')
+    mock_gen_context = mocker.patch('cookiecutter.main.generate_context')
+    mock_gen_files = mocker.patch('cookiecutter.main.generate_files')
+    mock_replay_dump = mocker.patch('cookiecutter.main.dump')
+    mock_replay_load = mocker.patch('cookiecutter.main.load')
+
+    main.cookiecutter('foobar', replay=False)
+
+    assert mock_prompt.called
+    assert mock_gen_context.called
+    assert mock_replay_dump.called
+    assert not mock_replay_load.called
+    assert mock_gen_files.called
diff --git a/tests/test-config/valid-config.yaml b/tests/test-config/valid-config.yaml
index eab906f..95150d9 100644
--- a/tests/test-config/valid-config.yaml
+++ b/tests/test-config/valid-config.yaml
@@ -3,3 +3,4 @@ default_context:
     email: "firstname.lastname@gmail.com"
     github_username: "example"
 cookiecutters_dir: "/home/example/some-path-to-templates"
+replay_dir: "/home/example/some-path-to-replay-files"
diff --git a/tests/test-replay/cookiedozer_load.json b/tests/test-replay/cookiedozer_load.json
new file mode 100644
index 0000000..fc1b0f2
--- /dev/null
+++ b/tests/test-replay/cookiedozer_load.json
@@ -0,0 +1,1 @@
+{"cookiecutter": {"version": "0.1.0", "email": "raphael@hackebrot.de", "full_name": "Raphael Pierzina", "github_username": "hackebrot"}}
\ No newline at end of file
diff --git a/tests/test-replay/invalid_replay.json b/tests/test-replay/invalid_replay.json
new file mode 100644
index 0000000..884c3bc
--- /dev/null
+++ b/tests/test-replay/invalid_replay.json
@@ -0,0 +1,1 @@
+{"not cookiecutter": {"version": "0.1.0", "email": "raphael@hackebrot.de", "full_name": "Raphael Pierzina", "github_username": "hackebrot"}}
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d45e6ec..7fc4533 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -4,6 +4,7 @@ import pytest
 from click.testing import CliRunner
 
 from cookiecutter.cli import main
+from cookiecutter.main import cookiecutter
 from cookiecutter import utils
 
 runner = CliRunner()
@@ -55,3 +56,56 @@ def test_cli_verbose():
     result = runner.invoke(main, ['tests/fake-repo-pre/', '--no-input', '-v'])
     assert result.exit_code == 0
     assert os.path.isdir('fake-project')
+
+
+@pytest.mark.usefixtures('remove_fake_project_dir')
+def test_cli_replay(mocker):
+    mock_cookiecutter = mocker.patch(
+        'cookiecutter.cli.cookiecutter'
+    )
+
+    template_path = 'tests/fake-repo-pre/'
+    result = runner.invoke(main, [
+        template_path,
+        '--replay',
+        '-v'
+    ])
+
+    assert result.exit_code == 0
+    mock_cookiecutter.assert_called_once_with(
+        template_path,
+        None,
+        False,
+        replay=True
+    )
+
+
+@pytest.mark.usefixtures('remove_fake_project_dir')
+def test_cli_exit_on_noinput_and_replay(mocker):
+    mock_cookiecutter = mocker.patch(
+        'cookiecutter.cli.cookiecutter',
+        side_effect=cookiecutter
+    )
+
+    template_path = 'tests/fake-repo-pre/'
+    result = runner.invoke(main, [
+        template_path,
+        '--no-input',
+        '--replay',
+        '-v'
+    ])
+
+    assert result.exit_code == 1
+
+    expected_error_msg = (
+        "You can not use both replay and no_input or extra_context "
+        "at the same time."
+    )
+    assert expected_error_msg in result.output
+
+    mock_cookiecutter.assert_called_once_with(
+        template_path,
+        None,
+        True,
+        replay=True
+    )
diff --git a/tests/test_cookiecutter_invocation.py b/tests/test_cookiecutter_invocation.py
index 830c777..dbbcaf8 100644
--- a/tests/test_cookiecutter_invocation.py
+++ b/tests/test_cookiecutter_invocation.py
@@ -38,6 +38,7 @@ def project_dir(request):
     return rendered_dir
 
 
+@pytest.mark.usefixtures('clean_system')
 def test_should_invoke_main(monkeypatch, project_dir):
     monkeypatch.setenv('PYTHONPATH', '.')
 
diff --git a/tests/test_get_config.py b/tests/test_get_config.py
index b1d1475..f45d13e 100644
--- a/tests/test_get_config.py
+++ b/tests/test_get_config.py
@@ -28,6 +28,7 @@ def test_get_config():
     conf = config.get_config('tests/test-config/valid-config.yaml')
     expected_conf = {
         'cookiecutters_dir': '/home/example/some-path-to-templates',
+        'replay_dir': '/home/example/some-path-to-replay-files',
         'default_context': {
             'full_name': 'Firstname Lastname',
             'email': 'firstname.lastname@gmail.com',
@@ -56,12 +57,14 @@ def test_invalid_config():
 
 def test_get_config_with_defaults():
     """
-    A config file that overrides 1 of 2 defaults
+    A config file that overrides 1 of 3 defaults
     """
     conf = config.get_config('tests/test-config/valid-partial-config.yaml')
     default_cookiecutters_dir = os.path.expanduser('~/.cookiecutters/')
+    default_replay_dir = os.path.expanduser('~/.cookiecutter_replay/')
     expected_conf = {
         'cookiecutters_dir': default_cookiecutters_dir,
+        'replay_dir': default_replay_dir,
         'default_context': {
             'full_name': 'Firstname Lastname',
             'email': 'firstname.lastname@gmail.com',
diff --git a/tests/test_get_user_config.py b/tests/test_get_user_config.py
index 78acd99..f015fab 100644
--- a/tests/test_get_user_config.py
+++ b/tests/test_get_user_config.py
@@ -67,6 +67,7 @@ def test_get_user_config_valid(user_config_path):
     conf = config.get_user_config()
     expected_conf = {
         'cookiecutters_dir': '/home/example/some-path-to-templates',
+        'replay_dir': '/home/example/some-path-to-replay-files',
         'default_context': {
             'full_name': 'Firstname Lastname',
             'email': 'firstname.lastname@gmail.com',