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
|
|
|
|