Completed
Pull Request — master (#837)
by
unknown
35s
created

apply_overwrites_to_context()   B

Complexity

Conditions 5

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
dl 0
loc 20
rs 8.5454
c 0
b 0
f 0
1
# -*- coding: utf-8 -*-
2
3
"""Functions for generating a project from a project template."""
4
from __future__ import unicode_literals
5
6
import fnmatch
7
import io
8
import json
9
import logging
10
import os
11
import shutil
12
from collections import OrderedDict
13
14
from binaryornot.check import is_binary
15
from jinja2 import FileSystemLoader
16
from cookiecutter.environment import StrictEnvironment
17
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
18
from past.builtins import basestring
19
20
from .environment import StrictEnvironment
21
from .exceptions import (
22
    NonTemplatedInputDirException,
23
    ContextDecodingException,
24
    FailedHookException,
25
    OutputDirExistsException,
26
    UndefinedVariableInTemplate
27
)
28
from .find import find_template
29
from .hooks import run_hook
30
from .utils import make_sure_path_exists, work_in, rmtree
31
32
logger = logging.getLogger(__name__)
33
34
35
def is_copy_only_path(path, context):
36
    """Check whether the given `path` should only be copied and not rendered.
37
38
    Returns True if `path` matches a pattern in the given `context` dict,
39
    otherwise False.
40
41
    :param path: A file-system path referring to a file or dir that
42
        should be rendered or just copied.
43
    :param context: cookiecutter context.
44
    """
45
    try:
46
        for dont_render in context['cookiecutter']['_copy_without_render']:
47
            if fnmatch.fnmatch(path, dont_render):
48
                return True
49
    except KeyError:
50
        return False
51
52
    return False
53
54
55
def apply_overwrites_to_context(context, overwrite_context):
56
    """Modify the given context in place based on the overwrite_context."""
57
    for variable, overwrite in overwrite_context.items():
58
        if variable not in context:
59
            # Do not include variables which are not used in the template
60
            continue
61
62
        context_value = context[variable]
63
64
        if isinstance(context_value, list):
65
            # We are dealing with a choice variable
66
            if overwrite in context_value:
67
                # This overwrite is actually valid for the given context
68
                # Let's set it as default (by definition first item in list)
69
                # see ``cookiecutter.prompt.prompt_choice_for_config``
70
                context_value.remove(overwrite)
71
                context_value.insert(0, overwrite)
72
        else:
73
            # Simply overwrite the value for this variable
74
            context[variable] = overwrite
75
76
77
def generate_context(context_file='cookiecutter.json', default_context=None,
78
                     extra_context=None):
79
    """Generate the context for a Cookiecutter project template.
80
81
    Loads the JSON file as a Python object, with key being the JSON filename.
82
83
    :param context_file: JSON file containing key/value pairs for populating
84
        the cookiecutter's variables.
85
    :param default_context: Dictionary containing config to take into account.
86
    :param extra_context: Dictionary containing configuration overrides
87
    """
88
    context = {}
89
90
    try:
91
        with open(context_file) as file_handle:
92
            obj = json.load(file_handle, object_pairs_hook=OrderedDict)
93
    except ValueError as e:
94
        # JSON decoding error.  Let's throw a new exception that is more
95
        # friendly for the developer or user.
96
        full_fpath = os.path.abspath(context_file)
97
        json_exc_message = str(e)
98
        our_exc_message = (
99
            'JSON decoding error while loading "{0}".  Decoding'
100
            ' error details: "{1}"'.format(full_fpath, json_exc_message))
101
        raise ContextDecodingException(our_exc_message)
102
103
    # Add the Python object to the context dictionary
104
    file_name = os.path.split(context_file)[1]
105
    file_stem = file_name.split('.')[0]
106
    context[file_stem] = obj
107
108
    # Overwrite context variable defaults with the default context from the
109
    # user's global config, if available
110
    if default_context:
111
        apply_overwrites_to_context(obj, default_context)
112
    if extra_context:
113
        apply_overwrites_to_context(obj, extra_context)
114
115
    logger.debug('Context generated is {}'.format(context))
116
    return context
117
118
119
def generate_file(project_dir, infile, context, env):
120
    """Render filename of infile as name of outfile, handle infile correctly.
121
122
    Dealing with infile appropriately:
123
124
        a. If infile is a binary file, copy it over without rendering.
125
        b. If infile is a text file, render its contents and write the
126
           rendered infile to outfile.
127
128
    Precondition:
129
130
        When calling `generate_file()`, the root template dir must be the
131
        current working directory. Using `utils.work_in()` is the recommended
132
        way to perform this directory change.
133
134
    :param project_dir: Absolute path to the resulting generated project.
135
    :param infile: Input file to generate the file from. Relative to the root
136
        template dir.
137
    :param context: Dict for populating the cookiecutter's variables.
138
    :param env: Jinja2 template execution environment.
139
    """
140
    logger.debug('Processing file {}'.format(infile))
141
142
    # Render the path to the output file (not including the root project dir)
143
    outfile_tmpl = env.from_string(infile)
144
145
    outfile = os.path.join(project_dir, outfile_tmpl.render(**context))
146
    file_name_is_empty = os.path.isdir(outfile)
147
    if file_name_is_empty:
148
        logger.debug('The resulting file name is empty: {0}'.format(outfile))
149
        return
150
151
    logger.debug('Created file at {0}'.format(outfile))
152
153
    # Just copy over binary files. Don't render.
154
    logger.debug("Check {} to see if it's a binary".format(infile))
155
    if is_binary(infile):
156
        logger.debug(
157
            'Copying binary {} to {} without rendering'
158
            ''.format(infile, outfile)
159
        )
160
        shutil.copyfile(infile, outfile)
161
    else:
162
        # Force fwd slashes on Windows for get_template
163
        # This is a by-design Jinja issue
164
        infile_fwd_slashes = infile.replace(os.path.sep, '/')
165
166
        # Render the file
167
        try:
168
            tmpl = env.get_template(infile_fwd_slashes)
169
        except TemplateSyntaxError as exception:
170
            # Disable translated so that printed exception contains verbose
171
            # information about syntax error location
172
            exception.translated = False
173
            raise
174
        rendered_file = tmpl.render(**context)
175
176
        logger.debug('Writing contents to file {}'.format(outfile))
177
178
        with io.open(outfile, 'w', encoding='utf-8') as fh:
179
            fh.write(rendered_file)
180
181
    # Apply file permissions to output file
182
    shutil.copymode(infile, outfile)
183
184
185
def render_and_create_dir(dirname, context, output_dir, environment,
186
                          overwrite_if_exists=False):
187
    """Render name of a directory, create the directory, return its path."""
188
    name_tmpl = environment.from_string(dirname)
189
    rendered_dirname = name_tmpl.render(**context)
190
191
    dir_to_create = os.path.normpath(
192
        os.path.join(output_dir, rendered_dirname)
193
    )
194
195
    logger.debug('Rendered dir {} must exist in output_dir {}'.format(
196
        dir_to_create,
197
        output_dir
198
    ))
199
200
    output_dir_exists = os.path.exists(dir_to_create)
201
202
    if output_dir_exists:
203
        if overwrite_if_exists:
204
            logger.debug(
205
                'Output directory {} already exists,'
206
                'overwriting it'.format(dir_to_create)
207
            )
208
        else:
209
            msg = 'Error: "{}" directory already exists'.format(dir_to_create)
210
            raise OutputDirExistsException(msg)
211
    else:
212
        make_sure_path_exists(dir_to_create)
213
214
    return dir_to_create, not output_dir_exists
215
216
217
def ensure_dir_is_templated(dirname):
218
    """Ensure that dirname is a templated directory name."""
219
    if '{{' in dirname and '}}' in dirname:
220
        return True
221
    else:
222
        raise NonTemplatedInputDirException
223
224
225
def _run_hook_from_repo_dir(repo_dir, hook_name, project_dir, context,
226
                            delete_project_on_failure):
227
    """Run hook from repo directory, clean project directory if hook fails.
228
229
    :param repo_dir: Project template input directory.
230
    :param hook_name: The hook to execute.
231
    :param project_dir: The directory to execute the script from.
232
    :param context: Cookiecutter project context.
233
    :param delete_project_on_failure: Delete the project directory on hook
234
        failure?
235
    """
236
    with work_in(repo_dir):
237
        try:
238
            run_hook(hook_name, project_dir, context)
239
        except FailedHookException:
240
            if delete_project_on_failure:
241
                rmtree(project_dir)
242
            logger.error(
243
                "Stopping generation because {} hook "
244
                "script didn't exit successfully".format(hook_name)
245
            )
246
            raise
247
248
249
def generate_files(repo_dir, context=None, output_dir='.',
250
                   overwrite_if_exists=False, extra_templates=None):
251
    """
252
    Renders the templates and saves them to files.
253
254
    :param repo_dir: Project template input directory.
255
    :param context: Dict for populating the template's variables.
256
    :param output_dir: Where to output the generated project dir into.
257
    :param overwrite_if_exists: Overwrite the contents of the output directory
258
        if it exists.
259
    :param extra_templates: path to the templates as string, or if multiple
260
        directories are needed as a list, to add to the jinja2 searchpath.
261
    """
262
    template_dir = find_template(repo_dir)
263
    logger.debug('Generating project from {}...'.format(template_dir))
264
    context = context or {}
265
266
    unrendered_dir = os.path.split(template_dir)[1]
267
    ensure_dir_is_templated(unrendered_dir)
268
    env = StrictEnvironment(
269
        context=context,
270
        keep_trailing_newline=True,
271
    )
272
    try:
273
        project_dir, output_directory_created = render_and_create_dir(
274
            unrendered_dir,
275
            context,
276
            output_dir,
277
            env,
278
            overwrite_if_exists
279
        )
280
    except UndefinedError as err:
281
        msg = "Unable to create project directory '{}'".format(unrendered_dir)
282
        raise UndefinedVariableInTemplate(msg, err, context)
283
284
    # We want the Jinja path and the OS paths to match. Consequently, we'll:
285
    #   + CD to the template folder
286
    #   + Set Jinja's path to '.'
287
    #
288
    #  In order to build our files to the correct folder(s), we'll use an
289
    # absolute path for the target folder (project_dir)
290
291
    project_dir = os.path.abspath(project_dir)
292
    logger.debug('Project directory is {}'.format(project_dir))
293
294
<<<<<<< 58da604fb63e3b8d4bba81d46d356c1e4840e132
295
    # if we created the output directory, then it's ok to remove it
296
    # if rendering fails
297
    delete_project_on_failure = output_directory_created
298
299
    _run_hook_from_repo_dir(
300
        repo_dir,
301
        'pre_gen_project',
302
        project_dir,
303
        context,
304
        delete_project_on_failure
305
    )
306
=======
307
    _run_hook_from_repo_dir(repo_dir, 'pre_gen_project', project_dir, context)
308
309
    # Configure jinja templates searchpath
310
    templates = ['.']
311
    if extra_templates is not None:
312
        if isinstance(extra_templates, basestring):
313
            extra_templates = [extra_templates]
314
        templates.extend(extra_templates)
315
>>>>>>> Enable passing extra template paths
316
317
    with work_in(template_dir):
318
        env.loader = FileSystemLoader(templates)
319
320
        for root, dirs, files in os.walk('.'):
321
            # We must separate the two types of dirs into different lists.
322
            # The reason is that we don't want ``os.walk`` to go through the
323
            # unrendered directories, since they will just be copied.
324
            copy_dirs = []
325
            render_dirs = []
326
327
            for d in dirs:
328
                d_ = os.path.normpath(os.path.join(root, d))
329
                # We check the full path, because that's how it can be
330
                # specified in the ``_copy_without_render`` setting, but
331
                # we store just the dir name
332
                if is_copy_only_path(d_, context):
333
                    copy_dirs.append(d)
334
                else:
335
                    render_dirs.append(d)
336
337
            for copy_dir in copy_dirs:
338
                indir = os.path.normpath(os.path.join(root, copy_dir))
339
                outdir = os.path.normpath(os.path.join(project_dir, indir))
340
                logger.debug(
341
                    'Copying dir {} to {} without rendering'
342
                    ''.format(indir, outdir)
343
                )
344
                shutil.copytree(indir, outdir)
345
346
            # We mutate ``dirs``, because we only want to go through these dirs
347
            # recursively
348
            dirs[:] = render_dirs
349
            for d in dirs:
350
                unrendered_dir = os.path.join(project_dir, root, d)
351
                try:
352
                    render_and_create_dir(
353
                        unrendered_dir,
354
                        context,
355
                        output_dir,
356
                        env,
357
                        overwrite_if_exists
358
                    )
359
                except UndefinedError as err:
360
                    if delete_project_on_failure:
361
                        rmtree(project_dir)
362
                    _dir = os.path.relpath(unrendered_dir, output_dir)
363
                    msg = "Unable to create directory '{}'".format(_dir)
364
                    raise UndefinedVariableInTemplate(msg, err, context)
365
366
            for f in files:
367
                infile = os.path.normpath(os.path.join(root, f))
368
                if is_copy_only_path(infile, context):
369
                    outfile_tmpl = env.from_string(infile)
370
                    outfile_rendered = outfile_tmpl.render(**context)
371
                    outfile = os.path.join(project_dir, outfile_rendered)
372
                    logger.debug(
373
                        'Copying file {} to {} without rendering'
374
                        ''.format(infile, outfile)
375
                    )
376
                    shutil.copyfile(infile, outfile)
377
                    shutil.copymode(infile, outfile)
378
                    continue
379
                try:
380
                    generate_file(project_dir, infile, context, env)
381
                except UndefinedError as err:
382
                    if delete_project_on_failure:
383
                        rmtree(project_dir)
384
                    msg = "Unable to create file '{}'".format(infile)
385
                    raise UndefinedVariableInTemplate(msg, err, context)
386
387
    _run_hook_from_repo_dir(
388
        repo_dir,
389
        'post_gen_project',
390
        project_dir,
391
        context,
392
        delete_project_on_failure
393
    )
394
395
    return project_dir
396