1# -*- coding: utf-8 -*-
2
3"""Functions for discovering and executing various cookiecutter hooks."""
4
5import errno
6import io
7import logging
8import os
9import subprocess
10import sys
11import tempfile
12
13from cookiecutter import utils
14from cookiecutter.environment import StrictEnvironment
15from cookiecutter.exceptions import FailedHookException
16
17logger = logging.getLogger(__name__)
18
19_HOOKS = [
20    'pre_gen_project',
21    'post_gen_project',
22]
23EXIT_SUCCESS = 0
24
25
26def valid_hook(hook_file, hook_name):
27    """Determine if a hook file is valid.
28
29    :param hook_file: The hook file to consider for validity
30    :param hook_name: The hook to find
31    :return: The hook file validity
32    """
33    filename = os.path.basename(hook_file)
34    basename = os.path.splitext(filename)[0]
35
36    matching_hook = basename == hook_name
37    supported_hook = basename in _HOOKS
38    backup_file = filename.endswith('~')
39
40    return matching_hook and supported_hook and not backup_file
41
42
43def find_hook(hook_name, hooks_dir='hooks'):
44    """Return a dict of all hook scripts provided.
45
46    Must be called with the project template as the current working directory.
47    Dict's key will be the hook/script's name, without extension, while values
48    will be the absolute path to the script. Missing scripts will not be
49    included in the returned dict.
50
51    :param hook_name: The hook to find
52    :param hooks_dir: The hook directory in the template
53    :return: The absolute path to the hook script or None
54    """
55    logger.debug('hooks_dir is %s', os.path.abspath(hooks_dir))
56
57    if not os.path.isdir(hooks_dir):
58        logger.debug('No hooks/dir in template_dir')
59        return None
60
61    for hook_file in os.listdir(hooks_dir):
62        if valid_hook(hook_file, hook_name):
63            return os.path.abspath(os.path.join(hooks_dir, hook_file))
64
65    return None
66
67
68def run_script(script_path, cwd='.'):
69    """Execute a script from a working directory.
70
71    :param script_path: Absolute path to the script to run.
72    :param cwd: The directory to run the script from.
73    """
74    run_thru_shell = sys.platform.startswith('win')
75    if script_path.endswith('.py'):
76        script_command = [sys.executable, script_path]
77    else:
78        script_command = [script_path]
79
80    utils.make_executable(script_path)
81
82    try:
83        proc = subprocess.Popen(script_command, shell=run_thru_shell, cwd=cwd)
84        exit_status = proc.wait()
85        if exit_status != EXIT_SUCCESS:
86            raise FailedHookException(
87                'Hook script failed (exit status: {})'.format(exit_status)
88            )
89    except OSError as os_error:
90        if os_error.errno == errno.ENOEXEC:
91            raise FailedHookException(
92                'Hook script failed, might be an ' 'empty file or missing a shebang'
93            )
94        raise FailedHookException('Hook script failed (error: {})'.format(os_error))
95
96
97def run_script_with_context(script_path, cwd, context):
98    """Execute a script after rendering it with Jinja.
99
100    :param script_path: Absolute path to the script to run.
101    :param cwd: The directory to run the script from.
102    :param context: Cookiecutter project template context.
103    """
104    _, extension = os.path.splitext(script_path)
105
106    with io.open(script_path, 'r', encoding='utf-8') as file:
107        contents = file.read()
108
109    with tempfile.NamedTemporaryFile(delete=False, mode='wb', suffix=extension) as temp:
110        env = StrictEnvironment(context=context, keep_trailing_newline=True)
111        template = env.from_string(contents)
112        output = template.render(**context)
113        temp.write(output.encode('utf-8'))
114
115    run_script(temp.name, cwd)
116
117
118def run_hook(hook_name, project_dir, context):
119    """
120    Try to find and execute a hook from the specified project directory.
121
122    :param hook_name: The hook to execute.
123    :param project_dir: The directory to execute the script from.
124    :param context: Cookiecutter project context.
125    """
126    script = find_hook(hook_name)
127    if script is None:
128        logger.debug('No %s hook found', hook_name)
129        return
130    logger.debug('Running hook %s', hook_name)
131    run_script_with_context(script, project_dir, context)
132