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

prompt_float()   A

Complexity

Conditions 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
c 1
b 0
f 0
dl 0
loc 6
rs 9.4285
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
    # click.prompt() behavior:
162
    # When supplied with a string default, the string default is returned,
163
    # rather than the string converted to a click.BOOL.
164
    # If default is passed as a boolean then the default is displayed as
165
    # [True] or [False], rather than [y] or [n].
166
    # This prompt translates y, yes, Yes, YES, n, no, No, NO to their correct
167
    # boolean values, its just that it does not translate a string default
168
    # value of y, yes, Yes, YES, n, no, No, NO to a boolean...
169
    value = click.prompt(
170
        variable.prompt,
171
        default=default_display,
172
        hide_input=variable.hide_input,
173
        type=click.BOOL,
174
    )
175
176
    # ...so if we get the displayed default value back (its a string),
177
    # change it to its associated boolean value
178
    if value == default_display:
179
        value = default
180
181
    return value
182
183
184
def prompt_choice(variable, default):
185
    """Returns prompt, default and callback for a choice variable"""
186
    choice_map = collections.OrderedDict(
187
        (u'{}'.format(i), value)
188
        for i, value in enumerate(variable.choices, 1)
189
    )
190
    choices = choice_map.keys()
191
192
    prompt = u'\n'.join((
193
        variable.prompt,
194
        u'\n'.join([u'{} - {}'.format(*c) for c in choice_map.items()]),
195
        u'Choose from {}'.format(u', '.join(choices)),
196
    ))
197
    default = str(variable.choices.index(default) + 1)
198
199
    user_choice = click.prompt(
200
        prompt,
201
        default=default,
202
        hide_input=variable.hide_input,
203
        type=click.Choice(choices),
204
    )
205
    return choice_map[user_choice]
206
207
208
PROMPTS = {
209
    'string': prompt_string,
210
    'boolean': prompt_boolean,
211
    'int': prompt_int,
212
    'float': prompt_float,
213
    'uuid': prompt_uuid,
214
    'json': prompt_json,
215
    'yes_no': prompt_yes_no,
216
}
217
218
219
def deserialize_string(value):
220
    return str(value)
221
222
223
def deserialize_boolean(value):
224
    return bool(value)
225
226
227
def deserialize_yes_no(value):
228
    return bool(value)
229
230
231
def deserialize_int(value):
232
    return int(value)
233
234
235
def deserialize_float(value):
236
    return float(value)
237
238
239
def deserialize_uuid(value):
240
    return click.UUID(value)
241
242
243
def deserialize_json(value):
244
    return value
245
246
247
DESERIALIZERS = {
248
    'string': deserialize_string,
249
    'boolean': deserialize_boolean,
250
    'int': deserialize_int,
251
    'float': deserialize_float,
252
    'uuid': deserialize_uuid,
253
    'json': deserialize_json,
254
    'yes_no': deserialize_yes_no,
255
}
256
257
258
class Variable(object):
259
    """
260
    Embody attributes of variables while processing the variables field of
261
    a cookiecutter version 2 context.
262
    """
263
264
    def __init__(self, name, default, **info):
