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

generate_file()   B

Complexity

Conditions 5

Size

Total Lines 64

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
dl 0
loc 64
rs 8.2837
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
                                if key in ['default']:
228
                                    raise ValueError("Cannot remove mandatory 'default' field")   # noqa
229
                                var_dict.pop(key, None)
230
                            else:
231
                                # normal field update
232
                                var_dict[key] = xtra_ctx_item[key]
233
234
                        # After all fields have been updated, there is some
235
                        # house-keeping to do. The default/choices
236
                        # house-keeping could effecively be no-ops if the
237
                        # user did the correct thing.
238
                        if ('default' in common_keys) & ('choices' in var_dict.keys()):       # noqa
239
                            # default updated, regardless if choices has been
240
                            # updated, re-order choices based on default
241
                            if var_dict['default'] in var_dict['choices']:
242
                                var_dict['choices'].remove(var_dict['default'])                # noqa
243
244
                            var_dict['choices'].insert(0, var_dict['default'])
245
246
                        if ('default' not in common_keys) & ('choices' in common_keys):         # noqa
247
                            # choices updated, so update default based on
248
                            # first location in choices
249
                            var_dict['default'] = var_dict['choices'][0]
250
251
                        if replace_name:
252
                            variable_names_to_resolve[xtra_ctx_name] = replace_name   # noqa
253
                            var_dict['name'] = replace_name
254
                    else:
255
                        msg = "No variable found in context whose name matches extra context name '{name}'"  # noqa
256
                        raise ValueError(msg.format(name=xtra_ctx_name))
257
                else:
258
                    msg = "Extra context dictionary item {item} is missing a 'name' key."                    # noqa
259
                    raise ValueError(msg.format(item=xtra_ctx_item))
260
            else:
261
                msg = "Extra context list item '{item}' is of type {t}, should be a dictionary."             # noqa
262
                raise ValueError(msg.format(item=str(xtra_ctx_item), t=type(xtra_ctx_item).__name__))        # noqa
263
264
        if variable_names_to_resolve:
265
            # At least one variable name has been over-written, if any
266
            # variables use the original name, they must get updated as well
267
            resolve_changed_variable_names(context, variable_names_to_resolve)
268
269
    else:
270
        msg = "Extra context must be a dictionary or a list of dictionaries!"
271
        raise ValueError(msg)
272
273
274
def generate_context(context_file='cookiecutter.json', default_context=None,
275
                     extra_context=None):
276
    """Generate the context for a Cookiecutter project template.
277
278
    Loads the JSON file as a Python object, with key being the JSON filename.
279
280
    :param context_file: JSON file containing key/value pairs for populating
281
        the cookiecutter's variables.
282
    :param default_context: Dictionary containing config to take into account.
283
    :param extra_context: Dictionary containing configuration overrides
284
    """
285
    context = {}
286
287
    try:
288
        with open(context_file) as file_handle:
289
            obj = json.load(file_handle, object_pairs_hook=OrderedDict)
290
    except ValueError as e:
291
        # JSON decoding error.  Let's throw a new exception that is more
292
        # friendly for the developer or user.
293
        full_fpath = os.path.abspath(context_file)
294
        json_exc_message = str(e)
295
        our_exc_message = (
296
            'JSON decoding error while loading "{0}".  Decoding'
297
            ' error details: "{1}"'.format(full_fpath, json_exc_message))
298
        raise ContextDecodingException(our_exc_message)
299
300
    # Add the Python object to the context dictionary
301
    file_name = os.path.split(context_file)[1]
302
    file_stem = file_name.split('.')[0]
303
    context[file_stem] = obj
304
305
    # Overwrite context variable defaults with the default context from the
306
    # user's global config, if available
307
    if context_is_version_2(context[file_stem]):
308
        logger.debug("Context is version 2")
309
310
        if default_context:
311
            apply_overwrites_to_context_v2(obj, default_context)
312
        if extra_context:
313
            apply_overwrites_to_context_v2(obj, extra_context)
314
    else:
315
        logger.debug("Context is version 1")
316
317
        if default_context:
318
            apply_overwrites_to_context(obj, default_context)
319
        if extra_context:
320
            apply_overwrites_to_context(obj, extra_context)
321
322
    logger.debug('Context generated is {}'.format(context))
323
    return context
