1 | # Licensed to the StackStorm, Inc ('StackStorm') under one or more |
||
2 | # contributor license agreements. See the NOTICE file distributed with |
||
3 | # this work for additional information regarding copyright ownership. |
||
4 | # The ASF licenses this file to You under the Apache License, Version 2.0 |
||
5 | # (the "License"); you may not use this file except in compliance with |
||
6 | # the License. You may obtain a copy of the License at |
||
7 | # |
||
8 | # http://www.apache.org/licenses/LICENSE-2.0 |
||
9 | # |
||
10 | # Unless required by applicable law or agreed to in writing, software |
||
11 | # distributed under the License is distributed on an "AS IS" BASIS, |
||
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||
13 | # See the License for the specific language governing permissions and |
||
14 | # limitations under the License. |
||
15 | |||
16 | from __future__ import absolute_import |
||
17 | import re |
||
18 | |||
19 | import jsonschema |
||
20 | from jsonschema import Draft3Validator |
||
21 | from prompt_toolkit import prompt |
||
22 | from prompt_toolkit import token |
||
23 | from prompt_toolkit import validation |
||
24 | |||
25 | from st2client.exceptions.operations import OperationFailureException |
||
26 | from six.moves import range |
||
0 ignored issues
–
show
|
|||
27 | |||
28 | |||
29 | POSITIVE_BOOLEAN = {'1', 'y', 'yes', 'true'} |
||
30 | NEGATIVE_BOOLEAN = {'0', 'n', 'no', 'nope', 'nah', 'false'} |
||
31 | |||
32 | |||
33 | class ReaderNotImplemented(OperationFailureException): |
||
34 | pass |
||
35 | |||
36 | |||
37 | class DialogInterrupted(OperationFailureException): |
||
38 | pass |
||
39 | |||
40 | |||
41 | class MuxValidator(validation.Validator): |
||
42 | def __init__(self, validators, spec): |
||
43 | super(MuxValidator, self).__init__() |
||
44 | |||
45 | self.validators = validators |
||
46 | self.spec = spec |
||
47 | |||
48 | def validate(self, document): |
||
49 | input = document.text |
||
50 | |||
51 | for validator in self.validators: |
||
52 | validator(input, self.spec) |
||
53 | |||
54 | |||
55 | class StringReader(object): |
||
56 | def __init__(self, name, spec, prefix=None, secret=False, **kw): |
||
57 | self.name = name |
||
58 | self.spec = spec |
||
59 | self.prefix = prefix or '' |
||
60 | self.options = { |
||
61 | 'is_password': secret |
||
62 | } |
||
63 | |||
64 | self._construct_description() |
||
65 | self._construct_template() |
||
66 | self._construct_validators() |
||
67 | |||
68 | self.options.update(kw) |
||
69 | |||
70 | @staticmethod |
||
71 | def condition(spec): |
||
72 | return True |
||
73 | |||
74 | @staticmethod |
||
75 | def validate(input, spec): |
||
76 | try: |
||
77 | jsonschema.validate(input, spec, Draft3Validator) |
||
78 | except jsonschema.ValidationError as e: |
||
79 | raise validation.ValidationError(len(input), str(e)) |
||
80 | |||
81 | def read(self): |
||
82 | message = self.template.format(self.prefix + self.name, **self.spec) |
||
83 | response = prompt(message, **self.options) |
||
84 | |||
85 | result = self.spec.get('default', None) |
||
86 | |||
87 | if response: |
||
88 | result = self._transform_response(response) |
||
89 | |||
90 | return result |
||
91 | |||
92 | def _construct_description(self): |
||
93 | if 'description' in self.spec: |
||
94 | def get_bottom_toolbar_tokens(cli): |
||
95 | return [(token.Token.Toolbar, self.spec['description'])] |
||
96 | |||
97 | self.options['get_bottom_toolbar_tokens'] = get_bottom_toolbar_tokens |
||
98 | |||
99 | def _construct_template(self): |
||
100 | self.template = u'{0}: ' |
||
101 | |||
102 | if 'default' in self.spec: |
||
103 | self.template = u'{0} [{default}]: ' |
||
104 | |||
105 | def _construct_validators(self): |
||
106 | self.options['validator'] = MuxValidator([self.validate], self.spec) |
||
107 | |||
108 | def _transform_response(self, response): |
||
109 | return response |
||
110 | |||
111 | |||
112 | class BooleanReader(StringReader): |
||
113 | @staticmethod |
||
114 | def condition(spec): |
||
115 | return spec.get('type', None) == 'boolean' |
||
116 | |||
117 | @staticmethod |
||
118 | def validate(input, spec): |
||
119 | if not input and (not spec.get('required', None) or spec.get('default', None)): |
||
120 | return |
||
121 | |||
122 | if input.lower() not in POSITIVE_BOOLEAN | NEGATIVE_BOOLEAN: |
||
123 | raise validation.ValidationError(len(input), |
||
124 | 'Does not look like boolean. Pick from [%s]' |
||
125 | % ', '.join(POSITIVE_BOOLEAN | NEGATIVE_BOOLEAN)) |
||
126 | |||
127 | def _construct_template(self): |
||
128 | self.template = u'{0} (boolean)' |
||
129 | |||
130 | if 'default' in self.spec: |
||
131 | self.template += u' [{}]: '.format(self.spec.get('default') and 'y' or 'n') |
||
132 | else: |
||
133 | self.template += u': ' |
||
134 | |||
135 | def _transform_response(self, response): |
||
136 | if response.lower() in POSITIVE_BOOLEAN: |
||
137 | return True |
||
138 | if response.lower() in NEGATIVE_BOOLEAN: |
||
139 | return False |
||
140 | |||
141 | # Hopefully, it will never happen |
||
142 | raise OperationFailureException('Response neither positive no negative. ' |
||
143 | 'Value have not been properly validated.') |
||
144 | |||
145 | |||
146 | class NumberReader(StringReader): |
||
147 | @staticmethod |
||
148 | def condition(spec): |
||
149 | return spec.get('type', None) == 'number' |
||
150 | |||
151 | @staticmethod |
||
152 | def validate(input, spec): |
||
153 | if input: |
||
154 | try: |
||
155 | input = float(input) |
||
156 | except ValueError as e: |
||
157 | raise validation.ValidationError(len(input), str(e)) |
||
158 | |||
159 | super(NumberReader, NumberReader).validate(input, spec) |
||
160 | |||
161 | def _construct_template(self): |
||
162 | self.template = u'{0} (float)' |
||
163 | |||
164 | if 'default' in self.spec: |
||
165 | self.template += u' [{default}]: '.format(default=self.spec.get('default')) |
||
166 | else: |
||
167 | self.template += u': ' |
||
168 | |||
169 | def _transform_response(self, response): |
||
170 | return float(response) |
||
171 | |||
172 | |||
173 | class IntegerReader(StringReader): |
||
174 | @staticmethod |
||
175 | def condition(spec): |
||
176 | return spec.get('type', None) == 'integer' |
||
177 | |||
178 | @staticmethod |
||
179 | def validate(input, spec): |
||
180 | if input: |
||
181 | try: |
||
182 | input = int(input) |
||
183 | except ValueError as e: |
||
184 | raise validation.ValidationError(len(input), str(e)) |
||
185 | |||
186 | super(IntegerReader, IntegerReader).validate(input, spec) |
||
187 | |||
188 | def _construct_template(self): |
||
189 | self.template = u'{0} (integer)' |
||
190 | |||
191 | if 'default' in self.spec: |
||
192 | self.template += u' [{default}]: '.format(default=self.spec.get('default')) |
||
193 | else: |
||
194 | self.template += u': ' |
||
195 | |||
196 | def _transform_response(self, response): |
||
197 | return int(response) |
||
198 | |||
199 | |||
200 | class SecretStringReader(StringReader): |
||
201 | def __init__(self, *args, **kwargs): |
||
202 | super(SecretStringReader, self).__init__(*args, secret=True, **kwargs) |
||
203 | |||
204 | @staticmethod |
||
205 | def condition(spec): |
||
206 | return spec.get('secret', None) |
||
207 | |||
208 | def _construct_template(self): |
||
209 | self.template = u'{0} (secret)' |
||
210 | |||
211 | if 'default' in self.spec: |
||
212 | self.template += u' [{default}]: '.format(default=self.spec.get('default')) |
||
213 | else: |
||
214 | self.template += u': ' |
||
215 | |||
216 | |||
217 | class EnumReader(StringReader): |
||
218 | @staticmethod |
||
219 | def condition(spec): |
||
220 | return spec.get('enum', None) |
||
221 | |||
222 | @staticmethod |
||
223 | def validate(input, spec): |
||
224 | if not input and (not spec.get('required', None) or spec.get('default', None)): |
||
225 | return |
||
226 | |||
227 | if not input.isdigit(): |
||
228 | raise validation.ValidationError(len(input), 'Not a number') |
||
229 | |||
230 | enum = spec.get('enum') |
||
231 | try: |
||
232 | enum[int(input)] |
||
233 | except IndexError: |
||
234 | raise validation.ValidationError(len(input), 'Out of bounds') |
||
235 | |||
236 | def _construct_template(self): |
||
237 | self.template = u'{0}: ' |
||
238 | |||
239 | enum = self.spec.get('enum') |
||
240 | for index, value in enumerate(enum): |
||
241 | self.template += u'\n {} - {}'.format(index, value) |
||
242 | |||
243 | num_options = len(enum) |
||
244 | more = '' |
||
245 | if num_options > 3: |
||
246 | num_options = 3 |
||
247 | more = '...' |
||
248 | options = [str(i) for i in range(0, num_options)] |
||
249 | self.template += u'\nChoose from {}{}'.format(', '.join(options), more) |
||
250 | |||
251 | if 'default' in self.spec: |
||
252 | self.template += u' [{}]: '.format(enum.index(self.spec.get('default'))) |
||
253 | else: |
||
254 | self.template += u': ' |
||
255 | |||
256 | def _transform_response(self, response): |
||
257 | return self.spec.get('enum')[int(response)] |
||
258 | |||
259 | |||
260 | class ObjectReader(StringReader): |
||
261 | |||
262 | @staticmethod |
||
263 | def condition(spec): |
||
264 | return spec.get('type', None) == 'object' |
||
265 | |||
266 | def read(self): |
||
267 | prefix = u'{}.'.format(self.name) |
||
268 | |||
269 | result = InteractiveForm(self.spec.get('properties', {}), |
||
270 | prefix=prefix, reraise=True).initiate_dialog() |
||
271 | |||
272 | return result |
||
273 | |||
274 | |||
275 | class ArrayReader(StringReader): |
||
276 | @staticmethod |
||
277 | def condition(spec): |
||
278 | return spec.get('type', None) == 'array' |
||
279 | |||
280 | @staticmethod |
||
281 | def validate(input, spec): |
||
282 | if not input and (not spec.get('required', None) or spec.get('default', None)): |
||
283 | return |
||
284 | |||
285 | for m in re.finditer(r'[^, ]+', input): |
||
286 | index, item = m.start(), m.group() |
||
287 | try: |
||
288 | StringReader.validate(item, spec.get('items', {})) |
||
289 | except validation.ValidationError as e: |
||
290 | raise validation.ValidationError(index, str(e)) |
||
291 | |||
292 | def read(self): |
||
293 | item_type = self.spec.get('items', {}).get('type', 'string') |
||
294 | |||
295 | if item_type not in ['string', 'integer', 'number', 'boolean']: |
||
296 | message = 'Interactive mode does not support arrays of %s type yet' % item_type |
||
297 | raise ReaderNotImplemented(message) |
||
298 | |||
299 | result = super(ArrayReader, self).read() |
||
300 | |||
301 | return result |
||
302 | |||
303 | def _construct_template(self): |
||
304 | self.template = u'{0} (comma-separated list)' |
||
305 | |||
306 | if 'default' in self.spec: |
||
307 | self.template += u' [{default}]: '.format(default=','.join(self.spec.get('default'))) |
||
308 | else: |
||
309 | self.template += u': ' |
||
310 | |||
311 | def _transform_response(self, response): |
||
312 | return [item.strip() for item in response.split(',')] |
||
313 | |||
314 | |||
315 | class ArrayObjectReader(StringReader): |
||
316 | @staticmethod |
||
317 | def condition(spec): |
||
318 | return spec.get('type', None) == 'array' and spec.get('items', {}).get('type') == 'object' |
||
319 | |||
320 | def read(self): |
||
321 | results = [] |
||
322 | properties = self.spec.get('items', {}).get('properties', {}) |
||
323 | message = '~~~ Would you like to add another item to "%s" array / list?' % self.name |
||
324 | |||
325 | is_continue = True |
||
326 | index = 0 |
||
327 | while is_continue: |
||
328 | prefix = u'{name}[{index}].'.format(name=self.name, index=index) |
||
329 | results.append(InteractiveForm(properties, |
||
330 | prefix=prefix, |
||
331 | reraise=True).initiate_dialog()) |
||
332 | |||
333 | index += 1 |
||
334 | if Question(message, {'default': 'y'}).read() != 'y': |
||
335 | is_continue = False |
||
336 | |||
337 | return results |
||
338 | |||
339 | |||
340 | class ArrayEnumReader(EnumReader): |
||
341 | def __init__(self, name, spec, prefix=None): |
||
342 | self.items = spec.get('items', {}) |
||
343 | |||
344 | super(ArrayEnumReader, self).__init__(name, spec, prefix) |
||
345 | |||
346 | @staticmethod |
||
347 | def condition(spec): |
||
348 | return spec.get('type', None) == 'array' and 'enum' in spec.get('items', {}) |
||
349 | |||
350 | @staticmethod |
||
351 | def validate(input, spec): |
||
352 | if not input and (not spec.get('required', None) or spec.get('default', None)): |
||
353 | return |
||
354 | |||
355 | for m in re.finditer(r'[^, ]+', input): |
||
356 | index, item = m.start(), m.group() |
||
357 | try: |
||
358 | EnumReader.validate(item, spec.get('items', {})) |
||
359 | except validation.ValidationError as e: |
||
360 | raise validation.ValidationError(index, str(e)) |
||
361 | |||
362 | def _construct_template(self): |
||
363 | self.template = u'{0}: ' |
||
364 | |||
365 | enum = self.items.get('enum') |
||
366 | for index, value in enumerate(enum): |
||
367 | self.template += u'\n {} - {}'.format(index, value) |
||
368 | |||
369 | num_options = len(enum) |
||
370 | more = '' |
||
371 | if num_options > 3: |
||
372 | num_options = 3 |
||
373 | more = '...' |
||
374 | options = [str(i) for i in range(0, num_options)] |
||
375 | self.template += u'\nChoose from {}{}'.format(', '.join(options), more) |
||
376 | |||
377 | if 'default' in self.spec: |
||
378 | default_choises = [str(enum.index(item)) for item in self.spec.get('default')] |
||
379 | self.template += u' [{}]: '.format(', '.join(default_choises)) |
||
380 | else: |
||
381 | self.template += u': ' |
||
382 | |||
383 | def _transform_response(self, response): |
||
384 | result = [] |
||
385 | |||
386 | for i in (item.strip() for item in response.split(',')): |
||
387 | if i: |
||
388 | result.append(self.items.get('enum')[int(i)]) |
||
389 | |||
390 | return result |
||
391 | |||
392 | |||
393 | class InteractiveForm(object): |
||
394 | readers = [ |
||
395 | EnumReader, |
||
396 | BooleanReader, |
||
397 | NumberReader, |
||
398 | IntegerReader, |
||
399 | ObjectReader, |
||
400 | ArrayEnumReader, |
||
401 | ArrayObjectReader, |
||
402 | ArrayReader, |
||
403 | SecretStringReader, |
||
404 | StringReader |
||
405 | ] |
||
406 | |||
407 | def __init__(self, schema, prefix=None, reraise=False): |
||
408 | self.schema = schema |
||
409 | self.prefix = prefix |
||
410 | self.reraise = reraise |
||
411 | |||
412 | def initiate_dialog(self): |
||
413 | result = {} |
||
414 | |||
415 | try: |
||
416 | for field in self.schema: |
||
417 | try: |
||
418 | result[field] = self._read_field(field) |
||
419 | except ReaderNotImplemented as e: |
||
420 | print('%s. Skipping...' % str(e)) |
||
421 | except DialogInterrupted: |
||
422 | if self.reraise: |
||
423 | raise |
||
424 | print('Dialog interrupted.') |
||
425 | |||
426 | return result |
||
427 | |||
428 | def _read_field(self, field): |
||
429 | spec = self.schema[field] |
||
430 | |||
431 | reader = None |
||
432 | |||
433 | for Reader in self.readers: |
||
434 | if Reader.condition(spec): |
||
435 | reader = Reader(field, spec, prefix=self.prefix) |
||
436 | break |
||
437 | |||
438 | if not reader: |
||
439 | raise ReaderNotImplemented('No reader for the field spec') |
||
440 | |||
441 | try: |
||
442 | return reader.read() |
||
443 | except KeyboardInterrupt: |
||
444 | raise DialogInterrupted() |
||
445 | |||
446 | |||
447 | class Question(StringReader): |
||
448 | def __init__(self, message, spec=None): |
||
449 | if not spec: |
||
450 | spec = {} |
||
451 | |||
452 | super(Question, self).__init__(message, spec) |
||
453 |
It is generally discouraged to redefine built-ins as this makes code very hard to read.