265
        """
266
        :param name: A string containing the variable's name in the jinja2
267
                     context.
268
        :param default: The variable's default value. Can any type defined
269
                        below.
270
        :param kwargs info: Keyword/Argument pairs recognized are shown below.
271
272
        Recognized Keyword/Arguments, but optional:
273
274
            - `description` -- A string description of the variable.
275
            - `prompt` -- A string to show user when prompted for input.
276
            - `prompt_user` -- A boolean, if True prompt user; else no prompt.
277
            - `hide_input` -- A boolean, if True hide user's input.
278
            - `type` -- Specifies the variable's data type see below,
279
                    defaults to string.
280
            - `skip_if` -- A string of a jinja2 renderable boolean expression,
281
                    the variable will be skipped if it renders True.
282
            - `do_if` -- A string of a jinja2 renderable boolean expression,
283
                    the variable will be processed if it renders True.
284
            - `choices` -- A list of choices, may be of mixed types.
285
            - `if_yes_skip_to` -- A string containing a variable name to skip
286
                    to if the yes_no value is True (yes). Only has meaning for
287
                    variables of type 'yes_no'.
288
            - `if_no_skip_to` -- A string containing a variable name to skip
289
                    to if the yes_no value is False (no). Only has meaning for
290
                    variables of type 'yes_no'.
291
            - `validation` -- A string defining a regex to use to validation
292
                    user input. Defaults to None.
293
            - `validation_msg` -- A string defining an additional message to
294
                    display if the validation check fails.
295
            - `validation_flags` -- A list of validation flag names that can be
296
                    specified to control the behaviour of the validation
297
                    check done using the above defined `validation` string.
298
                    Specifying a flag is equivalent to setting it to True,
299
                    not specifying a flag is equivalent to setting it to False.
300
                    The default value of this variable has no effect on the
301
                    validation check.
302
303
                    The flags supported are:
304
305
                        * ascii - enabling re.ASCII
306
                        * debug - enabling re.DEBUG
307
                        * ignorecase - enabling re.IGNORECASE
308
                        * locale - enabling re.LOCALE
309
                        * mulitline - enabling re.MULTILINE
310
                        * dotall - enabling re.DOTALL
311
                        * verbose - enabling re.VERBOSE
312
313
                    See: https://docs.python.org/3/library/re.html#re.compile
314
315
        Supported Types
316
            * string
317
            * boolean
318
            * int
319
            * float
320
            * uuid
321
            * json
322
            * yes_no
323
324
        """
325
326
        # mandatory fields
327
        self.name = name
328
        self.default = default
329
330
        # optional fields
331
        self.info = info
332
333
        # -- DESCRIPTION -----------------------------------------------------
334
        self.description = self.check_type('description', None, str)
335
336
        # -- PROMPT ----------------------------------------------------------
337
        self.prompt = self.check_type('prompt', DEFAULT_PROMPT.format(variable=self), str)
338
339
        # -- HIDE_INPUT ------------------------------------------------------
340
        self.hide_input = self.check_type('hide_input', False, bool)
341
342
        # -- TYPE ------------------------------------------------------------
343
        self.var_type = info.get('type', 'string')
344
        if self.var_type not in VALID_TYPES:
345
            msg = 'Variable: {var_name} has an invalid type {var_type}. Valid types are: {types}'
346
            raise ValueError(msg.format(var_type=self.var_type,
347
                                        var_name=self.name,
348
                                        types=VALID_TYPES))
349
350
        # -- SKIP_IF ---------------------------------------------------------
351
        self.skip_if = self.check_type('skip_if', '', str)
352
353
        # -- DO_IF ---------------------------------------------------------
354
        self.do_if = self.check_type('do_if', '', str)
355
356
        # -- IF_YES_SKIP_TO ---------------------------------------------------------
357
        self.if_yes_skip_to = self.check_type('if_yes_skip_to', None, str)
358
        if self.if_yes_skip_to:
359
            if self.var_type not in ['yes_no']:
360
                msg = "Variable: '{var_name}' specifies 'if_yes_skip_to' field, but variable not of type 'yes_no'"
361
                raise ValueError(msg.format(var_name=self.name))
362
363
        # -- IF_NO_SKIP_TO ---------------------------------------------------------
364
        self.if_no_skip_to = self.check_type('if_no_skip_to', None, str)
365
        if self.if_no_skip_to:
366
            if self.var_type not in ['yes_no']:
367
                msg = "Variable: '{var_name}' specifies 'if_no_skip_to' field, but variable not of type 'yes_no'"
368
                raise ValueError(msg.format(var_name=self.name))
369
370
        # -- PROMPT_USER -----------------------------------------------------
