Repository: audreyr/cookiecutter
Pull request #617 (2015-12-22 00:23:33)

Jinja2 extension support
Please see the [docs](https://github.com/audreyr/cookiecutter/compare/master...hackebrot:jinja2-extension-support?expand=1#diff-abec5659ca71b4547a2f66a5023bbc4fR377) :smile_cat: 

codecov-io: ## [Current coverage][1] is `99.29%`
> Merging **#617** into **master** will increase coverage by **+0.02%** as of [`106e9f0`][3]


```diff
@@            master    #617   diff @@
======================================
  Files           12      13     +1
  Stmts          549     567    +18
  Branches         0       0       
  Methods          0       0       
======================================
+ Hit            545     563    +18
  Partial          0       0       
  Missed           4       4       
```

> Review entire [Coverage Diff][4] as of [`106e9f0`][3]


-----
### [Uncovered Suggestions][2]

1. `+0.35%` via [...okiecutter/config.py#19...20](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/config.py?ref=106e9f00999c17e0650940f5bd301a3d37901d0f#19) 
1. `+0.35%` via [...ookiecutter/utils.py#27...28](https://codecov.io/github/audreyr/cookiecutter/cookiecutter/utils.py?ref=106e9f00999c17e0650940f5bd301a3d37901d0f#27) 


[1]: https://codecov.io/github/audreyr/cookiecutter?ref=106e9f00999c17e0650940f5bd301a3d37901d0f
[2]: https://codecov.io/github/audreyr/cookiecutter/features/suggestions?ref=106e9f00999c17e0650940f5bd301a3d37901d0f
[3]: https://codecov.io/github/audreyr/cookiecutter/commit/106e9f00999c17e0650940f5bd301a3d37901d0f
[4]: https://codecov.io/github/audreyr/cookiecutter/compare/c95d7d8acb2a477a5d3e3720304829874aa27bf9...106e9f00999c17e0650940f5bd301a3d37901d0f

> Powered by [Codecov](https://codecov.io). Updated on successful CI builds.
hackebrot: cc @ionelmc :wave: 
ionelmc: +1 awesome
hackebrot: :smile_cat: 
audreyr: Took a quick look. So far it looks good, nice work @hackebrot. I'll try it out tomorrow and study it in more detail.
hackebrot: Thank you for your review @audreyr! Look forward to getting this one in :joy_cat: 

Patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
index 92e4d12..0048f45 100755
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -22,7 +22,8 @@ from cookiecutter.exceptions import (
     OutputDirExistsException,
     InvalidModeException,
     FailedHookException,
-    UndefinedVariableInTemplate
+    UndefinedVariableInTemplate,
+    UnknownExtension
 )
 
 logger = logging.getLogger(__name__)
@@ -105,7 +106,8 @@ def main(template, no_input, checkout, verbose, replay, overwrite_if_exists,
         )
     except (OutputDirExistsException,
             InvalidModeException,
-            FailedHookException) as e:
+            FailedHookException,
+            UnknownExtension) as e:
         click.echo(e)
         sys.exit(1)
     except UndefinedVariableInTemplate as undefined_err:
diff --git a/cookiecutter/environment.py b/cookiecutter/environment.py
new file mode 100644
index 0000000..f228ffb
--- /dev/null
+++ b/cookiecutter/environment.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+
+from jinja2 import Environment, StrictUndefined
+
+from .exceptions import UnknownExtension
+
+
+class ExtensionLoaderMixin(object):
+    """Mixin that provides a sane way of loading extensions specified in a
+    given context.
+
+    The context is being extracted from the keyword arguments before calling
+    the next parent class in line of the child.
+    """
+    def __init__(self, **kwargs):
+        context = kwargs.pop('context', {})
+
+        try:
+            super(ExtensionLoaderMixin, self).__init__(
+                extensions=self._read_extensions(context),
+                **kwargs
+            )
+        except ImportError as err:
+            raise UnknownExtension('Unable to load extension: {}'.format(err))
+
+    def _read_extensions(self, context):
+        """Return a list of extensions as str to be passed on to the jinja2
+        env. If context does not contain the relevant info, return an empty
+        list instead.
+        """
+        try:
+            extensions = context['cookiecutter']['_extensions']
+        except KeyError:
+            return []
+        else:
+            return [str(ext) for ext in extensions]
+
+
+class StrictEnvironment(ExtensionLoaderMixin, Environment):
+    """Jinja2 environment that raises an error when it hits a variable
+    which is not defined in the context used to render a template.
+    """
+    def __init__(self, **kwargs):
+        super(StrictEnvironment, self).__init__(
+            undefined=StrictUndefined,
+            **kwargs
+        )
diff --git a/cookiecutter/exceptions.py b/cookiecutter/exceptions.py
index 0179625..0789b19 100755
--- a/cookiecutter/exceptions.py
+++ b/cookiecutter/exceptions.py
@@ -104,3 +104,7 @@ class UndefinedVariableInTemplate(CookiecutterException):
             "Error message: {self.error.message}. "
             "Context: {self.context}"
         ).format(**locals())
+
+
+class UnknownExtension(CookiecutterException):
+    """Raised when an environment is unable to import a required extension."""
diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py
index d627646..5ee2934 100755
--- a/cookiecutter/generate.py
+++ b/cookiecutter/generate.py
@@ -16,8 +16,8 @@ import logging
 import os
 import shutil
 
-from jinja2 import FileSystemLoader, StrictUndefined
-from jinja2.environment import Environment
+from jinja2 import FileSystemLoader
+from cookiecutter.environment import StrictEnvironment
 from jinja2.exceptions import TemplateSyntaxError, UndefinedError
 from binaryornot.check import is_binary
 
@@ -256,9 +256,9 @@ def generate_files(repo_dir, context=None, output_dir='.',
 
     unrendered_dir = os.path.split(template_dir)[1]
     ensure_dir_is_templated(unrendered_dir)
-    env = Environment(
+    env = StrictEnvironment(
+        context=context,
         keep_trailing_newline=True,
-        undefined=StrictUndefined
     )
     try:
         project_dir = render_and_create_dir(
diff --git a/cookiecutter/prompt.py b/cookiecutter/prompt.py
index 7b931c8..0d7e4df 100755
--- a/cookiecutter/prompt.py
+++ b/cookiecutter/prompt.py
@@ -15,11 +15,10 @@ from past.builtins import basestring
 
 from future.utils import iteritems
 
-from jinja2 import StrictUndefined
-from jinja2.environment import Environment
 from jinja2.exceptions import UndefinedError
 
 from .exceptions import UndefinedVariableInTemplate
+from .environment import StrictEnvironment
 
 
 def read_user_variable(var_name, default_value):
@@ -117,7 +116,7 @@ def prompt_for_config(context, no_input=False):
     :param no_input: Prompt the user at command line for manual configuration?
     """
     cookiecutter_dict = {}
-    env = Environment(undefined=StrictUndefined)
+    env = StrictEnvironment(context=context)
 
     for key, raw in iteritems(context[u'cookiecutter']):
         if key.startswith(u'_'):
diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst
index 9680887..bc21ac3 100644
--- a/docs/advanced_usage.rst
+++ b/docs/advanced_usage.rst
@@ -371,3 +371,36 @@ The resulting prompt changes and looks like::
 
 .. note::
    As you can see the order of the options changed from ``1 - MIT`` to ``1 - Apache Software License 2.0``. **Cookiecutter** takes the first value in the list as the default.
+
+.. _`template extensions`:
+
+Template Extensions
+-------------------
+
+*New in Cookiecutter 1.4*
+
+A template may extend the Cookiecutter environment with custom `Jinja2 extensions`_,
+that can add extra filters, tests, globals or even extend the parser.
+
+To do so, a template author must specify the required extensions in ``cookiecutter.json`` as follows:
+
+.. code-block:: json
+
+    {
+        "project_slug": "Foobar",
+        "year": "{% now 'utc', '%Y' %}",
+        "_extensions": ["jinja2_time.TimeExtension"]
+    }
+
+On invocation Cookiecutter tries to import the extensions and add them to its environment respectively.
+
+In the above example, Cookiecutter provides the additional tag `now`_, after
+installing the `jinja2_time.TimeExtension`_ and enabling it in ``cookiecutter.json``.
+
+Please note that Cookiecutter will **not** install any dependencies on its own!
+As a user you need to make sure you have all the extensions installed, before
+running Cookiecutter on a template that requires custom Jinja2 extensions.
+
+.. _`Jinja2 extensions`: http://jinja2.readthedocs.org/en/latest/extensions.html#extensions
+.. _`now`: https://github.com/hackebrot/jinja2-time#now-tag
+.. _`jinja2_time.TimeExtension`: https://github.com/hackebrot/jinja2-time
diff --git a/docs/cookiecutter.rst b/docs/cookiecutter.rst
index 7c91eff..3db4f1a 100644
--- a/docs/cookiecutter.rst
+++ b/docs/cookiecutter.rst
@@ -20,6 +20,14 @@ cookiecutter.config module
     :undoc-members:
     :show-inheritance:
 
+cookiecutter.environment module
+-------------------------------
+
+.. automodule:: cookiecutter.environment
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 cookiecutter.exceptions module
 ------------------------------
 


Test patch:
diff --git a/tests/test-extensions/cookiecutter.json b/tests/test-extensions/cookiecutter.json
new file mode 100644
index 0000000..d03feef
--- /dev/null
+++ b/tests/test-extensions/cookiecutter.json
@@ -0,0 +1,6 @@
+{
+    "project_slug": "Foobar",
+    "year": "{% now 'utc', '%Y' %}",
+    "_extensions": ["jinja2_time.TimeExtension"]
+}
+
diff --git a/tests/test-extensions/{{cookiecutter.project_slug}}/HISTORY.rst b/tests/test-extensions/{{cookiecutter.project_slug}}/HISTORY.rst
new file mode 100644
index 0000000..84a0ae0
--- /dev/null
+++ b/tests/test-extensions/{{cookiecutter.project_slug}}/HISTORY.rst
@@ -0,0 +1,7 @@
+History
+-------
+
+0.1.0 ({% now 'utc', '%Y-%m-%d' %})
+---------------------
+
+First release on PyPI.
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0db9db8..1cd0139 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -326,3 +326,20 @@ def test_echo_undefined_variable_error(tmpdir):
     }
     context_str = json.dumps(context, indent=4, sort_keys=True)
     assert context_str in result.output
+
+
+def test_echo_unknown_extension_error(tmpdir):
+    output_dir = str(tmpdir.mkdir('output'))
+    template_path = 'tests/test-extensions/'
+
+    result = runner.invoke(main, [
+        '--no-input',
+        '--default-config',
+        '--output-dir',
+        output_dir,
+        template_path,
+    ])
+
+    assert result.exit_code == 1
+
+    assert 'Unable to load extension: ' in result.output
diff --git a/tests/test_environment.py b/tests/test_environment.py
new file mode 100644
index 0000000..d585662
--- /dev/null
+++ b/tests/test_environment.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from cookiecutter.environment import StrictEnvironment
+from cookiecutter.exceptions import UnknownExtension
+
+
+def test_env_should_raise_for_unknown_extension():
+    context = {
+        'cookiecutter': {
+            '_extensions': ['foobar']
+        }
+    }
+
+    with pytest.raises(UnknownExtension) as err:
+        StrictEnvironment(context=context, keep_trailing_newline=True)
+
+    assert 'Unable to load extension: ' in str(err.value)
diff --git a/tests/test_generate_file.py b/tests/test_generate_file.py
index 187973e..40a7260 100755
--- a/tests/test_generate_file.py
+++ b/tests/test_generate_file.py
@@ -15,10 +15,10 @@ import os
 import pytest
 
 from jinja2 import FileSystemLoader
-from jinja2.environment import Environment
 from jinja2.exceptions import TemplateSyntaxError
 
 from cookiecutter import generate
+from cookiecutter.environment import StrictEnvironment
 
 
 @pytest.fixture(scope='function')
@@ -34,7 +34,7 @@ def remove_cheese_file(request):
 
 @pytest.fixture
 def env():
-    environment = Environment()
+    environment = StrictEnvironment()
     environment.loader = FileSystemLoader('.')
     return environment
 
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
index 42aad0b..1129715 100644
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -14,8 +14,7 @@ import platform
 import pytest
 from past.builtins import basestring
 
-from cookiecutter import prompt, exceptions
-from jinja2.environment import Environment
+from cookiecutter import prompt, exceptions, environment
 
 
 @pytest.mark.parametrize('raw_var, rendered_var', [
@@ -26,9 +25,9 @@ from jinja2.environment import Environment
     (None, None),
 ])
 def test_convert_to_str(mocker, raw_var, rendered_var):
-    env = Environment()
+    env = environment.StrictEnvironment()
     from_string = mocker.patch(
-        'cookiecutter.prompt.Environment.from_string',
+        'cookiecutter.prompt.StrictEnvironment.from_string',
         wraps=env.from_string
     )
     context = {'project': 'foobar'}
@@ -228,7 +227,7 @@ class TestPromptChoiceForConfig(object):
 
         actual_choice = prompt.prompt_choice_for_config(
             context,
-            Environment(),
+            environment.StrictEnvironment(),
             'orientation',
             choices,
             True  # Suppress user input
@@ -244,7 +243,7 @@ class TestPromptChoiceForConfig(object):
 
         actual_choice = prompt.prompt_choice_for_config(
             context,
-            Environment(),
+            environment.StrictEnvironment(),
             'orientation',
             choices,
             False  # Ask the user for input