Completed
Pull Request — master (#1008)
by
unknown
30s
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 jinja2.exceptions import TemplateSyntaxError, UndefinedError
17
18
from .environment import StrictEnvironment
19
from .exceptions import (
20
    NonTemplatedInputDirException,
21
    ContextDecodingException,
22
    FailedHookException,
23
    OutputDirExistsException,
24
    UndefinedVariableInTemplate
25
)
26
from .find import find_template
27
from .hooks import run_hook
28
from .utils import make_sure_path_exists, work_in, rmtree
29
30
from .context import context_is_version_2
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 apply_default_overwrites_to_context_v2(context, overwrite_default_context):
78
    """
79
    Modify the given version 2 context in place based on the
80
    overwrite_default_context.
81
    """
82
83
    for variable, overwrite in overwrite_default_context.items():
84
        var_dict = next((d for d in context['variables'] if d['name'] == variable), None)   # noqa
85
        if var_dict:
86
            if 'choices' in var_dict.keys():
87
                context_value = var_dict['choices']
88
            else:
89
                context_value = var_dict['default']
90
91
            if isinstance(context_value, list):
92
                # We are dealing with a choice variable
93
                if overwrite in context_value:
94
                    # This overwrite is actually valid for the given context
95
                    # Let's set it as default (by definition 1st item in list)
96
                    # see ``cookiecutter.prompt.prompt_choice_for_config``
97
                    context_value.remove(overwrite)
98
                    context_value.insert(0, overwrite)
99
                    var_dict['default'] = overwrite
100
            else:
101
                # Simply overwrite the value for this variable
102
                var_dict['default'] = overwrite
103
104
105
def apply_overwrites_to_context_v2(context, extra_context):
106
    """
107
    Modify the given version 2 context in place based on extra_context.
108
109
    The extra_context parameter may be a dictionary or a list of dictionaries.
110
111
    If extra_context is a dictionary, the key is assumed to identify the
112
    variable's 'name' field and the value will be applied to the name field's
113
    default value -- this behavior is exactly like version 1 context overwrite
114
    behavior.
115
116
    When extra_context is a list of dictionaries, each dictionary MUST specify
117
    at the very least a 'name' key/value pair, or a ValueError is raised. The
118
    'name' key's value will be used to find the variable dictionary to
119
    overwrite by matching each dictionary's 'name' field.
120
121
    If extra_context is a list of dictionaries, apply the overwrite from each
122
    dictionary to it's matching variable's dictionary. This allows all fields
123
    of a variable to be updated. A match considers the variable's 'name' field
124
    only; any name fields in the extra_context list of dictionaries that do
125
    not match a variable 'name' field, are ignored. Any key/value pairs
126
    specified in an extra_content dictionary that are not already defined by
127
    the matching variable dictionary will raise a ValueError.
128
129
    Changing the 'name' Field
130
    -------------------------
131
    Changing the 'name' field requires a special syntax. Because the algorithm
132
    chosen to find a variable’s dictionary entry in the variables list of
133
    OrderDicts uses the variable’s ‘name’ field; it could not be used to
134
    simultaneously hold a new ‘name’ field value. Therefore the following
135
    extra context dictionary entry snytax was introduced to allow the ‘name’
136
    field of a variable to be changed:
137
138
        {
139
           'name': 'CURRENT_VARIABLE_NAME::NEW_VARIABLE_NAME',
140
        }
141
142
    So, for example, to change a variable’s ‘name’ field from
143
    ‘director_credit’ to ‘producer_credit’, would require:
144
145
        {
146
           'name': 'director_credit::producer_credit',
147
        }
148
149
    Removing a Field from a Variable
150
    --------------------------------
151
    It is possible that a previous extra context overwrite requires that a
152
    subsequent variable entry be removed.
153
154
    In order to accomplish this a remove field token is used in the extra
155
    context as follows:
156
157
        {
158
           'name': 'director_cut',
159
           'skip_if': '<<REMOVE::FIELD>>',
160
        }
161
162
    In the example above, the extra context overwrite results in the variable
163
    named ‘director_cut’ having it’s ‘skip_if’ field removed.
164
165
    Overwrite Considerations Regarding ‘default’ & ‘choices’ Fields
166
    ---------------------------------------------------------------
167
    When a variable is defined that has both the ‘default’ and the ‘choices’
168
    fields, these two fields influence each other. If one of these fields is
169
    updated, but not the other field, then the other field will be
170
    automatically updated by the overwrite logic.
171
172
    If both fields are updated, then the ‘default’ value will be moved to the
173
    first location of the ‘choices’ field if it exists elsewhere in the list;
174
    if the default value is not in the list, it will be added to the first
175
    location in the choices list.
176
177
    """
178
    if isinstance(extra_context, dict):
179
        apply_default_overwrites_to_context_v2(context, extra_context)
180
    elif isinstance(extra_context, list):
181
        for xtra_ctx_item in extra_context:
182
            if isinstance(xtra_ctx_item, dict):
183
                if 'name' in xtra_ctx_item.keys():
184
                    # xtra_ctx_item['name'] may have a replace value of the
185
                    # form:
186
                    #       'name_value::replace_name_value'
187
                    xtra_ctx_name = xtra_ctx_item['name'].split('::')[0]
188
                    try:
189
                        replace_name = xtra_ctx_item['name'].split('::')[1]
190
                    except IndexError:
191
                        replace_name = None
192
193
                    var_dict = next((d for d in context['variables'] if d['name'] == xtra_ctx_name), None)  # noqa
194
                    if var_dict:
195
                        # Since creation of new key/value pairs is NOT
196
                        # desired, we only use a key that is common to both
197
                        # the variables context and the extra context.
198
                        common_keys = [key for key in xtra_ctx_item.keys() if key in var_dict.keys()]   # noqa
199
                        for key in common_keys:
200
                            if xtra_ctx_item[key] == '<<REMOVE::FIELD>>':
201
                                var_dict.pop(key, None)
202
                            else:
203
                                # normal field update
204
                                var_dict[key] = xtra_ctx_item[key]
205
206
                        # After all fields have been updated, there is some
207
                        # house-keeping to do. The default/choices
208
                        # house-keeping could effecively be no-ops if the
209
                        # user did the correct thing.
210
                        if ('default' in common_keys) & ('choices' in var_dict.keys()):       # noqa
211
                            # default updated, regardless if choices has been
212
                            # updated, re-order choices based on default
213
                            if var_dict['default'] in var_dict['choices']:
214
                                var_dict['choices'].remove(var_dict['default'])                # noqa
215
216
                            var_dict['choices'].insert(0, var_dict['default'])
217
218
                        if ('default' not in common_keys) & ('choices' in common_keys):         # noqa
219
                            # choices updated, so update default based on
220
                            # first location in choices
221
                            var_dict['default'] = var_dict['choices'][0]
222
223
                        if replace_name:
224
                            var_dict['name'] = replace_name
225
                    else:
226
                        msg = "No variable found in context whose name matches extra context name '{name}'"  # noqa
227
                        raise ValueError(msg.format(name=xtra_ctx_name))
228
                else:
229
                    msg = "Extra context dictionary item {item} is missing a 'name' key."                    # noqa
230
                    raise ValueError(msg.format(item=xtra_ctx_item))
231
            else:
232
                msg = "Extra context list item '{item}' is of type {t}, should be a dictionary."             # noqa
233
                raise ValueError(msg.format(item=str(xtra_ctx_item), t=type(xtra_ctx_item).__name__))        # noqa
234
    else:
235
        msg = "Extra context must be a dictionary or a list of dictionaries!"
236
        raise ValueError(msg)
237
238
239
def generate_context(context_file='cookiecutter.json', default_context=None,
240
                     extra_context=None):
241
    """Generate the context for a Cookiecutter project template.
242
243
    Loads the JSON file as a Python object, with key being the JSON filename.
244
245
    :param context_file: JSON file containing key/value pairs for populating
246
        the cookiecutter's variables.
247
    :param default_context: Dictionary containing config to take into account.
248
    :param extra_context: Dictionary containing configuration overrides
249
    """
250
    context = {}
251
252
    try:
253
        with open(context_file) as file_handle:
254
            obj = json.load(file_handle, object_pairs_hook=OrderedDict)
255
    except ValueError as e:
256
        # JSON decoding error.  Let's throw a new exception that is more
257
        # friendly for the developer or user.
258
        full_fpath = os.path.abspath(context_file)
259
        json_exc_message = str(e)
260
        our_exc_message = (
261
            'JSON decoding error while loading "{0}".  Decoding'
262
            ' error details: "{1}"'.format(full_fpath, json_exc_message))
263
        raise ContextDecodingException(our_exc_message)
264
265
    # Add the Python object to the context dictionary
266
    file_name = os.path.split(context_file)[1]
267
    file_stem = file_name.split('.')[0]
268
    context[file_stem] = obj
269
270
    # Overwrite context variable defaults with the default context from the
271
    # user's global config, if available
272
    if context_is_version_2(context[file_stem]):
273
        logger.debug("Context is version 2")
274
275
        if default_context:
276
            apply_overwrites_to_context_v2(obj, default_context)
277
        if extra_context:
278
            apply_overwrites_to_context_v2(obj, extra_context)
279
    else:
280
        logger.debug("Context is version 1")
281
282
        if default_context:
283
            apply_overwrites_to_context(obj, default_context)
284
        if extra_context:
285
            apply_overwrites_to_context(obj, extra_context)
286
287
    logger.debug('Context generated is {}'.format(context))
288
    return context
289
290
291
def generate_file(project_dir, infile, context, env):
292
    """Render filename of infile as name of outfile, handle infile correctly.
293
294
    Dealing with infile appropriately:
295
296
        a. If infile is a binary file, copy it over without rendering.
297
        b. If infile is a text file, render its contents and write the
298
           rendered infile to outfile.
299
300
    Precondition:
301
302
        When calling `generate_file()`, the root template dir must be the
303
        current working directory. Using `utils.work_in()` is the recommended
304
        way to perform this directory change.
305
306
    :param project_dir: Absolute path to the resulting generated project.
307
    :param infile: Input file to generate the file from. Relative to the root
308
        template dir.
309
    :param context: Dict for populating the cookiecutter's variables.
310
    :param env: Jinja2 template execution environment.
311
    """
312
    logger.debug('Processing file {}'.format(infile))
313
314
    # Render the path to the output file (not including the root project dir)
315
    outfile_tmpl = env.from_string(infile)
316
317
    outfile = os.path.join(project_dir, outfile_tmpl.render(**context))
318
    file_name_is_empty = os.path.isdir(outfile)
319
    if file_name_is_empty:
320
        logger.debug('The resulting file name is empty: {0}'.format(outfile))
321
        return
322
323
    logger.debug('Created file at {0}'.format(outfile))
324
325
    # Just copy over binary files. Don't render.
326
    logger.debug("Check {} to see if it's a binary".format(infile))
327
    if is_binary(infile):
328
        logger.debug(
329
            'Copying binary {} to {} without rendering'
330
            ''.format(infile, outfile)
331
        )
332
        shutil.copyfile(infile, outfile)
333
    else:
334
        # Force fwd slashes on Windows for get_template
335
        # This is a by-design Jinja issue
336
        infile_fwd_slashes = infile.replace(os.path.sep, '/')
337
338
        # Render the file
339
        try:
340
            tmpl = env.get_template(infile_fwd_slashes)
341
        except TemplateSyntaxError as exception:
342
            # Disable translated so that printed exception contains verbose
343
            # information about syntax error location
344
            exception.translated = False
345
            raise
346
        rendered_file = tmpl.render(**context)
347
348
        logger.debug('Writing contents to file {}'.format(outfile))
349
350
        with io.open(outfile, 'w', encoding='utf-8') as fh:
351
            fh.write(rendered_file)
352
353
    # Apply file permissions to output file
354
    shutil.copymode(infile, outfile)
355
356
357
def render_and_create_dir(dirname, context, output_dir, environment,
358
                          overwrite_if_exists=False):
359
    """Render name of a directory, create the directory, return its path."""
360
    name_tmpl = environment.from_string(dirname)
361
    rendered_dirname = name_tmpl.render(**context)
362
363
    dir_to_create = os.path.normpath(
364
        os.path.join(output_dir, rendered_dirname)
365
    )
366
367
    logger.debug('Rendered dir {} must exist in output_dir {}'.format(
368
        dir_to_create,
369
        output_dir
370
    ))
371
372
    output_dir_exists = os.path.exists(dir_to_create)
373
374
    if output_dir_exists:
375
        if overwrite_if_exists:
376
            logger.debug(
377
                'Output directory {} already exists,'
378
                'overwriting it'.format(dir_to_create)
379
            )
380
        else:
381
            msg = 'Error: "{}" directory already exists'.format(dir_to_create)
382
            raise OutputDirExistsException(msg)
383
    else:
384
        make_sure_path_exists(dir_to_create)
385
386
    return dir_to_create, not output_dir_exists
387
388
389
def ensure_dir_is_templated(dirname):
390
    """Ensure that dirname is a templated directory name."""
391
    if '{{' in dirname and '}}' in dirname:
392
        return True
393
    else:
394
        raise NonTemplatedInputDirException
395
396
397
def _run_hook_from_repo_dir(repo_dir, hook_name, project_dir, context,
398
                            delete_project_on_failure):
399
    """Run hook from repo directory, clean project directory if hook fails.
400
401
    :param repo_dir: Project template input directory.
402
    :param hook_name: The hook to execute.
403
    :param project_dir: The directory to execute the script from.
404
    :param context: Cookiecutter project context.
405
    :param delete_project_on_failure: Delete the project directory on hook
406
        failure?
407
    """
408
    with work_in(repo_dir):
409
        try:
410
            run_hook(hook_name, project_dir, context)
411
        except FailedHookException:
412
            if delete_project_on_failure:
413
                rmtree(project_dir)
414
            logger.error(
415
                "Stopping generation because {} hook "
416
                "script didn't exit successfully".format(hook_name)
417
            )
418
            raise
419
420
421
def generate_files(repo_dir, context=None, output_dir='.',
422
                   overwrite_if_exists=False):
423
    """Render the templates and saves them to files.
424
425
    :param repo_dir: Project template input directory.
426
    :param context: Dict for populating the template's variables.
427
    :param output_dir: Where to output the generated project dir into.
428
    :param overwrite_if_exists: Overwrite the contents of the output directory
429
        if it exists.
430
    """
431
    template_dir = find_template(repo_dir)
432
    logger.debug('Generating project from {}...'.format(template_dir))
433
    context = context or {}
434
435
    unrendered_dir = os.path.split(template_dir)[1]
436
    ensure_dir_is_templated(unrendered_dir)
437
    env = StrictEnvironment(
438
        context=context,
439
        keep_trailing_newline=True,
440
    )
441
    try:
442
        project_dir, output_directory_created = render_and_create_dir(
443
            unrendered_dir,
444
            context,
445
            output_dir,
446
            env,
447
            overwrite_if_exists
448
        )
449
    except UndefinedError as err:
450
        msg = "Unable to create project directory '{}'".format(unrendered_dir)
451
        raise UndefinedVariableInTemplate(msg, err, context)
452
453
    # We want the Jinja path and the OS paths to match. Consequently, we'll:
454
    #   + CD to the template folder
455
    #   + Set Jinja's path to '.'
456
    #
457
    #  In order to build our files to the correct folder(s), we'll use an
458
    # absolute path for the target folder (project_dir)
459
460
    project_dir = os.path.abspath(project_dir)
461
    logger.debug('Project directory is {}'.format(project_dir))
462
463
    # if we created the output directory, then it's ok to remove it
464
    # if rendering fails
465
    delete_project_on_failure = output_directory_created
466
467
    _run_hook_from_repo_dir(
468
        repo_dir,
469
        'pre_gen_project',
470
        project_dir,
471
        context,
472
        delete_project_on_failure
473
    )
474
475
    with work_in(template_dir):
476
        env.loader = FileSystemLoader('.')
477
478
        for root, dirs, files in os.walk('.'):
479
            # We must separate the two types of dirs into different lists.
480
            # The reason is that we don't want ``os.walk`` to go through the
481
            # unrendered directories, since they will just be copied.
482
            copy_dirs = []
483
            render_dirs = []
484
485
            for d in dirs:
486
                d_ = os.path.normpath(os.path.join(root, d))
487
                # We check the full path, because that's how it can be
488
                # specified in the ``_copy_without_render`` setting, but
489
                # we store just the dir name
490
                if is_copy_only_path(d_, context):
491
                    copy_dirs.append(d)
492
                else:
493
                    render_dirs.append(d)
494
495
            for copy_dir in copy_dirs:
496
                indir = os.path.normpath(os.path.join(root, copy_dir))
497
                outdir = os.path.normpath(os.path.join(project_dir, indir))
498
                logger.debug(
499
                    'Copying dir {} to {} without rendering'
500
                    ''.format(indir, outdir)
501
                )
502
                shutil.copytree(indir, outdir)
503
504
            # We mutate ``dirs``, because we only want to go through these
505
            # dirs recursively
506
            dirs[:] = render_dirs
507
            for d in dirs:
508
                unrendered_dir = os.path.join(project_dir, root, d)
509
                try:
510
                    render_and_create_dir(
511
                        unrendered_dir,
512
                        context,
513
                        output_dir,
514
                        env,
515
                        overwrite_if_exists
516
                    )
517
                except UndefinedError as err:
518
                    if delete_project_on_failure:
519
                        rmtree(project_dir)
520
                    _dir = os.path.relpath(unrendered_dir, output_dir)
521
                    msg = "Unable to create directory '{}'".format(_dir)
522
                    raise UndefinedVariableInTemplate(msg, err, context)
523
524
            for f in files:
525
                infile = os.path.normpath(os.path.join(root, f))
526
                if is_copy_only_path(infile, context):
527
                    outfile_tmpl = env.from_string(infile)
528
                    outfile_rendered = outfile_tmpl.render(**context)
529
                    outfile = os.path.join(project_dir, outfile_rendered)
530
                    logger.debug(
531
                        'Copying file {} to {} without rendering'
532
                        ''.format(infile, outfile)
533
                    )
534
                    shutil.copyfile(infile, outfile)
535
                    shutil.copymode(infile, outfile)
536
                    continue
537
                try:
538
                    generate_file(project_dir, infile, context, env)
539
                except UndefinedError as err:
540
                    if delete_project_on_failure:
541
                        rmtree(project_dir)
542
                    msg = "Unable to create file '{}'".format(infile)
543
                    raise UndefinedVariableInTemplate(msg, err, context)
544
545
    _run_hook_from_repo_dir(
546
        repo_dir,
547
        'post_gen_project',
548
        project_dir,
549
        context,
550
        delete_project_on_failure
551
    )
552
553
    return project_dir
554