Repository: cookiecutter/cookiecutter

Issue:
When directory name resolves to empty string cookiecutter tries to put things in root of filesystem
When using the force flag to get around this issue https://github.com/audreyr/cookiecutter/issues/585 I got a traceback saying that cookiecutter does not have permission to create a file.

Turns out that this was because the package directory name resolved to an empty string.

It would seem that this behaviour could be rather dangerous..

```
Traceback (most recent call last):
  File "/Users/human/.virtualenvs/service-sandbox/bin/cookiecutter", line 11, in <module>
    sys.exit(main())
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/click/core.py", line 700, in __call__
    return self.main(*args, **kwargs)
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/click/core.py", line 680, in main
    rv = self.invoke(ctx)
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/click/core.py", line 873, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/click/core.py", line 508, in invoke
    return callback(*args, **kwargs)
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/cookiecutter/cli.py", line 100, in main
    config_file=user_config
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/cookiecutter/main.py", line 140, in cookiecutter
    output_dir=output_dir
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/cookiecutter/generate.py", line 327, in generate_files
    generate_file(project_dir, infile, context, env)
  File "/Users/human/.virtualenvs/service-sandbox/lib/python2.7/site-packages/cookiecutter/generate.py", line 177, in generate_file
    with io.open(outfile, 'w', encoding='utf-8') as fh:
IOError: [Errno 13] Permission denied: '/__init__.py'
```

Raise helpful exception when path contains undefined variable
Fixes #586



Discussion:
Can you please provide the following:
1. The exact way you invoked Cookiecutter. ex. `cookiecutter <MY-PROJECT-LOCATION`.
2. Your operating system.
3. If possible, the URL location of the Cookiecutter template you are trying to use.

Here's a minimal example that shows the behaviour https://github.com/johtso/cookiecutter-paths-bug

```
❯ cookiecutter -o ../tmp_cookiecutter_output . -f
test [foo]:
Traceback (most recent call last):
  File "/usr/local/bin/cookiecutter", line 11, in <module>
    sys.exit(main())
  File "/usr/local/lib/python2.7/site-packages/click/core.py", line 700, in __call__
    return self.main(*args, **kwargs)
  File "/usr/local/lib/python2.7/site-packages/click/core.py", line 680, in main
    rv = self.invoke(ctx)
  File "/usr/local/lib/python2.7/site-packages/click/core.py", line 873, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/usr/local/lib/python2.7/site-packages/click/core.py", line 508, in invoke
    return callback(*args, **kwargs)
  File "/usr/local/lib/python2.7/site-packages/cookiecutter/cli.py", line 100, in main
    config_file=user_config
  File "/usr/local/lib/python2.7/site-packages/cookiecutter/main.py", line 140, in cookiecutter
    output_dir=output_dir
  File "/usr/local/lib/python2.7/site-packages/cookiecutter/generate.py", line 327, in generate_files
    generate_file(project_dir, infile, context, env)
  File "/usr/local/lib/python2.7/site-packages/cookiecutter/generate.py", line 177, in generate_file
    with io.open(outfile, 'w', encoding='utf-8') as fh:
IOError: [Errno 13] Permission denied: '/test.txt'
```

The root of this bug is here: https://github.com/audreyr/cookiecutter/blob/master/cookiecutter/generate.py#L146

`infile` is something like `'{{cookiecutter.not_defined}}/test.txt'` which is then rendered to `'/test.txt'`.

Consequently doing an `os.path.join` with the `project_dir` results in `'/test.txt'`



Resolving patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -11,6 +11,7 @@
 import os
 import sys
 import logging
+import json
 
 import click
 
@@ -18,7 +19,10 @@
 from cookiecutter.config import USER_CONFIG_PATH
 from cookiecutter.main import cookiecutter
 from cookiecutter.exceptions import (
-    OutputDirExistsException, InvalidModeException, FailedHookException
+    OutputDirExistsException,
+    InvalidModeException,
+    FailedHookException,
+    UndefinedVariableInTemplate
 )
 
 logger = logging.getLogger(__name__)
@@ -100,9 +104,21 @@ def main(template, no_input, checkout, verbose, replay, overwrite_if_exists,
             config_file=user_config
         )
     except (OutputDirExistsException,
-            InvalidModeException, FailedHookException) as e:
+            InvalidModeException,
+            FailedHookException) as e:
         click.echo(e)
         sys.exit(1)
+    except UndefinedVariableInTemplate as undefined_err:
+        click.echo('{}'.format(undefined_err.message))
+        click.echo('Error message: {}'.format(undefined_err.error.message))
+
+        context_str = json.dumps(
+            undefined_err.context,
+            indent=4,
+            sort_keys=True
+        )
+        click.echo('Context: {}'.format(context_str))
+        sys.exit(1)
 
 
 if __name__ == "__main__":
