cookiecutter.generate.render_and_create_dir()   A
last analyzed

Complexity

Conditions 3

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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