Completed
Push — update-cookiecutter-command ( e74ec6 )
by Michael
01:05
created

cookiecutter.generate_file()   F

Complexity

Conditions 9

Size

Total Lines 94

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 9
dl 0
loc 94
rs 3.291

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