diff --git a/cookiecutter/exceptions.py b/cookiecutter/exceptions.py
--- a/cookiecutter/exceptions.py
+++ b/cookiecutter/exceptions.py
@@ -87,3 +87,20 @@ class FailedHookException(CookiecutterException):
     """
     Raised when a hook script fails
     """
+
+
+class UndefinedVariableInTemplate(CookiecutterException):
+    """Raised when a template uses a variable which is not defined in the
+    context.
+    """
+    def __init__(self, message, error, context):
+        self.message = message
+        self.error = error
+        self.context = context
+
+    def __str__(self):
+        return (
+            "{self.message}. "
+            "Error message: {self.error.message}. "
+            "Context: {self.context}"
+        ).format(**locals())
diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py
--- a/cookiecutter/generate.py
+++ b/cookiecutter/generate.py
@@ -16,16 +16,17 @@
 import os
 import shutil
 
-from jinja2 import FileSystemLoader, Template
+from jinja2 import FileSystemLoader, StrictUndefined
 from jinja2.environment import Environment
-from jinja2.exceptions import TemplateSyntaxError
+from jinja2.exceptions import TemplateSyntaxError, UndefinedError
 from binaryornot.check import is_binary
 
 from .exceptions import (
     NonTemplatedInputDirException,
     ContextDecodingException,
     FailedHookException,
-    OutputDirExistsException
+    OutputDirExistsException,
+    UndefinedVariableInTemplate
 )
 from .find import find_template
 from .utils import make_sure_path_exists, work_in, rmtree
@@ -141,7 +142,7 @@ def generate_file(project_dir, infile, context, env):
     logging.debug('Generating file {0}'.format(infile))
 
     # Render the path to the output file (not including the root project dir)
-    outfile_tmpl = Template(infile)
+    outfile_tmpl = env.from_string(infile)
 
     outfile = os.path.join(project_dir, outfile_tmpl.render(**context))
     file_name_is_empty = os.path.isdir(outfile)
@@ -181,14 +182,14 @@ def generate_file(project_dir, infile, context, env):
     shutil.copymode(infile, outfile)
 
 
