cookiecutter.generate.generate_context()   B
last analyzed

Complexity

Conditions 6

Size

Total Lines 45
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 24
dl 0
loc 45
rs 8.3706
c 0
b 0
f 0
cc 6
nop 3
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
from pathlib import Path
10
from binaryornot.check import is_binary
11
from jinja2 import FileSystemLoader, Environment
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
                    f"{overwrite} provided for choice variable {variable}, "
69
                    f"but the choices are {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
            f"JSON decoding error while loading '{full_fpath}'. "
104
            f"Decoding error details: '{json_exc_message}'"
105
        )
106
        raise ContextDecodingException(our_exc_message) from e
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 error:
119
            warnings.warn(f"Invalid default received: {error}")
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: str,
207
    context: dict,
208
    output_dir: "os.PathLike[str]",
209
    environment: Environment,
210
    overwrite_if_exists: bool = False,
211
):
212
    """Render name of a directory, create the directory, return its path."""
213
    name_tmpl = environment.from_string(dirname)
214
    rendered_dirname = name_tmpl.render(**context)
215
216
    dir_to_create = Path(output_dir, rendered_dirname)
217
218
    logger.debug(
219
        'Rendered dir %s must exist in output_dir %s', dir_to_create, output_dir
220
    )
221
222
    output_dir_exists = dir_to_create.exists()
223
224
    if output_dir_exists:
225
        if overwrite_if_exists:
226
            logger.debug(
227
                'Output directory %s already exists, overwriting it', dir_to_create
228
            )
229
        else:
230
            msg = f'Error: "{dir_to_create}" directory already exists'
231
            raise OutputDirExistsException(msg)
232
    else:
233
        make_sure_path_exists(dir_to_create)
234
235
    return dir_to_create, not output_dir_exists
236
237
238
def ensure_dir_is_templated(dirname):
239
    """Ensure that dirname is a templated directory name."""
240
    if '{{' in dirname and '}}' in dirname:
241
        return True
242
    else:
243
        raise NonTemplatedInputDirException
244
245
246
def _run_hook_from_repo_dir(
247
    repo_dir, hook_name, project_dir, context, delete_project_on_failure
248
):
249
    """Run hook from repo directory, clean project directory if hook fails.
250
251
    :param repo_dir: Project template input directory.
252
    :param hook_name: The hook to execute.
253
    :param project_dir: The directory to execute the script from.
254
    :param context: Cookiecutter project context.
255
    :param delete_project_on_failure: Delete the project directory on hook
256
        failure?
257
    """
258
    with work_in(repo_dir):
259
        try:
260
            run_hook(hook_name, project_dir, context)
261
        except FailedHookException:
262
            if delete_project_on_failure:
263
                rmtree(project_dir)
264
            logger.error(
265
                "Stopping generation because %s hook "
266
                "script didn't exit successfully",
267
                hook_name,
268
            )
269
            raise
270
271
272
def generate_files(
273
    repo_dir,
274
    context=None,
275
    output_dir='.',
276
    overwrite_if_exists=False,
277
    skip_if_file_exists=False,
278
    accept_hooks=True,
279
    keep_project_on_failure=False,
280
):
281
    """Render the templates and saves them to files.
282
283
    :param repo_dir: Project template input directory.
284
    :param context: Dict for populating the template's variables.
285
    :param output_dir: Where to output the generated project dir into.
286
    :param overwrite_if_exists: Overwrite the contents of the output directory
287
        if it exists.
288
    :param skip_if_file_exists: Skip the files in the corresponding directories
289
        if they already exist
290
    :param accept_hooks: Accept pre and post hooks if set to `True`.
291
    :param keep_project_on_failure: If `True` keep generated project directory even when
292
        generation fails
293
    """
294
    template_dir = find_template(repo_dir)
295
    logger.debug('Generating project from %s...', template_dir)
296
    context = context or OrderedDict([])
297
298
    envvars = context.get('cookiecutter', {}).get('_jinja2_env_vars', {})
299
300
    unrendered_dir = os.path.split(template_dir)[1]
301
    ensure_dir_is_templated(unrendered_dir)
302
    env = StrictEnvironment(context=context, keep_trailing_newline=True, **envvars)
303
    try:
304
        project_dir, output_directory_created = render_and_create_dir(
305
            unrendered_dir, context, output_dir, env, overwrite_if_exists
306
        )
