Repository: cookiecutter/cookiecutter

Issue:
Context injection from pre hook / access from post hook
It would be good to have a standard way to:
- inject context vars from the pre script
- access context vars from the post script

This should also be documented as a best practice so that there aren't multiple ways of doing the same thing.



Discussion:
@audreyr - I also need this and I am willing to help as much as I can. I am dealing with this at the moment by injecting the context data as part of the environment variables when the post hook scripts execute (added them to a copy of the main environment variables and passed them to subprocess.Popen). I guess it's not as clean as rendering the scripts but is relatively easy to do and does solves my problem at the moment.

Thoughts? What is your recommended approach?

So I have very limited internet connectivity through next week, but I plan to get some Cookiecutter time in soon (Oct 14ish).

@jguedez in the meantime here's something I could use help with: I haven't gotten a chance to look at it yet, but I think this is what @fakedrake implemented in #77. If you have spare time, help review the pull request, write constructive feedback if needed, maybe try out the branch locally and make any changes that are needed. I'm also curious how it compares to your injection approach.

@audreyr  OK sounds good, I will take a look at this over the weekend...


Resolving patch:
diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py
--- a/cookiecutter/generate.py
+++ b/cookiecutter/generate.py
@@ -183,7 +183,7 @@ def generate_files(repo_dir, context=None, output_dir="."):
 
     # run pre-gen hook from repo_dir
     with work_in(repo_dir):
-        run_hook('pre_gen_project', project_dir)
+        run_hook('pre_gen_project', project_dir, context)
 
     with work_in(template_dir):
         env = Environment(keep_trailing_newline=True)
@@ -202,4 +202,4 @@ def generate_files(repo_dir, context=None, output_dir="."):
 
     # run post-gen hook from repo_dir
     with work_in(repo_dir):
-        run_hook('post_gen_project', project_dir)
+        run_hook('post_gen_project', project_dir, context)
diff --git a/cookiecutter/hooks.py b/cookiecutter/hooks.py
--- a/cookiecutter/hooks.py
+++ b/cookiecutter/hooks.py
@@ -8,10 +8,16 @@
 Functions for discovering and executing various cookiecutter hooks.
 """
 
+import io
 import logging
 import os
 import subprocess
 import sys
+import tempfile
+
+from jinja2 import Template
+
+from cookiecutter import utils
 
 _HOOKS = [
     'pre_gen_project',
@@ -21,13 +27,13 @@
 
 
 def find_hooks():
-    '''
+    """
     Must be called with the project template as the current working directory.
     Returns a dict of all hook scripts provided.
     Dict's key will be the hook/script's name, without extension, while
     values will be the absolute path to the script.
     Missing scripts will not be included in the returned dict.