324
325
326
def generate_file(project_dir, infile, context, env):
327
    """Render filename of infile as name of outfile, handle infile correctly.
328
329
    Dealing with infile appropriately:
330
331
        a. If infile is a binary file, copy it over without rendering.
332
        b. If infile is a text file, render its contents and write the
333
           rendered infile to outfile.
334
335
    Precondition:
336
337
        When calling `generate_file()`, the root template dir must be the
338
        current working directory. Using `utils.work_in()` is the recommended
339
        way to perform this directory change.
340
341
    :param project_dir: Absolute path to the resulting generated project.
342
    :param infile: Input file to generate the file from. Relative to the root
343
        template dir.
344
    :param context: Dict for populating the cookiecutter's variables.
345
    :param env: Jinja2 template execution environment.
346
    """
347
    logger.debug('Processing file {}'.format(infile))
348
349
    # Render the path to the output file (not including the root project dir)
350
    outfile_tmpl = env.from_string(infile)
351
352
    outfile = os.path.join(project_dir, outfile_tmpl.render(**context))
353
    file_name_is_empty = os.path.isdir(outfile)
354
    if file_name_is_empty:
355
        logger.debug('The resulting file name is empty: {0}'.format(outfile))
356
        return
357
358
    logger.debug('Created file at {0}'.format(outfile))
359
360
    # Just copy over binary files. Don't render.
361
    logger.debug("Check {} to see if it's a binary".format(infile))
362
    if is_binary(infile):
363
        logger.debug(
364
            'Copying binary {} to {} without rendering'
365
            ''.format(infile, outfile)
366
        )
367
        shutil.copyfile(infile, outfile)
368
    else:
369
        # Force fwd slashes on Windows for get_template
370
        # This is a by-design Jinja issue
371
        infile_fwd_slashes = infile.replace(os.path.sep, '/')
372
373
        # Render the file
374
        try:
375
            tmpl = env.get_template(infile_fwd_slashes)
376
        except TemplateSyntaxError as exception:
377
            # Disable translated so that printed exception contains verbose
378
            # information about syntax error location
379
            exception.translated = False
380
            raise
381
        rendered_file = tmpl.render(**context)
382
383
        logger.debug('Writing contents to file {}'.format(outfile))
384
385
        with io.open(outfile, 'w', encoding='utf-8') as fh:
386
            fh.write(rendered_file)
387
388
    # Apply file permissions to output file
389
    shutil.copymode(infile, outfile)
390
391
392
def render_and_create_dir(dirname, context, output_dir, environment,
393
                          overwrite_if_exists=False):
394
    """Render name of a directory, create the directory, return its path."""
395
    name_tmpl = environment.from_string(dirname)
396
    rendered_dirname = name_tmpl.render(**context)
397
398
    dir_to_create = os.path.normpath(
399
        os.path.join(output_dir, rendered_dirname)
400
    )
401
402
    logger.debug('Rendered dir {} must exist in output_dir {}'.format(
403
        dir_to_create,
404
        output_dir
405
    ))
406
407
    output_dir_exists = os.path.exists(dir_to_create)
408
409
    if output_dir_exists:
410
        if overwrite_if_exists:
411
            logger.debug(
412
                'Output directory {} already exists,'
413
                'overwriting it'.format(dir_to_create)
414
            )
415
        else:
416
            msg = 'Error: "{}" directory already exists'.format(dir_to_create)
417
            raise OutputDirExistsException(msg)
418
    else:
419
        make_sure_path_exists(dir_to_create)
420
421
    return dir_to_create, not output_dir_exists
422
423
424
def ensure_dir_is_templated(dirname):
425
    """Ensure that dirname is a templated directory name."""
426
    if '{{' in dirname and '}}' in dirname:
427
        return True
428
    else:
429
        raise NonTemplatedInputDirException
430
431
432
def _run_hook_from_repo_dir(repo_dir, hook_name, project_dir, context,
433
                            delete_project_on_failure):
434
    """Run hook from repo directory, clean project directory if hook fails.
435
436
    :param repo_dir: Project template input directory.
437
    :param hook_name: The hook to execute.
438
    :param project_dir: The directory to execute the script from.
439
    :param context: Cookiecutter project context.
440
    :param delete_project_on_failure: Delete the project directory on hook
441
        failure?
442
    """
443
    with work_in(repo_dir):
444
        try:
445
            run_hook(hook_name, project_dir, context)
446
        except FailedHookException:
447
            if delete_project_on_failure:
448
                rmtree(project_dir)
449
            logger.error(
450
                "Stopping generation because {} hook "
451
                "script didn't exit successfully".format(hook_name)
452
            )
453
            raise
454
455
456
def generate_files(repo_dir, context=None, output_dir='.',
457
                   overwrite_if_exists=False):
458
    """Render the templates and saves them to files.
459
460
    :param repo_dir: Project template input directory.
461
    :param context: Dict for populating the template's variables.
462
    :param output_dir: Where to output the generated project dir into.
463
    :param overwrite_if_exists: Overwrite the contents of the output directory
464
        if it exists.
465
    """