371
        self.prompt_user = self.check_type('prompt_user', True, bool)
372
        # do not prompt for private variable names (beginning with _)
373
        if self.name.startswith('_'):
374
            self.prompt_user = False
375
376
        # -- CHOICES ---------------------------------------------------------
377
        # choices are somewhat special as they can be of every type
378
        self.choices = self.check_type('choices', [], list)
379
        if self.choices and default not in self.choices:
380
            msg = "Variable: {var_name} has an invalid default value {default} for choices: {choices}."
381
            raise ValueError(msg.format(var_name=self.name, default=self.default, choices=self.choices))
382
383
        # -- VALIDATION STARTS -----------------------------------------------
384
        self.validation = self.check_type('validation', None, str)
385
386
        self.validation_msg = self.check_type('validation_msg', None, str)
387
388
        self.validation_flag_names = self.check_type('validation_flags', [], list)
389
390
        self.validation_flags = 0
391
392
        for vflag in self.validation_flag_names:
393
            if vflag in REGEX_COMPILE_FLAGS.keys():
394
                self.validation_flags |= REGEX_COMPILE_FLAGS[vflag]
395
            else:
396
                msg = "Variable: {var_name} - Ignoring unkown RegEx validation Control Flag named '{flag}'\n" \
397
                      "Legal flag names are: {names}"
398
                logger.warn(msg.format(var_name=self.name, flag=vflag,
399
                                       names=REGEX_COMPILE_FLAGS.keys()))
400
                self.validation_flag_names.remove(vflag)
401
402
        self.validate = None
403
        if self.validation:
404
            try:
405
                self.validate = re.compile(self.validation, self.validation_flags)
406
            except re.error as e:
407
                msg = "Variable: {var_name} - Validation Setup Error: Invalid RegEx '{value}' - does not compile - {err}"
408
                raise ValueError(msg.format(var_name=self.name,
409
                                            value=self.validation, err=e))
410
        # -- VALIDATION ENDS -------------------------------------------------
411
412
    def __repr__(self):
413
        return "<{class_name} {variable_name}>".format(
414
            class_name=self.__class__.__name__,
415
            variable_name=self.name,
416
        )
417
418
    def __str__(self):
419
        s = ["{key}='{value}'".format(key=key, value=self.__dict__[key]) for key in self.__dict__ if key != 'info']
420
        return self.__repr__() + ':\n' + ',\n'.join(s)
421
422
    def check_type(self, option_name, option_default_value, option_type):
423
        """
424
        Retrieve the option_value named option_name from info and check its type.
425
        Raise ValueError if the type is incorrect; otherwise return option's value.
426
        """
427
        option_value = self.info.get(option_name, option_default_value)
428
429
        if option_value is not None:
430
            if not isinstance(option_value, option_type):
431
                msg = "Variable: '{var_name}' Option: '{opt_name}' requires a value of type {type_name}, but has a value of: {value}"
432
                raise ValueError(msg.format(var_name=self.name, opt_name=option_name, type_name=option_type.__name__, value=option_value))
433
434
        return option_value
435
436
437
class CookiecutterTemplate(object):
438
    """
439
    Embodies all attributes of a version 2 Cookiecutter template.
440
    """
441
442
    def __init__(self, name, cookiecutter_version, variables, **info):
443
        """
444
        Mandatorty Parameters
445
446
        :param name: A string, the cookiecutter template name
447
        :param cookiecutter_version: A string containing the version of the
448
            cookiecutter application that is compatible with this template.
449
        :param variables: A list of OrderedDict items that describe each
450
            variable in the template. These variables are essentially what
451
            is found in the version 1 cookiecutter.json file.
452
453
        Optional Parameters (via \**info)
454
455
        :param authors: An array of string - maintainers of the template.
456
        :param description: A string, human readable description of template.
457
        :param keywords: An array of string - similar to PyPI keywords.
458
        :param license: A string identifying the license of the template code.
459
        :param url: A string containing the URL for the template project.
460
        :param version: A string containing a version identifier, ideally
461
            following the semantic versioning spec.
462
463
        """
