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

load_context()   F

Complexity

Conditions 23

Size

Total Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 23
c 4
b 0
f 0
dl 0
loc 83
rs 2.148

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like load_context() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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
            - `choices` -- A list of choices, may be of mixed types.
283
            - `validation` -- A string defining a regex to use to validation
284
                    user input. Defaults to None.
285
            - `validation_flags` - A list of validation flag names that can be
286
                    specified to control the behaviour of the validation
287
                    check done using the above defined `validation` string.
288
                    Specifying a flag is equivalent to setting it to True,
289
                    not specifying a flag is equivalent to setting it to False.
290
                    The default value of this variable has no effect on the
291
                    validation check.
292
293
                    The flags supported are:
294
295
                        * ascii - enabling re.ASCII
296
                        * debug - enabling re.DEBUG
297
                        * ignorecase - enabling re.IGNORECASE
298
                        * locale - enabling re.LOCALE
299
                        * mulitline - enabling re.MULTILINE
300
                        * dotall - enabling re.DOTALL
301
                        * verbose - enabling re.VERBOSE
302
303
                    See: https://docs.python.org/3/library/re.html#re.compile
304
305
        Supported Types
306
            * string
307
            * boolean
308
            * int
309
            * float
310
            * uuid
311
            * json
312
            * yes_no
313
314
        """
315
316
        # mandatory fields
317
        self.name = name
318
        self.default = default
319
320
        # optional fields
321
        self.info = info
322
323
        # -- DESCRIPTION -----------------------------------------------------
324
        self.description = self.check_type('description', None, str)
325
326
        # -- PROMPT ----------------------------------------------------------
327
        self.prompt = self.check_type('prompt', DEFAULT_PROMPT.format(variable=self), str)
328
329
        # -- HIDE_INPUT ------------------------------------------------------
330
        self.hide_input = self.check_type('hide_input', False, bool)
331
332
        # -- TYPE ------------------------------------------------------------
333
        self.var_type = info.get('type', 'string')
334
        if self.var_type not in VALID_TYPES:
335
            msg = 'Variable: {var_name} has an invalid type {var_type}. Valid types are: {types}'
336
            raise ValueError(msg.format(var_type=self.var_type,
337
                                        var_name=self.name,
338
                                        types=VALID_TYPES))
339
340
        # -- SKIP_IF ---------------------------------------------------------
341
        self.skip_if = self.check_type('skip_if', '', str)
342
343
        # -- DO_IF ---------------------------------------------------------
344
        self.do_if = self.check_type('do_if', '', str)
345
346
        # -- IF_YES_SKIP_TO ---------------------------------------------------------
347
        self.if_yes_skip_to = self.check_type('if_yes_skip_to', None, str)
348
349
        # -- IF_NO_SKIP_TO ---------------------------------------------------------
350
        self.if_no_skip_to = self.check_type('if_no_skip_to', None, str)
351
352
        # -- PROMPT_USER -----------------------------------------------------
353
        self.prompt_user = self.check_type('prompt_user', True, bool)
354
        # do not prompt for private variable names (beginning with _)
355
        if self.name.startswith('_'):
356
            self.prompt_user = False
357
358
        # -- CHOICES ---------------------------------------------------------
359
        # choices are somewhat special as they can be of every type
360
        self.choices = self.check_type('choices', [], list)
361
        if self.choices and default not in self.choices:
362
            msg = "Variable: {var_name} has an invalid default value {default} for choices: {choices}."
363
            raise ValueError(msg.format(var_name=self.name, default=self.default, choices=self.choices))
364
365
        # -- VALIDATION STARTS -----------------------------------------------
366
        self.validation = self.check_type('validation', None, str)
367
368
        self.validation_flag_names = self.check_type('validation_flags', [], list)
369
370
        self.validation_flags = 0
371
372
        for vflag in self.validation_flag_names:
373
            if vflag in REGEX_COMPILE_FLAGS.keys():
374
                self.validation_flags |= REGEX_COMPILE_FLAGS[vflag]
375
            else:
376
                msg = "Variable: {var_name} - Ignoring unkown RegEx validation Control Flag named '{flag}'\n" \
377
                      "Legal flag names are: {names}"
378
                logger.warn(msg.format(var_name=self.name, flag=vflag,
379
                                       names=REGEX_COMPILE_FLAGS.keys()))
380
                self.validation_flag_names.remove(vflag)
381
382
        self.validate = None
383
        if self.validation:
384
            try:
385
                self.validate = re.compile(self.validation, self.validation_flags)
386
            except re.error as e:
387
                msg = "Variable: {var_name} - Validation Setup Error: Invalid RegEx '{value}' - does not compile - {err}"
388
                raise ValueError(msg.format(var_name=self.name,
389
                                            value=self.validation, err=e))
390
        # -- VALIDATION ENDS -------------------------------------------------
391
392
    def __repr__(self):
393
        return "<{class_name} {variable_name}>".format(
394
            class_name=self.__class__.__name__,
395
            variable_name=self.name,
396
        )
397
398
    def __str__(self):
399
        s = ["{key}='{value}'".format(key=key, value=self.__dict__[key]) for key in self.__dict__ if key != 'info']
400
        return self.__repr__() + ':\n' + ',\n'.join(s)
401
402
    def check_type(self, option_name, option_default_value, option_type):
403
        """
