cookiecutter.generate   B
last analyzed

Complexity

Total Complexity 45

Size/Duplication

Total Lines 386
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 45
eloc 218
dl 0
loc 386
rs 8.8
c 0
b 0
f 0

8 Functions

Rating   Name   Duplication   Size   Complexity  
A apply_overwrites_to_context() 0 20 5
A is_copy_only_path() 0 18 4
B generate_context() 0 40 5
A ensure_dir_is_templated() 0 6 3
A _run_hook_from_repo_dir() 0 22 4
A render_and_create_dir() 0 30 3
B generate_file() 0 69 7
F generate_files() 0 134 14

How to fix   Complexity   

Complexity

Complex classes like cookiecutter.generate often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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