Completed
Pull Request — master (#934)
by
unknown
54s
created

_patched_link()   A

Complexity

Conditions 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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