Completed
Pull Request — master (#1008)
by
unknown
30s
created

generate_context()   B

Complexity

Conditions 5

Size

Total Lines 40

Duplication

Lines 0
Ratio 0 %

Importance

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