466
    template_dir = find_template(repo_dir)
467
    logger.debug('Generating project from {}...'.format(template_dir))
468
    context = context or {}
469
470
    unrendered_dir = os.path.split(template_dir)[1]
471
    ensure_dir_is_templated(unrendered_dir)
472
    env = StrictEnvironment(
473
        context=context,
474
        keep_trailing_newline=True,
475
    )
476
    try:
477
        project_dir, output_directory_created = render_and_create_dir(
478
            unrendered_dir,
479
            context,
480
            output_dir,
481
            env,
482
            overwrite_if_exists
483
        )
484
    except UndefinedError as err:
485
        msg = "Unable to create project directory '{}'".format(unrendered_dir)
486
        raise UndefinedVariableInTemplate(msg, err, context)
487
488
    # We want the Jinja path and the OS paths to match. Consequently, we'll:
489
    #   + CD to the template folder
490
    #   + Set Jinja's path to '.'
491
    #
492
    #  In order to build our files to the correct folder(s), we'll use an
493
    # absolute path for the target folder (project_dir)
494
495
    project_dir = os.path.abspath(project_dir)
496
    logger.debug('Project directory is {}'.format(project_dir))
497
498
    # if we created the output directory, then it's ok to remove it
499
    # if rendering fails
500
    delete_project_on_failure = output_directory_created
501
502
    _run_hook_from_repo_dir(
503
        repo_dir,
504
        'pre_gen_project',
505
        project_dir,
506
        context,
507
        delete_project_on_failure
508
    )
509
510
    with work_in(template_dir):
511
        env.loader = FileSystemLoader('.')
512
513
        for root, dirs, files in os.walk('.'):
514
            # We must separate the two types of dirs into different lists.
515
            # The reason is that we don't want ``os.walk`` to go through the
516
            # unrendered directories, since they will just be copied.
517
            copy_dirs = []
518
            render_dirs = []
519
520
            for d in dirs:
521
                d_ = os.path.normpath(os.path.join(root, d))
522
                # We check the full path, because that's how it can be
523
                # specified in the ``_copy_without_render`` setting, but
524
                # we store just the dir name
525
                if is_copy_only_path(d_, context):
526
                    copy_dirs.append(d)
527
                else:
528
                    render_dirs.append(d)
529
530
            for copy_dir in copy_dirs:
531
                indir = os.path.normpath(os.path.join(root, copy_dir))
532
                outdir = os.path.normpath(os.path.join(project_dir, indir))
533
                logger.debug(
534
                    'Copying dir {} to {} without rendering'
535
                    ''.format(indir, outdir)
536
                )
537
                shutil.copytree(indir, outdir)
538
539
            # We mutate ``dirs``, because we only want to go through these
540
            # dirs recursively
541
            dirs[:] = render_dirs
542
            for d in dirs:
543
                unrendered_dir = os.path.join(project_dir, root, d)
544
                try:
545
                    render_and_create_dir(
546
                        unrendered_dir,
547
                        context,
548
                        output_dir,
549
                        env,
550
                        overwrite_if_exists
551
                    )
552
                except UndefinedError as err:
553
                    if delete_project_on_failure:
554
                        rmtree(project_dir)
555
                    _dir = os.path.relpath(unrendered_dir, output_dir)
556
                    msg = "Unable to create directory '{}'".format(_dir)
557
                    raise UndefinedVariableInTemplate(msg, err, context)
558
559
            for f in files:
560
                infile = os.path.normpath(os.path.join(root, f))
561
                if is_copy_only_path(infile, context):
562
                    outfile_tmpl = env.from_string(infile)
563
                    outfile_rendered = outfile_tmpl.render(**context)
564
                    outfile = os.path.join(project_dir, outfile_rendered)
565
                    logger.debug(
566
                        'Copying file {} to {} without rendering'
567
                        ''.format(infile, outfile)
568
                    )
569
                    shutil.copyfile(infile, outfile)
570
                    shutil.copymode(infile, outfile)
571
                    continue
572
                try:
573
                    generate_file(project_dir, infile, context, env)
574
                except UndefinedError as err:
575
                    if delete_project_on_failure:
576
                        rmtree(project_dir)
577
                    msg = "Unable to create file '{}'".format(infile)
578
                    raise UndefinedVariableInTemplate(msg, err, context)
579
580
    _run_hook_from_repo_dir(
581
        repo_dir,
582
        'post_gen_project',
583
        project_dir,
584
        context,
585
        delete_project_on_failure
586
    )
587
588
    return project_dir
589