-def render_and_create_dir(dirname, context, output_dir,
+def render_and_create_dir(dirname, context, output_dir, environment,
                           overwrite_if_exists=False):
     """
     Renders the name of a directory, creates the directory, and
     returns its path.
     """
 
-    name_tmpl = Template(dirname)
+    name_tmpl = environment.from_string(dirname)
     rendered_dirname = name_tmpl.render(**context)
     logging.debug('Rendered dir {0} must exist in output_dir {1}'.format(
         rendered_dirname,
@@ -255,10 +256,21 @@ def generate_files(repo_dir, context=None, output_dir='.',
 
     unrendered_dir = os.path.split(template_dir)[1]
     ensure_dir_is_templated(unrendered_dir)
-    project_dir = render_and_create_dir(unrendered_dir,
-                                        context,
-                                        output_dir,
-                                        overwrite_if_exists)
+    env = Environment(
+        keep_trailing_newline=True,
+        undefined=StrictUndefined
+    )
+    try:
+        project_dir = render_and_create_dir(
+            unrendered_dir,
+            context,
+            output_dir,
+            env,
+            overwrite_if_exists
+        )
+    except UndefinedError as err:
+        msg = "Unable to create project directory '{}'".format(unrendered_dir)
+        raise UndefinedVariableInTemplate(msg, err, context)
 
     # We want the Jinja path and the OS paths to match. Consequently, we'll:
     #   + CD to the template folder
@@ -273,7 +285,6 @@ def generate_files(repo_dir, context=None, output_dir='.',
     _run_hook_from_repo_dir(repo_dir, 'pre_gen_project', project_dir, context)
 
     with work_in(template_dir):
-        env = Environment(keep_trailing_newline=True)
         env.loader = FileSystemLoader('.')
 
         for root, dirs, files in os.walk('.'):
@@ -307,13 +318,24 @@ def generate_files(repo_dir, context=None, output_dir='.',
             dirs[:] = render_dirs
             for d in dirs:
                 unrendered_dir = os.path.join(project_dir, root, d)
-                render_and_create_dir(unrendered_dir, context, output_dir,
-                                      overwrite_if_exists)
+                try:
+                    render_and_create_dir(
+                        unrendered_dir,
+                        context,
+                        output_dir,
+                        env,
+                        overwrite_if_exists
+                    )
+                except UndefinedError as err:
+                    rmtree(project_dir)
+                    _dir = os.path.relpath(unrendered_dir, output_dir)
+                    msg = "Unable to create directory '{}'".format(_dir)
+                    raise UndefinedVariableInTemplate(msg, err, context)
 
             for f in files:
                 infile = os.path.normpath(os.path.join(root, f))
                 if copy_without_render(infile, context):
-                    outfile_tmpl = Template(infile)
+                    outfile_tmpl = env.from_string(infile)
                     outfile_rendered = outfile_tmpl.render(**context)
                     outfile = os.path.join(project_dir, outfile_rendered)
                     logging.debug(
@@ -324,7 +346,12 @@ def generate_files(repo_dir, context=None, output_dir='.',
                     shutil.copymode(infile, outfile)
                     continue
                 logging.debug('f is {0}'.format(f))
-                generate_file(project_dir, infile, context, env)
+                try:
+                    generate_file(project_dir, infile, context, env)
+                except UndefinedError as err:
+                    rmtree(project_dir)
+                    msg = "Unable to create file '{}'".format(infile)
+                    raise UndefinedVariableInTemplate(msg, err, context)
 
     _run_hook_from_repo_dir(repo_dir, 'post_gen_project', project_dir, context)
 


Test patch:
diff --git a/tests/hooks-abort-render/{{cookiecutter.repo_dir}}/README.rst b/tests/hooks-abort-render/{{cookiecutter.repo_dir}}/README.rst
--- a/tests/hooks-abort-render/{{cookiecutter.repo_dir}}/README.rst
+++ b/tests/hooks-abort-render/{{cookiecutter.repo_dir}}/README.rst
@@ -1,2 +1,2 @@
-{{cookiecutter.repo_name}}
-==========================
+{{cookiecutter.repo_dir}}
+=========================
diff --git a/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/readme.txt b/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/readme.txt
--- a/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/readme.txt
+++ b/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/readme.txt
@@ -1 +1 @@
-I eat {{ binary_test }}
\ No newline at end of file
+I eat {{ cookiecutter.binary_test }}
diff --git a/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/{{cookiecutter.binary_test}}/readme.txt b/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/{{cookiecutter.binary_test}}/readme.txt
--- a/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/{{cookiecutter.binary_test}}/readme.txt
+++ b/tests/test-generate-binaries/input{{cookiecutter.binary_test}}/{{cookiecutter.binary_test}}/readme.txt
@@ -1 +1 @@
-I eat {{ binary_test }}
\ No newline at end of file
+I eat {{ cookiecutter.binary_test }}
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,4 +1,5 @@
 import os
+import json
 import pytest
 
 from click.testing import CliRunner
@@ -295,3 +296,33 @@ def test_default_user_config(mocker):
         output_dir='.',
         config_file=None
     )
+
+
+def test_echo_undefined_variable_error(tmpdir):
+    output_dir = str(tmpdir.mkdir('output'))
+    template_path = 'tests/undefined-variable/file-name/'
+
+    result = runner.invoke(main, [
+        '--no-input',
+        '--default-config',
+        '--output-dir',
+        output_dir,
+        template_path,
+    ])
+
+    assert result.exit_code == 1
+
+    error = "Unable to create file '{{cookiecutter.foobar}}'"
+    assert error in result.output
+
+    message = "Error message: 'dict object' has no attribute 'foobar'"
+    assert message in result.output
+
+    context = {
+        'cookiecutter': {
+            'github_username': 'hackebrot',
+            'project_slug': 'testproject'
+        }
+    }
+    context_str = json.dumps(context, indent=4, sort_keys=True)
+    assert context_str in result.output
diff --git a/tests/test_cookiecutters.py b/tests/test_cookiecutters.py
--- a/tests/test_cookiecutters.py
+++ b/tests/test_cookiecutters.py
@@ -51,7 +51,10 @@ def bake_data():
     )
 
     yield pypackage_data
-    yield jquery_data
+
+    # TODO: Remove xfail as soon as PR has been accepted
+    # https://github.com/audreyr/cookiecutter-jquery/pull/2
+    yield pytest.mark.xfail(jquery_data, reason='Undefined variable')
 
 
 @skipif_travis
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+from jinja2.exceptions import UndefinedError
+
+from cookiecutter import exceptions
+
+
+def test_undefined_variable_to_str():
+    undefined_var_error = exceptions.UndefinedVariableInTemplate(
+        'Beautiful is better than ugly',
+        UndefinedError('Errors should never pass silently'),
+        {'cookiecutter': {'foo': 'bar'}}
+    )
+
+    expected_str = (
+        "Beautiful is better than ugly. "
+        "Error message: Errors should never pass silently. "
+        "Context: {'cookiecutter': {'foo': 'bar'}}"
+    )
+
+    assert str(undefined_var_error) == expected_str
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
@@ -212,3 +212,82 @@ def test_generate_files_permissions():
     )
     input_script_file_mode = os.stat(input_script_file).st_mode & 0o777
     assert tests_script_file_mode == input_script_file_mode
+
+
+@pytest.fixture
+def undefined_context():
+    return {
+        'cookiecutter': {
+            'project_slug': 'testproject',
+            'github_username': 'hackebrot'
+        }
+    }
+
+
+def test_raise_undefined_variable_file_name(tmpdir, undefined_context):
+    output_dir = tmpdir.mkdir('output')
+
+    with pytest.raises(exceptions.UndefinedVariableInTemplate) as err:
+        generate.generate_files(
+            repo_dir='tests/undefined-variable/file-name/',
+            output_dir=str(output_dir),
+            context=undefined_context
+        )
+    error = err.value
+    assert "Unable to create file '{{cookiecutter.foobar}}'" == error.message
+    assert error.context == undefined_context
+
+    assert not output_dir.join('testproject').exists()
+
+
+def test_raise_undefined_variable_file_content(tmpdir, undefined_context):
+    output_dir = tmpdir.mkdir('output')
+
+    with pytest.raises(exceptions.UndefinedVariableInTemplate) as err:
+        generate.generate_files(
+            repo_dir='tests/undefined-variable/file-content/',
+            output_dir=str(output_dir),
+            context=undefined_context
+        )
+    error = err.value
+    assert "Unable to create file 'README.rst'" == error.message
+    assert error.context == undefined_context
+
+    assert not output_dir.join('testproject').exists()
+
+
+def test_raise_undefined_variable_dir_name(tmpdir, undefined_context):
+    output_dir = tmpdir.mkdir('output')
+
+    with pytest.raises(exceptions.UndefinedVariableInTemplate) as err:
+        generate.generate_files(
+            repo_dir='tests/undefined-variable/dir-name/',
+            output_dir=str(output_dir),
+            context=undefined_context
+        )
+    error = err.value
+
+    directory = os.path.join('testproject', '{{cookiecutter.foobar}}')
+    msg = "Unable to create directory '{}'".format(directory)
+    assert msg == error.message
+
+    assert error.context == undefined_context
+
+    assert not output_dir.join('testproject').exists()
+
+
+def test_raise_undefined_variable_project_dir(tmpdir):
+    output_dir = tmpdir.mkdir('output')
+
+    with pytest.raises(exceptions.UndefinedVariableInTemplate) as err:
+        generate.generate_files(
+            repo_dir='tests/undefined-variable/dir-name/',
+            output_dir=str(output_dir),
+            context={}
+        )
+    error = err.value
+    msg = "Unable to create project directory '{{cookiecutter.project_slug}}'"
+    assert msg == error.message
+    assert error.context == {}
+
+    assert not output_dir.join('testproject').exists()
diff --git a/tests/undefined-variable/dir-name/{{cookiecutter.project_slug}}/README.rst b/tests/undefined-variable/dir-name/{{cookiecutter.project_slug}}/README.rst
new file mode 100644
--- /dev/null
+++ b/tests/undefined-variable/dir-name/{{cookiecutter.project_slug}}/README.rst
@@ -0,0 +1,2 @@
+{{cookiecutter.project_slug}}
+{% for _ in cookiecutter.project_slug %}={% endfor %}
diff --git a/tests/undefined-variable/dir-name/{{cookiecutter.project_slug}}/{{cookiecutter.foobar}}/helloworld.py b/tests/undefined-variable/dir-name/{{cookiecutter.project_slug}}/{{cookiecutter.foobar}}/helloworld.py
new file mode 100644
diff --git a/tests/undefined-variable/file-content/{{cookiecutter.project_slug}}/README.rst b/tests/undefined-variable/file-content/{{cookiecutter.project_slug}}/README.rst
new file mode 100644
--- /dev/null
+++ b/tests/undefined-variable/file-content/{{cookiecutter.project_slug}}/README.rst
@@ -0,0 +1,6 @@
+{{cookiecutter.project_slug}}
+{% for _ in cookiecutter.project_slug %}={% endfor %}
+
+{{cookiecutter.foobar}}
+
+https://github.com/{{cookiecutter.github_username}}/{{cookiecutter.project_slug}}
diff --git a/tests/undefined-variable/file-name/cookiecutter.json b/tests/undefined-variable/file-name/cookiecutter.json
new file mode 100644
--- /dev/null
+++ b/tests/undefined-variable/file-name/cookiecutter.json
@@ -0,0 +1,4 @@
+{
+    "project_slug": "testproject",
+    "github_username": "hackebrot"
+}
diff --git a/tests/undefined-variable/file-name/{{cookiecutter.project_slug}}/{{cookiecutter.foobar}} b/tests/undefined-variable/file-name/{{cookiecutter.project_slug}}/{{cookiecutter.foobar}}
new file mode 100644
--- /dev/null
+++ b/tests/undefined-variable/file-name/{{cookiecutter.project_slug}}/{{cookiecutter.foobar}}
@@ -0,0 +1,2 @@
+{{cookiecutter.project_slug}}
+{% for _ in cookiecutter.project_slug %}={% endfor %}