-    '''
+    """
     hooks_dir = 'hooks'
     r = {}
     logging.debug("hooks_dir is {0}".format(hooks_dir))
@@ -41,17 +47,21 @@ def find_hooks():
     return r
 
 
-def _run_hook(script_path, cwd='.'):
-    '''
-    Run a sigle external script located at `script_path` (path should be
-    absolute).
-    If `cwd` is provided, the script will be run from that directory.
-    '''
+def run_script(script_path, cwd='.'):
+    """
+    Executes a script from a working directory.
+
+    :param script_path: Absolute path to the script to run.
+    :param cwd: The directory to run the script from.
+    """
     run_thru_shell = sys.platform.startswith('win')
     if script_path.endswith('.py'):
         script_command = [sys.executable, script_path]
     else:
         script_command = [script_path]
+
+    utils.make_executable(script_path)
+
     proc = subprocess.Popen(
         script_command,
         shell=run_thru_shell,
@@ -60,13 +70,34 @@ def _run_hook(script_path, cwd='.'):
     proc.wait()
 
 
-def run_hook(hook_name, project_dir):
-    '''
-    Try and find a script mapped to `hook_name` in the current working
-    directory, and execute it from `project_dir`.
-    '''
+def run_script_with_context(script_path, cwd, context):
+    """
+    Executes a script after rendering with it Jinja.
+
+    :param script_path: Absolute path to the script to run.
+    :param cwd: The directory to run the script from.
+    :param context: Cookiecutter project template context.
+    """
+    _, extension = os.path.splitext(script_path)
+
+    contents = io.open(script_path, 'r', encoding='utf-8').read()
+
+    with tempfile.NamedTemporaryFile(delete=False, mode='w', suffix=extension) as temp:
+        temp.write(Template(contents).render(**context))
+
+    run_script(temp.name, cwd)
+
+
+def run_hook(hook_name, project_dir, context):
+    """
+    Try to find and execute a hook from the specified project directory.
+
+    :param hook_name: The hook to execute.
+    :param project_dir: The directory to execute the script from.
+    :param context: Cookiecutter project context.
+    """
     script = find_hooks().get(hook_name)
     if script is None:
         logging.debug("No hooks found")
         return
-    return _run_hook(script, project_dir)
+    return run_script_with_context(script, project_dir, context)
diff --git a/cookiecutter/utils.py b/cookiecutter/utils.py
--- a/cookiecutter/utils.py
+++ b/cookiecutter/utils.py
@@ -9,12 +9,14 @@
 """
 
 from __future__ import unicode_literals
+import contextlib
 import errno
 import logging
+import ntpath
 import os
 import stat
 import shutil
-import contextlib
+import tempfile
 
 
 def force_delete(func, path, exc_info):
@@ -67,3 +69,13 @@ def work_in(dirname=None):
         yield
     finally:
         os.chdir(curdir)
+
+
+def make_executable(script_path):
+    """
+    Makes `script_path` executable
+
+    :param script_path: The file to change
+    """
+    status = os.stat(script_path)
+    os.chmod(script_path, status.st_mode | stat.S_IEXEC)


Test patch:
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
--- a/tests/test_hooks.py
+++ b/tests/test_hooks.py
@@ -15,17 +15,18 @@
 
 from cookiecutter import hooks, utils
 
+
 def make_test_repo(name):
-    hooks = os.path.join(name, 'hooks')
+    hook_dir = os.path.join(name, 'hooks')
     template = os.path.join(name, 'input{{hooks}}')
     os.mkdir(name)
-    os.mkdir(hooks)
+    os.mkdir(hook_dir)
     os.mkdir(template)
 
     with open(os.path.join(template, 'README.rst'), 'w') as f:
         f.write("foo\n===\n\nbar\n")
 
-    with open(os.path.join(hooks, 'pre_gen_project.py'), 'w') as f:
+    with open(os.path.join(hook_dir, 'pre_gen_project.py'), 'w') as f:
         f.write("#!/usr/bin/env python\n")
         f.write("# -*- coding: utf-8 -*-\n")
         f.write("from __future__ import print_function\n")
@@ -36,14 +37,14 @@ def make_test_repo(name):
 
     if sys.platform.startswith('win'):
         post = 'post_gen_project.bat'
-        with open(os.path.join(hooks, post), 'w') as f:
+        with open(os.path.join(hook_dir, post), 'w') as f:
             f.write("@echo off\n")
             f.write("\n")
             f.write("echo post generation hook\n")
             f.write("echo. >shell_post.txt\n")
     else:
         post = 'post_gen_project.sh'
-        filename = os.path.join(hooks, post)
+        filename = os.path.join(hook_dir, post)
         with open(filename, 'w') as f:
             f.write("#!/bin/bash\n")
             f.write("\n")
@@ -65,8 +66,8 @@ def setUp(self):
     def tearDown(self):
         utils.rmtree(self.repo_path)
 
-    def test_find_hooks(self):
-        '''Getting the list of all defined hooks'''
+    def test_find_hook(self):
+        """Finds the specified hook"""
         with utils.work_in(self.repo_path):
             self.assertEqual({
                 'pre_gen_project': os.path.abspath('hooks/pre_gen_project.py'),
@@ -74,14 +75,14 @@ def test_find_hooks(self):
             }, hooks.find_hooks())
 
     def test_no_hooks(self):
-        '''find_hooks should return an empty dict if no hooks folder could be found. '''
+        """find_hooks should return None if the hook could not be found. """
         with utils.work_in('tests/fake-repo'):
             self.assertEqual({}, hooks.find_hooks())
 
 
 class TestExternalHooks(unittest.TestCase):
 
-    repo_path  = os.path.abspath('tests/test-hooks/')
+    repo_path = os.path.abspath('tests/test-hooks/')
     hooks_path = os.path.abspath('tests/test-hooks/hooks')
 
     def setUp(self):
@@ -100,29 +101,64 @@ def tearDown(self):
             os.remove('tests/test-hooks/input{{hooks}}/python_pre.txt')
         if os.path.exists('tests/test-hooks/input{{hooks}}/shell_post.txt'):
             os.remove('tests/test-hooks/input{{hooks}}/shell_post.txt')
+        if os.path.exists('tests/context_post.txt'):
+            os.remove('tests/context_post.txt')
 
-    def test_run_hook(self):
-        '''execute a hook script, independently of project generation'''
-        hooks._run_hook(os.path.join(self.hooks_path, self.post_hook))
+    def test_run_script(self):
+        """Execute a hook script, independently of project generation"""
+        hooks.run_script(os.path.join(self.hooks_path, self.post_hook))
         self.assertTrue(os.path.isfile('shell_post.txt'))
 
-    def test_run_hook_cwd(self):
-        '''Change directory before running hook'''
-        hooks._run_hook(os.path.join(self.hooks_path, self.post_hook), 
-                        'tests')
+    def test_run_script_cwd(self):
+        """Change directory before running hook"""
+        hooks.run_script(
+            os.path.join(self.hooks_path, self.post_hook),
+            'tests'
+        )
         self.assertTrue(os.path.isfile('tests/shell_post.txt'))
         self.assertFalse('tests' in os.getcwd())
-        
-    def test_public_run_hook(self):
-        '''Execute hook from specified template in specified output directory'''
+
+    def test_run_script_with_context(self):
+        """Execute a hook script, passing a context"""
+
+        hook_path = os.path.join(self.hooks_path, 'post_gen_project.sh')
+
+        if sys.platform.startswith('win'):
+            post = 'post_gen_project.bat'
+            with open(os.path.join(self.hooks_path, post), 'w') as f:
+                f.write("@echo off\n")
+                f.write("\n")
+                f.write("echo post generation hook\n")
+                f.write("echo. >{{cookiecutter.file}}\n")
+        else:
+            with open(hook_path, 'w') as fh:
+                fh.write("#!/bin/bash\n")
+                fh.write("\n")
+                fh.write("echo 'post generation hook';\n")
+                fh.write("touch 'shell_post.txt'\n")
+                fh.write("touch '{{cookiecutter.file}}'\n")
+                os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IXUSR)
+
+        hooks.run_script_with_context(
+            os.path.join(self.hooks_path, self.post_hook),
+            'tests',
+            {
+                'cookiecutter': {
+                    'file': 'context_post.txt'
+                }
+            })
+        self.assertTrue(os.path.isfile('tests/context_post.txt'))
+        self.assertFalse('tests' in os.getcwd())
+
+    def test_run_hook(self):
+        """Execute hook from specified template in specified output directory"""
         tests_dir = os.path.join(self.repo_path, 'input{{hooks}}')
         with utils.work_in(self.repo_path):
-            hooks.run_hook('pre_gen_project', tests_dir)
+            hooks.run_hook('pre_gen_project', tests_dir, {})
             self.assertTrue(os.path.isfile(os.path.join(tests_dir, 'python_pre.txt')))
 
-            hooks.run_hook('post_gen_project', tests_dir)
+            hooks.run_hook('post_gen_project', tests_dir, {})
             self.assertTrue(os.path.isfile(os.path.join(tests_dir, 'shell_post.txt')))
 
-
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -16,10 +16,12 @@
 
 from cookiecutter import utils
 
+
 def make_readonly(path):
     mode = os.stat(path).st_mode
     os.chmod(path, mode & ~stat.S_IWRITE)
 
+
 class TestUtils(unittest.TestCase):
 
     def test_rmtree(self):