Test Failed
Push — master ( e380d0...f5671d )
by W
02:58
created

st2client/st2client/utils/interactive.py (1 issue)

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
Bug Best Practice introduced by
This seems to re-define the built-in range.

It is generally discouraged to redefine built-ins as this makes code very hard to read.

Loading history...
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