464
465
        # mandatory fields
466
        self.name = name
467
        self.cookiecutter_version = cookiecutter_version
468
        self.variables = [Variable(**v) for v in variables]
469
470
        # optional fields
471
        self.authors = info.get('authors', [])
472
        self.description = info.get('description', None)
473
        self.keywords = info.get('keywords', [])
474
        self.license = info.get('license', None)
475
        self.url = info.get('url', None)
476
        self.version = info.get('version', None)
477
478
    def __repr__(self):
479
        return "<{class_name} {template_name}>".format(
480
            class_name=self.__class__.__name__,
481
            template_name=self.name,
482
        )
483
484
    def __iter__(self):
485
        for v in self.variables:
486
            yield v
487
488
489
def load_context(json_object, no_input=False, verbose=True):
490
    """
491
    Load a version 2 context & process the json_object for declared variables
492
    in the Cookiecutter template.
493
494
    :param json_object: A JSON file that has be loaded into a Python OrderedDict.
495
    :param no_input: Prompt the user at command line for manual configuration if False,
496
                     if True, no input prompts are made, all defaults are accepted.
497
    :param verbose: Emit maximum varible information.
498
    """
499
    env = Environment(extensions=['jinja2_time.TimeExtension'])
500
    context = collections.OrderedDict({})
501
502
    skip_to_variable_name = None
503
504
    for variable in CookiecutterTemplate(**json_object):
505
        if skip_to_variable_name:
506
            if variable.name == skip_to_variable_name:
507
                skip_to_variable_name = None
508
            else:
509
                # Is executed, but not marked so in coverage report, due to
510
                # CPython's peephole optimizer's optimizations.
511
                # See https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered
512
                # Issue #198 in coverage.py marked WONTFIX
513
                continue  # pragma: no cover
514
515
        if variable.skip_if:
516
            skip_template = env.from_string(variable.skip_if)
517
            if skip_template.render(cookiecutter=context) == 'True':
518
                continue
519
520
        if variable.do_if:
521
            do_template = env.from_string(variable.do_if)
522
            if do_template.render(cookiecutter=context) == 'False':
523
                continue
524
525
        default = variable.default
526
527
        if isinstance(default, str):
528
            template = env.from_string(default)
529
            default = template.render(cookiecutter=context)
530
531
        deserialize = DESERIALIZERS[variable.var_type]
532
533
        if no_input or (not variable.prompt_user):
534
            context[variable.name] = deserialize(default)
535
        else:
536
            if variable.choices:
537
                prompt = prompt_choice
538
            else:
539
                prompt = PROMPTS[variable.var_type]
540
541
            if verbose and variable.description:
542
                click.echo(variable.description)
543
544
            while True:
545
                value = prompt(variable, default)
546
                if variable.validate:
547
                    if variable.validate.match(value):
548
                        break
549
                    else:
550
                        msg = "Input validation failure against regex: '{val_string}', try again!".format(val_string=variable.validation)
551
                        click.echo(msg)
552
                        if variable.validation_msg:
553
                            click.echo(variable.validation_msg)
554
                else:
555
                    # no validation defined
556
                    break
557
558
            if verbose:
559
                width, _ = click.get_terminal_size()
560
                click.echo('-' * width)
561
562
            context[variable.name] = deserialize(value)
563
564
        if variable.if_yes_skip_to and context[variable.name] is True:
565
            skip_to_variable_name = variable.if_yes_skip_to
566
567
        if variable.if_no_skip_to and context[variable.name] is False:
568
            skip_to_variable_name = variable.if_no_skip_to
569
570
    if skip_to_variable_name:
571
        logger.warn("Processed all variables, but skip_to_variable_name '{}' was never found.".format(skip_to_variable_name))
572
573
    return context
574