404
        Retrieve the option_value named option_name from info and check its type.
405
        Raise ValueError if the type is incorrect; otherwise return option's value.
406
        """
407
        option_value = self.info.get(option_name, option_default_value)
408
409
        if option_value is not None:
410
            if not isinstance(option_value, option_type):
411
                msg = "Variable: '{var_name}' Option: '{opt_name}' requires a value of type {type_name}, but has a value of: {value}"
412
                raise ValueError(msg.format(var_name=self.name, opt_name=option_name, type_name=option_type.__name__, value=option_value))
413
414
        return option_value
415
416
417
class CookiecutterTemplate(object):
418
    """
419
    Embodies all attributes of a version 2 Cookiecutter template.
420
    """
421
422
    def __init__(self, name, cookiecutter_version, variables, **info):
423
        """
424
        Mandatorty Parameters
425
426
        :param name: A string, the cookiecutter template name
427
        :param cookiecutter_version: A string containing the version of the
428
            cookiecutter application that is compatible with this template.
429
        :param variables: A list of OrderedDict items that describe each
430
            variable in the template. These variables are essentially what
431
            is found in the version 1 cookiecutter.json file.
432
433
        Optional Parameters (via \**info)
434
435
        :param authors: An array of string - maintainers of the template.
436
        :param description: A string, human readable description of template.
437
        :param keywords: An array of string - similar to PyPI keywords.
438
        :param license: A string identifying the license of the template code.
439
        :param url: A string containing the URL for the template project.
440
        :param version: A string containing a version identifier, ideally
441
            following the semantic versioning spec.
442
443
        """
444
445
        # mandatory fields
446
        self.name = name
447
        self.cookiecutter_version = cookiecutter_version
448
        self.variables = [Variable(**v) for v in variables]
449
450
        # optional fields
451
        self.authors = info.get('authors', [])
452
        self.description = info.get('description', None)
453
        self.keywords = info.get('keywords', [])
454
        self.license = info.get('license', None)
455
        self.url = info.get('url', None)
456
        self.version = info.get('version', None)
457
458
    def __repr__(self):
459
        return "<{class_name} {template_name}>".format(
460
            class_name=self.__class__.__name__,
461
            template_name=self.name,
462
        )
463
464
    def __iter__(self):
465
        for v in self.variables:
466
            yield v
467
468
469
def load_context(json_object, no_input=False, verbose=True):
470
    """
471
    Load a version 2 context & process the json_object for declared variables
472
    in the Cookiecutter template.
473
474
    :param json_object: A JSON file that has be loaded into a Python OrderedDict.
475
    :param no_input: Prompt the user at command line for manual configuration if False,
476
                     if True, no input prompts are made, all defaults are accepted.
477
    :param verbose: Emit maximum varible information.
478
    """
479
    env = Environment(extensions=['jinja2_time.TimeExtension'])
480
    context = collections.OrderedDict({})
481
482
    skip_to_variable_name = None
483
484
    for variable in CookiecutterTemplate(**json_object):
485
        if skip_to_variable_name:
486
            if variable.name == skip_to_variable_name:
487
                skip_to_variable_name = None
488
            else:
489
                # Is executed, but not marked so in coverage report, due to
490
                # CPython's peephole optimizer's optimizations.
491
                # See https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered
492
                # Issue #198 in coverage.py marked WONTFIX
493
                continue  # pragma: no cover
494
495
        if variable.skip_if:
496
            skip_template = env.from_string(variable.skip_if)
497
            if skip_template.render(cookiecutter=context) == 'True':
498
                continue
499
500
        if variable.do_if:
501
            do_template = env.from_string(variable.do_if)
502
            if do_template.render(cookiecutter=context) == 'False':
503
                continue
504
505
        default = variable.default
506
507
        if isinstance(default, str):
508
            template = env.from_string(default)
509
            default = template.render(cookiecutter=context)
510
511
        deserialize = DESERIALIZERS[variable.var_type]
512
513
        if no_input or (not variable.prompt_user):
514
            context[variable.name] = deserialize(default)
515
        else:
516
            if variable.choices:
517
                prompt = prompt_choice
518
            else:
519
                prompt = PROMPTS[variable.var_type]
520
521
            if verbose and variable.description:
522
                click.echo(variable.description)
523
524
            while True:
525
                value = prompt(variable, default)
526
                if variable.validate:
527
                    if variable.validate.match(value):
528
                        break
529
                    else:
530
                        msg = "Input validation failure against regex: '{val_string}', try again!".format(val_string=variable.validation)
531
                        click.echo(msg)
532
                else:
533
                    # no validation defined
534
                    break
535
536
            if verbose:
537
                width, _ = click.get_terminal_size()
538
                click.echo('-' * width)
539
540
            context[variable.name] = deserialize(value)
541
542
        if variable.if_yes_skip_to and context[variable.name] is True:
543
            skip_to_variable_name = variable.if_yes_skip_to
544
545
        if variable.if_no_skip_to and context[variable.name] is False:
546
            skip_to_variable_name = variable.if_no_skip_to
547
548
    if skip_to_variable_name:
549
        logger.warn("Processed all variables, but skip_to_variable_name '{}' was never found.".format(skip_to_variable_name))
550
551
    return context
552