Completed
Pull Request — master (#1008)
by
unknown
30s
created

CookiecutterTemplate   A

Complexity

Total Complexity 5

Size/Duplication

Total Lines 50
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 50
rs 10
wmc 5

3 Methods

Rating   Name   Duplication   Size   Complexity  
A __iter__() 0 3 2
B __init__() 0 35 2
A __repr__() 0 4 1
1
# -*- coding: utf-8 -*-
2
# flake8: noqa
3
"""
4
cookiecutter.context
5
--------------------
6
7
Process the version 2 cookiecutter context (previsously loaded via
8
cookiecutter.json) and handle any user input that might be associated with
9
initializing the settings defined in the 'variables' OrderedDict part of the
10
context.
11
12
This module produces a dictionary used later by the jinja2 template engine to
13
generate files.
14
15
Based on the source code written by @hackebrot see:
16
https://github.com/audreyr/cookiecutter/pull/848
17
https://github.com/hackebrot/cookiecutter/tree/new-context-format
18
19
"""
20
21
import logging
22
import collections
23
import json
24
import re
25
26
import click
27
from jinja2 import Environment
28
29
logger = logging.getLogger(__name__)
30
31
DEFAULT_PROMPT = 'Please enter a value for "{variable.name}"'
32
33
VALID_TYPES = [
34
    'boolean',
35
    'yes_no',
36
    'int',
37
    'float',
38
    'uuid',
39
    'json',
40
    'string',
41
]
42
43
SET_OF_REQUIRED_FIELDS = {
44
    'name',
45
    'cookiecutter_version',
46
    'variables',
47
}
48
49
REGEX_COMPILE_FLAGS = {
50
    'ascii': re.ASCII,
51
    'debug': re.DEBUG,
52
    'ignorecase': re.IGNORECASE,
53
    'locale': re.LOCALE,
54
    'mulitline': re.MULTILINE,
55
    'dotall': re.DOTALL,
56
    'verbose': re.VERBOSE,
57
}
58
59
60
def context_is_version_2(cookiecutter_context):
61
    """
62
    Return True if the cookiecutter_context meets the current requirements for
63
    a version 2 cookiecutter.json file format.
64
    """
65
    # This really is not sufficient since a v1 context could define each of
66
    # these fields; perhaps a more thorough test would be to also check if the
67
    # 'variables' field was defined as a list of OrderedDict items.
68
    if (cookiecutter_context.keys() &
69
            SET_OF_REQUIRED_FIELDS) == SET_OF_REQUIRED_FIELDS:
70
        return True
71
    else:
72
        return False
73
74
75
def prompt_string(variable, default):
76
    return click.prompt(
77
        variable.prompt,
78
        default=default,
79
        hide_input=variable.hide_input,
80
        type=click.STRING,
81
    )
82
83
84
def prompt_boolean(variable, default):
85
    return click.prompt(
86
        variable.prompt,
87
        default=default,
88
        hide_input=variable.hide_input,
89
        type=click.BOOL,
90
    )
91
92
93
def prompt_int(variable, default):
94
    return click.prompt(
95
        variable.prompt,
96
        default=default,
97
        hide_input=variable.hide_input,
98
        type=click.INT,
99
    )
100
101
102
def prompt_float(variable, default):
103
    return click.prompt(
104
        variable.prompt,
105
        default=default,
106
        hide_input=variable.hide_input,
107
        type=click.FLOAT,
108
    )
109
110
111
def prompt_uuid(variable, default):
112
    return click.prompt(
113
        variable.prompt,
114
        default=default,
115
        hide_input=variable.hide_input,
116
        type=click.UUID,
117
    )
118
119
120
def prompt_json(variable, default):
121
    # The JSON object from cookiecutter.json might be very large
122
    # We only show 'default'
123
    DEFAULT_JSON = 'default'
124
125
    def process_json(user_value):
126
        try:
127
            return json.loads(
128
                user_value,
129
                object_pairs_hook=collections.OrderedDict,
130
            )
131
        except ValueError:
132
            # json.decoder.JSONDecodeError raised in Python 3.5, 3.6
133
            # but it inherits from ValueError which is raised in Python 3.4
134
            # ---------------------------------------------------------------
135
            # Leave it up to click to ask the user again.
136
            # Local function procsse_json() is called by click within a
137
            # try block that catches click.UsageError exception's and asks
138
            # the user to try again.
139
            raise click.UsageError('Unable to decode to JSON.')
140
141
    dict_value = click.prompt(
142
        variable.prompt,
143
        default=DEFAULT_JSON,
144
        hide_input=variable.hide_input,
145
        type=click.STRING,
146
        value_proc=process_json,
147
    )
148
149
    if dict_value == DEFAULT_JSON:
150
        # Return the given default w/o any processing
151
        return default
152
    return dict_value
153
154
155
def prompt_yes_no(variable, default):
156
    if default is True:
157
        default_display = 'y'
158
    else:
159
        default_display = 'n'
160
161
    return click.prompt(
162
        variable.prompt,
163
        default=default_display,
164
        hide_input=variable.hide_input,
165
        type=click.BOOL,
166
    )
167
168
169
def prompt_choice(variable, default):
170
    """Returns prompt, default and callback for a choice variable"""
171
    choice_map = collections.OrderedDict(
172
        (u'{}'.format(i), value)
173
        for i, value in enumerate(variable.choices, 1)
174
    )
175
    choices = choice_map.keys()
176
177
    prompt = u'\n'.join((
178
        variable.prompt,
179
        u'\n'.join([u'{} - {}'.format(*c) for c in choice_map.items()]),
180
        u'Choose from {}'.format(u', '.join(choices)),
181
    ))
182
    default = str(variable.choices.index(default) + 1)
183
184
    user_choice = click.prompt(
185
        prompt,
186
        default=default,
187
        hide_input=variable.hide_input,
188
        type=click.Choice(choices),
189
    )
190
    return choice_map[user_choice]
191
192
193
PROMPTS = {
194
    'string': prompt_string,
195
    'boolean': prompt_boolean,
196
    'int': prompt_int,
197
    'float': prompt_float,
198
    'uuid': prompt_uuid,
199
    'json': prompt_json,
200
    'yes_no': prompt_yes_no,
201
}
202
203
204
def deserialize_string(value):
205
    return str(value)
206
207
208
def deserialize_boolean(value):
209
    return bool(value)
210
211
212
def deserialize_yes_no(value):
213
    return bool(value)
214
215
216
def deserialize_int(value):
217
    return int(value)
218
219
220
def deserialize_float(value):
221
    return float(value)
222
223
224
def deserialize_uuid(value):
225
    return click.UUID(value)
226
227
228
def deserialize_json(value):
229
    return value
230
231
232
DESERIALIZERS = {
233
    'string': deserialize_string,
234
    'boolean': deserialize_boolean,
235
    'int': deserialize_int,
236
    'float': deserialize_float,
237
    'uuid': deserialize_uuid,
238
    'json': deserialize_json,
239
    'yes_no': deserialize_yes_no,
240
}
241
242
243
class Variable(object):
244
    """
245
    Embody attributes of variables while processing the variables field of
246
    a cookiecutter version 2 context.
247
    """
248
249
    def __init__(self, name, default, **info):
250
        """
251
        :param name: A string containing the variable's name in the jinja2
252
                     context.
253
        :param default: The variable's default value. Can any type defined
254
                        below.
255
        :param kwargs info: Keyword/Argument pairs recognized are shown below.
256
257
        Recognized Keyword/Arguments, but optional:
258
259
            - `description` -- A string description of the variable.
260
            - `prompt` -- A string to show user when prompted for input.
261
            - `prompt_user` -- A boolean, if True prompt user; else no prompt.
262
            - `hide_input` -- A boolean, if True hide user's input.
263
            - `type` -- Specifies the variable's data type see below,
264
                    defaults to string.
265
            - `skip_if` -- A string of a jinja2 renderable boolean expression,
266
                    the variable will be skipped if it renders True.
267
            - `choices` -- A list of choices, may be of mixed types.
268
            - `validation` -- A string defining a regex to use to validation
269
                    user input. Defaults to None.
270
            - `validation_flags` - A list of validation flag names that can be
271
                    specified to control the behaviour of the validation
272
                    check done using the above defined `validation` string.
273
                    Specifying a flag is equivalent to setting it to True,
274
                    not specifying a flag is equivalent to setting it to False.
275
                    The default value of this variable has no effect on the
276
                    validation check.
277
278
                    The flags supported are:
279
280
                        * ascii - enabling re.ASCII
281
                        * debug - enabling re.DEBUG
282
                        * ignorecase - enabling re.IGNORECASE
283
                        * locale - enabling re.LOCALE
284
                        * mulitline - enabling re.MULTILINE
285
                        * dotall - enabling re.DOTALL
286
                        * verbose - enabling re.VERBOSE
287
288
                    See: https://docs.python.org/3/library/re.html#re.compile
289
290
        Supported Types
291
            * string
292
            * boolean
293
            * int
294
            * float
295
            * uuid
296
            * json
297
            * yes_no
298
299
        """
300
301
        # mandatory fields
302
        self.name = name
303
        self.default = default
304
305
        # optional fields
306
        self.info = info
307
308
        # -- DESCRIPTION -----------------------------------------------------
309
        self.description = self.check_type('description', None, str)
310
311
        # -- PROMPT ----------------------------------------------------------
312
        self.prompt = self.check_type('prompt', DEFAULT_PROMPT.format(variable=self), str)
313
314
        # -- HIDE_INPUT ------------------------------------------------------
315
        self.hide_input = self.check_type('hide_input', False, bool)
316
317
        # -- TYPE ------------------------------------------------------------
318
        self.var_type = info.get('type', 'string')
319
        if self.var_type not in VALID_TYPES:
320
            msg = 'Variable: {var_name} has an invalid type {var_type}. Valid types are: {types}'
321
            raise ValueError(msg.format(var_type=self.var_type,
322
                                        var_name=self.name,
323
                                        types=VALID_TYPES))
324
325
        # -- SKIP_IF ---------------------------------------------------------
326
        self.skip_if = self.check_type('skip_if', '', str)
327
328
        # -- PROMPT_USER -----------------------------------------------------
329
        self.prompt_user = self.check_type('prompt_user', True, bool)
330
        # do not prompt for private variable names (beginning with _)
331
        if self.name.startswith('_'):
332
            self.prompt_user = False
333
334
        # -- CHOICES ---------------------------------------------------------
335
        # choices are somewhat special as they can be of every type
336
        self.choices = self.check_type('choices', [], list)
337
        if self.choices and default not in self.choices:
338
            msg = "Variable: {var_name} has an invalid default value {default} for choices: {choices}."
339
            raise ValueError(msg.format(var_name=self.name, default=self.default, choices=self.choices))
340
341
        # -- VALIDATION STARTS -----------------------------------------------
342
        self.validation = self.check_type('validation', None, str)
343
344
        self.validation_flag_names = self.check_type('validation_flags', [], list)
345
346
        self.validation_flags = 0
347
348
        for vflag in self.validation_flag_names:
349
            if vflag in REGEX_COMPILE_FLAGS.keys():
350
                self.validation_flags |= REGEX_COMPILE_FLAGS[vflag]
351
            else:
352
                msg = "Variable: {var_name} - Ignoring unkown RegEx validation Control Flag named '{flag}'\n" \
353
                      "Legal flag names are: {names}"
354
                logger.warn(msg.format(var_name=self.name, flag=vflag,
355
                                       names=REGEX_COMPILE_FLAGS.keys()))
356
                self.validation_flag_names.remove(vflag)
357
358
        self.validate = None
359
        if self.validation:
360
            try:
361
                self.validate = re.compile(self.validation, self.validation_flags)
362
            except re.error as e:
363
                msg = "Variable: {var_name} - Validation Setup Error: Invalid RegEx '{value}' - does not compile - {err}"
364
                raise ValueError(msg.format(var_name=self.name,
365
                                            value=self.validation, err=e))
366
        # -- VALIDATION ENDS -------------------------------------------------
367
368
    def __repr__(self):
369
        return "<{class_name} {variable_name}>".format(
370
            class_name=self.__class__.__name__,
371
            variable_name=self.name,
372
        )
373
374
    def __str__(self):
375
        s = ["{key}='{value}'".format(key=key, value=self.__dict__[key]) for key in self.__dict__ if key != 'info']
376
        return self.__repr__() + ':\n' + ',\n'.join(s)
377
378
    def check_type(self, option_name, option_default_value, option_type):
379
        """
380
        Retrieve the option_value named option_name from info and check its type.
381
        Raise ValueError if the type is incorrect; otherwise return option's value.
382
        """
383
        option_value = self.info.get(option_name, option_default_value)
384
385
        if option_value is not None:
386
            if not isinstance(option_value, option_type):
387
                msg = "Variable: '{var_name}' Option: '{opt_name}' requires a value of type {type_name}, but has a value of: {value}"
388
                raise ValueError(msg.format(var_name=self.name, opt_name=option_name, type_name=option_type.__name__, value=option_value))
389
390
        return option_value
391
392
393
class CookiecutterTemplate(object):
394
    """
395
    Embodies all attributes of a version 2 Cookiecutter template.
396
    """
397
398
    def __init__(self, name, cookiecutter_version, variables, **info):
399
        """
400
        Mandatorty Parameters
401
402
        :param name: A string, the cookiecutter template name
403
        :param cookiecutter_version: A string containing the version of the
404
            cookiecutter application that is compatible with this template.
405
        :param variables: A list of OrderedDict items that describe each
406
            variable in the template. These variables are essentially what
407
            is found in the version 1 cookiecutter.json file.
408
409
        Optional Parameters (via \**info)
410
411
        :param authors: An array of string - maintainers of the template.
412
        :param description: A string, human readable description of template.
413
        :param keywords: An array of string - similar to PyPI keywords.
414
        :param license: A string identifying the license of the template code.
415
        :param url: A string containing the URL for the template project.
416
        :param version: A string containing a version identifier, ideally
417
            following the semantic versioning spec.
418
419
        """
420
421
        # mandatory fields
422
        self.name = name
423
        self.cookiecutter_version = cookiecutter_version
424
        self.variables = [Variable(**v) for v in variables]
425
426
        # optional fields
427
        self.authors = info.get('authors', [])
428
        self.description = info.get('description', None)
429
        self.keywords = info.get('keywords', [])
430
        self.license = info.get('license', None)
431
        self.url = info.get('url', None)
432
        self.version = info.get('version', None)
433
434
    def __repr__(self):
435
        return "<{class_name} {template_name}>".format(
436
            class_name=self.__class__.__name__,
437
            template_name=self.name,
438
        )
439
440
    def __iter__(self):
441
        for v in self.variables:
442
            yield v
443
444
445
def load_context(json_object, no_input=False, verbose=True):
446
    """
447
    Load a version 2 context & process the json_object for declared variables
448
    in the Cookiecutter template.
449
450
    :param json_object: A JSON file that has be loaded into a Python OrderedDict.
451
    :param no_input: Prompt the user at command line for manual configuration if False,
452
                     if True, no input prompts are made, all defaults are accepted.
453
    :param verbose: Emit maximum varible information.
454
    """
455
    env = Environment(extensions=['jinja2_time.TimeExtension'])
456
    context = collections.OrderedDict({})
457
458
    for variable in CookiecutterTemplate(**json_object):
459
        if variable.skip_if:
460
            skip_template = env.from_string(variable.skip_if)
461
            if skip_template.render(cookiecutter=context) == 'True':
462
                continue
463
464
        default = variable.default
465
466
        if isinstance(default, str):
467
            template = env.from_string(default)
468
            default = template.render(cookiecutter=context)
469
470
        deserialize = DESERIALIZERS[variable.var_type]
471
472
        if no_input or (not variable.prompt_user):
473
            context[variable.name] = deserialize(default)
474
            continue
475
476
        if variable.choices:
477
            prompt = prompt_choice
478
        else:
479
            prompt = PROMPTS[variable.var_type]
480
481
        if verbose and variable.description:
482
            click.echo(variable.description)
483
484
        while True:
485
            value = prompt(variable, default)
486
            if variable.validate:
487
                if variable.validate.match(value):
488
                    break
489
                else:
490
                    msg = "Input validation failure against regex: '{val_string}', try again!".format(val_string=variable.validation)
491
                    click.echo(msg)
492
            else:
493
                # no validation defined
494
                break
495
496
        if verbose:
497
            width, _ = click.get_terminal_size()
498
            click.echo('-' * width)
499
500
        context[variable.name] = deserialize(value)
501
502
    return context
503