Completed
Push — master ( b6e32d...508ef4 )
by Andrey
15s queued 13s
created

cookiecutter.generate   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 408
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 53
eloc 222
dl 0
loc 408
rs 6.96
c 0
b 0
f 0

8 Functions

Rating   Name   Duplication   Size   Complexity  
A is_copy_only_path() 0 18 4
C generate_file() 0 76 9
B apply_overwrites_to_context() 0 29 7
A ensure_dir_is_templated() 0 6 3
A _run_hook_from_repo_dir() 0 24 4
F generate_files() 0 140 17
A render_and_create_dir() 0 27 3
B generate_context() 0 45 6

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