Completed
Pull Request — master (#934)
by
unknown
01:08
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
from collections import OrderedDict
6
import fnmatch
7
import io
8
import json
9
import logging
10
import os
11
import shutil
12
import sys
13
14
from jinja2 import FileSystemLoader
15
from cookiecutter.environment import StrictEnvironment
16
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
17
from binaryornot.check import is_binary
18
19
from .exceptions import (
20
    NonTemplatedInputDirException,
21
    ContextDecodingException,
22
    FailedHookException,
23
    OutputDirExistsException,
24
    UndefinedVariableInTemplate,
25
    NoSymlinksOnWindowsPythonBefore32
26
)
27
from .find import find_template
28
from .utils import make_sure_path_exists, work_in, rmtree
29
from .hooks import run_hook
30
31
logger = logging.getLogger(__name__)
32
33
34
WIN_BEFORE_PY32 = sys.platform.startswith('win') and sys.version_info < (3, 2)
35
36
if WIN_BEFORE_PY32:
37
    from jaraco.windows.filesystem import islink
38
39
    def _patched_link(*args, **kwargs):
40
        if islink(*args, **kwargs):
41
            raise NoSymlinksOnWindowsPythonBefore32(
42
                "Symlinks not supported on Windows before Python 3.2."
43
            )
44
        return False
45
46
    os.path.islink = _patched_link
47
48
49
def is_copy_only_path(path, context):
50
    """Check whether the given `path` should only be copied as opposed to being
51
    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 overwrite_if_exists:
218
        if output_dir_exists:
219
            logger.debug(
220
                'Output directory {} already exists,'
221
                'overwriting it'.format(dir_to_create)
222
            )
223
    else:
224
        if output_dir_exists:
225
            msg = 'Error: "{}" directory already exists'.format(dir_to_create)
226
            raise OutputDirExistsException(msg)
227
228
    if symlink is not None:
229
        link_tmpl = environment.from_string(symlink)
230
        rendered_link = link_tmpl.render(**context)
231
232
        logger.debug('Creating symlink from {} to {}'.format(
233
            dir_to_create,
234
            rendered_link
235
        ))
236
237
        os.symlink(rendered_link, dir_to_create)
238
    else:
239
        make_sure_path_exists(dir_to_create)
240
241
    return dir_to_create
242
243
244
def ensure_dir_is_templated(dirname):
245
    """Ensure that dirname is a templated directory name."""
246
    if '{{' in dirname and '}}' in dirname:
247
        return True
248
    else:
249
        raise NonTemplatedInputDirException
250
251
252
def _run_hook_from_repo_dir(repo_dir, hook_name, project_dir, context):
253
    """Run hook from repo directory, clean project directory if hook fails.
254
255
    :param repo_dir: Project template input directory.
256
    :param hook_name: The hook to execute.
257
    :param project_dir: The directory to execute the script from.
258
    :param context: Cookiecutter project context.
259
    """
260
    with work_in(repo_dir):
261
        try:
262
            run_hook(hook_name, project_dir, context)
263
        except FailedHookException:
264
            rmtree(project_dir)
265
            logger.error(
266
                "Stopping generation because {} hook "
267
                "script didn't exit successfully".format(hook_name)
268
            )
269
            raise
270
271
272
def generate_files(repo_dir, context=None, output_dir='.',
273
                   overwrite_if_exists=False):
274
    """Render the templates and saves them to files.
275
276
    :param repo_dir: Project template input directory.
277
    :param context: Dict for populating the template's variables.
278
    :param output_dir: Where to output the generated project dir into.
279
    :param overwrite_if_exists: Overwrite the contents of the output directory
280
        if it exists.
281
    """
282
    template_dir = find_template(repo_dir)
283
    logger.debug('Generating project from {}...'.format(template_dir))
284
    context = context or {}
285
286
    unrendered_dir = os.path.split(template_dir)[1]
287
    ensure_dir_is_templated(unrendered_dir)
288
    env = StrictEnvironment(
289
        context=context,
290
        keep_trailing_newline=True,
291
    )
292
    try:
293
        project_dir = render_and_create_dir(
294
            unrendered_dir,
295
            context,
296
            output_dir,
297
            env,
298
            overwrite_if_exists
299
        )
300
    except UndefinedError as err:
301
        msg = "Unable to create project directory '{}'".format(unrendered_dir)
302
        raise UndefinedVariableInTemplate(msg, err, context)
303
304
    # We want the Jinja path and the OS paths to match. Consequently, we'll:
305
    #   + CD to the template folder
306
    #   + Set Jinja's path to '.'
307
    #
308
    #  In order to build our files to the correct folder(s), we'll use an
309
    # absolute path for the target folder (project_dir)
310
311
    project_dir = os.path.abspath(project_dir)
312
    logger.debug('Project directory is {}'.format(project_dir))
313
314
    _run_hook_from_repo_dir(repo_dir, 'pre_gen_project', project_dir, context)
315
316
    with work_in(template_dir):
317
        env.loader = FileSystemLoader('.')
318
319
        for root, dirs, files in os.walk('.'):
320
            # We must separate the two types of dirs into different lists.
321
            # The reason is that we don't want ``os.walk`` to go through the
322
            # unrendered directories, since they will just be copied.
323
            copy_dirs = []
324
            render_dirs = []
325
            symlinks = dict()
326
327
            for d in dirs:
328
                d_ = os.path.normpath(os.path.join(root, d))
329
330
                if os.path.islink(d_):
331
                    logger.debug('Processing symlink at {}...'.format(d))
332
                    symlinks[d] = os.readlink(d_)
333
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 is_copy_only_path(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
                logger.debug(
346
                    'Copying dir {} to {} without rendering'
347
                    ''.format(indir, outdir)
348
                )
349
                shutil.copytree(indir, outdir, symlinks=True)
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
                        symlink=symlinks.get(d, None)
364
                    )
365
                except UndefinedError as err:
366
                    rmtree(project_dir)
367
                    _dir = os.path.relpath(unrendered_dir, output_dir)
368
                    msg = "Unable to create directory '{}'".format(_dir)
369
                    raise UndefinedVariableInTemplate(msg, err, context)
370
371
            for f in files:
372
                infile = os.path.normpath(os.path.join(root, f))
373
                if is_copy_only_path(infile, context):
374
                    outfile_tmpl = env.from_string(infile)
375
                    outfile_rendered = outfile_tmpl.render(**context)
376
                    outfile = os.path.join(project_dir, outfile_rendered)
377
                    logger.debug(
378
                        'Copying file {} to {} without rendering'
379
                        ''.format(infile, outfile)
380
                    )
381
                    shutil.copyfile(infile, outfile)
382
                    shutil.copymode(infile, outfile)
383
                    continue
384
                try:
385
                    generate_file(project_dir, infile, context, env)
386
                except UndefinedError as err:
387
                    rmtree(project_dir)
388
                    msg = "Unable to create file '{}'".format(infile)
389
                    raise UndefinedVariableInTemplate(msg, err, context)
390
391
    _run_hook_from_repo_dir(repo_dir, 'post_gen_project', project_dir, context)
392
393
    return project_dir
394