Repository: cookiecutter/cookiecutter

Issue:
add CLI option not to delete files on failure
If there's a bug in my hooks, it can be tough to debug it. I'd like to be able to inspect the generated files.

Currently, I modify the hook to exit with code 0 just before the failing line, but it'd be nice if that wasn't necessary.


Discussion:

Resolving patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -144,6 +144,11 @@ def list_installed_templates(default_config, passed_config_file):
 @click.option(
     '-l', '--list-installed', is_flag=True, help='List currently installed templates.'
 )
+@click.option(
+    '--keep-project-on-failure',
+    is_flag=True,
+    help='Do not delete project folder on failure',
+)
 def main(
     template,
     extra_context,
@@ -161,6 +166,7 @@ def main(
     accept_hooks,
     replay_file,
     list_installed,
+    keep_project_on_failure,
 ):
     """Create a project from a Cookiecutter project template (TEMPLATE).
 
@@ -205,6 +211,7 @@ def main(
             directory=directory,
             skip_if_file_exists=skip_if_file_exists,
             accept_hooks=_accept_hooks,
+            keep_project_on_failure=keep_project_on_failure,
         )
     except (
         ContextDecodingException,
diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py
--- a/cookiecutter/generate.py
+++ b/cookiecutter/generate.py
@@ -268,6 +268,7 @@ def generate_files(
     overwrite_if_exists=False,
     skip_if_file_exists=False,
     accept_hooks=True,
+    keep_project_on_failure=False,
 ):
     """Render the templates and saves them to files.
 
@@ -277,6 +278,8 @@ def generate_files(
     :param overwrite_if_exists: Overwrite the contents of the output directory
         if it exists.
     :param accept_hooks: Accept pre and post hooks if set to `True`.
+    :param keep_project_on_failure: If `True` keep generated project directory even when
+        generation fails
     """
     template_dir = find_template(repo_dir)
     logger.debug('Generating project from %s...', template_dir)
@@ -307,7 +310,7 @@ def generate_files(
 
     # if we created the output directory, then it's ok to remove it
     # if rendering fails
-    delete_project_on_failure = output_directory_created
+    delete_project_on_failure = output_directory_created and not keep_project_on_failure
 
     if accept_hooks:
         _run_hook_from_repo_dir(
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -34,6 +34,7 @@ def cookiecutter(
     directory=None,
     skip_if_file_exists=False,
     accept_hooks=True,
+    keep_project_on_failure=False,
 ):
     """
     Run Cookiecutter just as if using it from the command line.
@@ -53,6 +54,8 @@ def cookiecutter(
     :param password: The password to use when extracting the repository.
     :param directory: Relative path to a cookiecutter template in a repository.
     :param accept_hooks: Accept pre and post hooks if set to `True`.
+    :param keep_project_on_failure: If `True` keep generated project directory even when
+        generation fails
     """
     if replay and ((no_input is not False) or (extra_context is not None)):
         err_msg = (
@@ -118,6 +121,7 @@ def cookiecutter(
             skip_if_file_exists=skip_if_file_exists,
             output_dir=output_dir,
             accept_hooks=accept_hooks,
+            keep_project_on_failure=keep_project_on_failure,
         )
 
     # Cleanup (if required)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -109,6 +109,7 @@ def test_cli_replay(mocker, cli_runner):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -135,6 +136,7 @@ def test_cli_replay_file(mocker, cli_runner):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -170,6 +172,7 @@ def test_cli_exit_on_noinput_and_replay(mocker, cli_runner):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -205,6 +208,7 @@ def test_run_cookiecutter_on_overwrite_if_exists_and_replay(
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -261,6 +265,7 @@ def test_cli_output_dir(mocker, cli_runner, output_dir_flag, output_dir):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -305,6 +310,7 @@ def test_user_config(mocker, cli_runner, user_config_path):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -335,6 +341,7 @@ def test_default_user_config_overwrite(mocker, cli_runner, user_config_path):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -360,6 +367,7 @@ def test_default_user_config(mocker, cli_runner):
         password=None,
         directory=None,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -629,6 +637,7 @@ def test_cli_accept_hooks(
         directory=None,
         skip_if_file_exists=False,
         accept_hooks=expected,
+        keep_project_on_failure=False,
     )
 
 
diff --git a/tests/test_generate_files.py b/tests/test_generate_files.py
--- a/tests/test_generate_files.py
+++ b/tests/test_generate_files.py
@@ -390,6 +390,18 @@ def test_raise_undefined_variable_dir_name(output_dir, undefined_context):
     assert not Path(output_dir).joinpath('testproject').exists()
 
 
+def test_keep_project_dir_on_failure(output_dir, undefined_context):
+    """Verify correct error raised when directory name cannot be rendered."""
+    with pytest.raises(exceptions.UndefinedVariableInTemplate):
+        generate.generate_files(
+            repo_dir='tests/undefined-variable/dir-name/',
+            output_dir=output_dir,
+            context=undefined_context,
+            keep_project_on_failure=True,
+        )
+    assert Path(output_dir).joinpath('testproject').exists()
+
+
 def test_raise_undefined_variable_dir_name_existing_project(
     output_dir, undefined_context
 ):
diff --git a/tests/test_specify_output_dir.py b/tests/test_specify_output_dir.py
--- a/tests/test_specify_output_dir.py
+++ b/tests/test_specify_output_dir.py
@@ -57,6 +57,7 @@ def test_api_invocation(mocker, template, output_dir, context):
         skip_if_file_exists=False,
         output_dir=output_dir,
         accept_hooks=True,
+        keep_project_on_failure=False,
     )
 
 
@@ -73,4 +74,5 @@ def test_default_output_dir(mocker, template, context):
         skip_if_file_exists=False,
         output_dir='.',
         accept_hooks=True,
+        keep_project_on_failure=False,
     )