307
    except UndefinedError as err:
308
        msg = f"Unable to create project directory '{unrendered_dir}'"
309
        raise UndefinedVariableInTemplate(msg, err, context) from err
310
311
    # We want the Jinja path and the OS paths to match. Consequently, we'll:
312
    #   + CD to the template folder
313
    #   + Set Jinja's path to '.'
314
    #
315
    #  In order to build our files to the correct folder(s), we'll use an
316
    # absolute path for the target folder (project_dir)
317
318
    project_dir = os.path.abspath(project_dir)
319
    logger.debug('Project directory is %s', project_dir)
320
321
    # if we created the output directory, then it's ok to remove it
322
    # if rendering fails
323
    delete_project_on_failure = output_directory_created and not keep_project_on_failure
324
325
    if accept_hooks:
326
        _run_hook_from_repo_dir(
327
            repo_dir, 'pre_gen_project', project_dir, context, delete_project_on_failure
328
        )
329
330
    with work_in(template_dir):
331
        env.loader = FileSystemLoader(['.', '../templates'])
332
333
        for root, dirs, files in os.walk('.'):
334
            # We must separate the two types of dirs into different lists.
335
            # The reason is that we don't want ``os.walk`` to go through the
336
            # unrendered directories, since they will just be copied.
337
            copy_dirs = []
338
            render_dirs = []
339
340
            for d in dirs:
341
                d_ = os.path.normpath(os.path.join(root, d))
342
                # We check the full path, because that's how it can be
343
                # specified in the ``_copy_without_render`` setting, but
344
                # we store just the dir name
345
                if is_copy_only_path(d_, context):
346
                    logger.debug('Found copy only path %s', d)
347
                    copy_dirs.append(d)
348
                else:
349
                    render_dirs.append(d)
350
351
            for copy_dir in copy_dirs:
352
                indir = os.path.normpath(os.path.join(root, copy_dir))
353
                outdir = os.path.normpath(os.path.join(project_dir, indir))
354
                outdir = env.from_string(outdir).render(**context)
355
                logger.debug('Copying dir %s to %s without rendering', indir, outdir)
356
357
                # The outdir is not the root dir, it is the dir which marked as copy
358
                # only in the config file. If the program hits this line, which means
359
                # the overwrite_if_exists = True, and root dir exists
360
                if os.path.isdir(outdir):
361
                    shutil.rmtree(outdir)
362
                shutil.copytree(indir, outdir)
363
364
            # We mutate ``dirs``, because we only want to go through these dirs
365
            # recursively
366
            dirs[:] = render_dirs
367
            for d in dirs:
368
                unrendered_dir = os.path.join(project_dir, root, d)
369
                try:
370
                    render_and_create_dir(
371
                        unrendered_dir, context, output_dir, env, overwrite_if_exists
372
                    )
373
                except UndefinedError as err:
374
                    if delete_project_on_failure:
375
                        rmtree(project_dir)
376
                    _dir = os.path.relpath(unrendered_dir, output_dir)
377
                    msg = f"Unable to create directory '{_dir}'"
378
                    raise UndefinedVariableInTemplate(msg, err, context) from err
379
380
            for f in files:
381
                infile = os.path.normpath(os.path.join(root, f))
382
                if is_copy_only_path(infile, context):
383
                    outfile_tmpl = env.from_string(infile)
384
                    outfile_rendered = outfile_tmpl.render(**context)
385
                    outfile = os.path.join(project_dir, outfile_rendered)
386
                    logger.debug(
387
                        'Copying file %s to %s without rendering', infile, outfile
388
                    )
389
                    shutil.copyfile(infile, outfile)
390
                    shutil.copymode(infile, outfile)
391
                    continue
392
                try:
393
                    generate_file(
394
                        project_dir, infile, context, env, skip_if_file_exists
395
                    )
396
                except UndefinedError as err:
397
                    if delete_project_on_failure:
398
                        rmtree(project_dir)
399
                    msg = f"Unable to create file '{infile}'"
400
                    raise UndefinedVariableInTemplate(msg, err, context) from err
401
402
    if accept_hooks:
403
        _run_hook_from_repo_dir(
404
            repo_dir,
405
            'post_gen_project',
406
            project_dir,
407
            context,
408
            delete_project_on_failure,
409
        )
410
411
    return project_dir
412