1"""
2    SoftLayer.formatting
3    ~~~~~~~~~~~~~~~~~~~~
4    Provider classes and helper functions to display output onto a command-line.
5
6"""
7# pylint: disable=E0202, consider-merging-isinstance, arguments-differ, keyword-arg-before-vararg
8import collections
9import json
10import os
11
12import click
13
14# If both PTable and prettytable are installed, its impossible to use the new version
15try:
16    from prettytable import prettytable
17except ImportError:
18    import prettytable
19
20from SoftLayer.CLI import exceptions
21from SoftLayer import utils
22
23FALSE_VALUES = ['0', 'false', 'FALSE', 'no', 'False']
24
25
26def format_output(data, fmt='table'):  # pylint: disable=R0911,R0912
27    """Given some data, will format it for console output.
28
29    :param data: One of: String, Table, FormattedItem, List, Tuple,
30                 SequentialOutput
31    :param string fmt (optional): One of: table, raw, json, python
32    """
33    if isinstance(data, str):
34        if fmt in ('json', 'jsonraw'):
35            return json.dumps(data)
36        return data
37
38    # responds to .prettytable()
39    if hasattr(data, 'prettytable'):
40        if fmt == 'table':
41            return str(format_prettytable(data))
42        elif fmt == 'raw':
43            return str(format_no_tty(data))
44
45    # responds to .to_python()
46    if hasattr(data, 'to_python'):
47        if fmt == 'json':
48            return json.dumps(
49                format_output(data, fmt='python'),
50                indent=4,
51                cls=CLIJSONEncoder)
52        elif fmt == 'jsonraw':
53            return json.dumps(format_output(data, fmt='python'),
54                              cls=CLIJSONEncoder)
55        elif fmt == 'python':
56            return data.to_python()
57
58    # responds to .formatted
59    if hasattr(data, 'formatted'):
60        if fmt == 'table':
61            return data.formatted
62
63    # responds to .separator
64    if hasattr(data, 'separator'):
65        output = [format_output(d, fmt=fmt) for d in data if d]
66        return str(SequentialOutput(data.separator, output))
67
68    # is iterable
69    if isinstance(data, list) or isinstance(data, tuple):
70        output = [format_output(d, fmt=fmt) for d in data]
71        if fmt == 'python':
72            return output
73        return format_output(listing(output, separator=os.linesep))
74
75    # fallback, convert this odd object to a string
76    return data
77
78
79def format_prettytable(table):
80    """Converts SoftLayer.CLI.formatting.Table instance to a prettytable."""
81    for i, row in enumerate(table.rows):
82        for j, item in enumerate(row):
83            table.rows[i][j] = format_output(item)
84
85    ptable = table.prettytable()
86    ptable.hrules = prettytable.FRAME
87    ptable.horizontal_char = '.'
88    ptable.vertical_char = ':'
89    ptable.junction_char = ':'
90    return ptable
91
92
93def format_no_tty(table):
94    """Converts SoftLayer.CLI.formatting.Table instance to a prettytable."""
95
96    for i, row in enumerate(table.rows):
97        for j, item in enumerate(row):
98            table.rows[i][j] = format_output(item, fmt='raw')
99    ptable = table.prettytable()
100
101    for col in table.columns:
102        ptable.align[col] = 'l'
103
104    ptable.hrules = prettytable.NONE
105    ptable.border = False
106    ptable.header = False
107    ptable.left_padding_width = 0
108    ptable.right_padding_width = 2
109    return ptable
110
111
112def mb_to_gb(megabytes):
113    """Converts number of megabytes to a FormattedItem in gigabytes.
114
115    :param int megabytes: number of megabytes
116    """
117    return FormattedItem(megabytes, "%dG" % (float(megabytes) / 1024))
118
119
120def b_to_gb(_bytes):
121    """Converts number of bytes to a FormattedItem in gigabytes.
122
123    :param int _bytes: number of bytes
124    """
125    return FormattedItem(_bytes,
126                         "%.2fG" % (float(_bytes) / 1024 / 1024 / 1024))
127
128
129def gb(gigabytes):  # pylint: disable=C0103
130    """Converts number of gigabytes to a FormattedItem in gigabytes.
131
132    :param int gigabytes: number of gigabytes
133    """
134    return FormattedItem(int(float(gigabytes)) * 1024,
135                         "%dG" % int(float(gigabytes)))
136
137
138def blank():
139    """Returns a blank FormattedItem."""
140    return FormattedItem(None, '-')
141
142
143def listing(items, separator=','):
144    """Given an iterable return a FormattedItem which display the list of items
145
146        :param items: An iterable that outputs strings
147        :param string separator: the separator to use
148    """
149    return SequentialOutput(separator, items)
150
151
152def active_txn(item):
153    """Returns a FormattedItem describing the active transaction on a object.
154
155        If no active transaction is running, returns a blank FormattedItem.
156
157        :param item: An object capable of having an active transaction
158    """
159    return transaction_status(utils.lookup(item, 'activeTransaction'))
160
161
162def transaction_status(transaction):
163    """Returns a FormattedItem describing the given transaction.
164
165        :param item: An object capable of having an active transaction
166    """
167    if not transaction or not transaction.get('transactionStatus'):
168        return blank()
169
170    return FormattedItem(
171        transaction['transactionStatus'].get('name'),
172        transaction['transactionStatus'].get('friendlyName'))
173
174
175def tags(tag_references):
176    """Returns a formatted list of tags."""
177    if not tag_references:
178        return blank()
179
180    tag_row = []
181    for tag_detail in tag_references:
182        tag = utils.lookup(tag_detail, 'tag', 'name')
183        if tag is not None:
184            tag_row.append(tag)
185
186    return listing(tag_row, separator=', ')
187
188
189def confirm(prompt_str, default=False):
190    """Show a confirmation prompt to a command-line user.
191
192    :param string prompt_str: prompt to give to the user
193    :param bool default: Default value to True or False
194    """
195    if default:
196        default_str = 'y'
197        prompt = '%s [Y/n]' % prompt_str
198    else:
199        default_str = 'n'
200        prompt = '%s [y/N]' % prompt_str
201
202    ans = click.prompt(prompt, default=default_str, show_default=False)
203    if ans.lower() in ('y', 'yes', 'yeah', 'yup', 'yolo'):
204        return True
205
206    return False
207
208
209def no_going_back(confirmation):
210    """Show a confirmation to a user.
211
212    :param confirmation str: the string the user has to enter in order to
213                             confirm their action.
214    """
215    if not confirmation:
216        confirmation = 'yes'
217
218    prompt = ('This action cannot be undone! Type "%s" or press Enter '
219              'to abort' % confirmation)
220
221    ans = click.prompt(prompt, default='', show_default=False)
222    if ans.lower() == str(confirmation):
223        return True
224
225    return False
226
227
228class SequentialOutput(list):
229    """SequentialOutput is used for outputting sequential items.
230
231    The purpose is to de-couple the separator from the output itself.
232
233    :param separator str: string to use as a default separator
234    """
235
236    def __init__(self, separator=os.linesep, *args, **kwargs):
237        self.separator = separator
238        super().__init__(*args, **kwargs)
239
240    def to_python(self):
241        """returns itself, since it itself is a list."""
242        return self
243
244    def __str__(self):
245        return self.separator.join(str(x) for x in self)
246
247
248class CLIJSONEncoder(json.JSONEncoder):
249    """A JSON encoder which is able to use a .to_python() method on objects."""
250
251    def default(self, o):
252        """Encode object if it implements to_python()."""
253        if hasattr(o, 'to_python'):
254            return o.to_python()
255        return super().default(o)
256
257
258class Table(object):
259    """A Table structure used for output.
260
261    :param list columns: a list of column names
262    """
263
264    def __init__(self, columns, title=None):
265        duplicated_cols = [col for col, count
266                           in collections.Counter(columns).items()
267                           if count > 1]
268        if len(duplicated_cols) > 0:
269            raise exceptions.CLIAbort("Duplicated columns are not allowed: %s"
270                                      % ','.join(duplicated_cols))
271
272        self.columns = columns
273        self.rows = []
274        self.align = {}
275        self.sortby = None
276        self.title = title
277
278    def add_row(self, row):
279        """Add a row to the table.
280
281        :param list row: the row of string to be added
282        """
283        self.rows.append(row)
284
285    def to_python(self):
286        """Decode this Table object to standard Python types."""
287        # Adding rows
288        items = []
289        for row in self.rows:
290            formatted_row = [_format_python_value(v) for v in row]
291            items.append(dict(zip(self.columns, formatted_row)))
292        return items
293
294    def prettytable(self):
295        """Returns a new prettytable instance."""
296        table = prettytable.PrettyTable(self.columns)
297
298        if self.sortby:
299            if self.sortby in self.columns:
300                table.sortby = self.sortby
301            else:
302                msg = "Column (%s) doesn't exist to sort by" % self.sortby
303                raise exceptions.CLIAbort(msg)
304
305        if isinstance(self.align, str):
306            table.align = self.align
307        else:
308            # Required because PrettyTable has a strict setter function for alignment
309            for a_col, alignment in self.align.items():
310                table.align[a_col] = alignment
311
312        if self.title:
313            table.title = self.title
314        # Adding rows
315        for row in self.rows:
316            table.add_row(row)
317        return table
318
319
320class KeyValueTable(Table):
321    """A table that is oriented towards key-value pairs."""
322
323    def to_python(self):
324        """Decode this KeyValueTable object to standard Python types."""
325        mapping = {}
326        for row in self.rows:
327            mapping[row[0]] = _format_python_value(row[1])
328        return mapping
329
330
331class FormattedItem(object):
332    """This is an object that can be displayed as a human readable and raw.
333
334        :param original: raw (machine-readable) value
335        :param string formatted: human-readable value
336    """
337
338    def __init__(self, original, formatted=None):
339        self.original = original
340        if formatted is not None:
341            self.formatted = formatted
342        else:
343            self.formatted = self.original
344
345    def to_python(self):
346        """returns the original (raw) value."""
347        return self.original
348
349    def __str__(self):
350        """returns the formatted value."""
351        # If the original value is None, represent this as 'NULL'
352        if self.original is None:
353            return 'NULL'
354
355        try:
356            return str(self.original)
357        except UnicodeError:
358            return 'invalid'
359
360    def __repr__(self):
361        return 'FormattedItem(%r, %r)' % (self.original, self.formatted)
362
363    # Implement sorting methods.
364    # NOTE(kmcdonald): functools.total_ordering could be used once support for
365    # Python 2.6 is dropped
366    def __eq__(self, other):
367        return self.original == getattr(other, 'original', other)
368
369    def __lt__(self, other):
370        if self.original is None:
371            return True
372
373        other_val = getattr(other, 'original', other)
374        if other_val is None:
375            return False
376        return self.original < other_val
377
378    def __gt__(self, other):
379        return not (self < other or self == other)
380
381    def __le__(self, other):
382        return self < other or self == other
383
384    def __ge__(self, other):
385        return self >= other
386
387
388def _format_python_value(value):
389    """If the value has to_python() defined then return that."""
390    if hasattr(value, 'to_python'):
391        return value.to_python()
392    return value
393
394
395def iter_to_table(value):
396    """Convert raw API responses to response tables."""
397    if isinstance(value, list):
398        return _format_list(value)
399    if isinstance(value, dict):
400        return _format_dict(value)
401    return value
402
403
404def _format_dict(result):
405    """Format dictionary responses into key-value table."""
406
407    table = KeyValueTable(['name', 'value'])
408    table.align['name'] = 'r'
409    table.align['value'] = 'l'
410
411    for key, value in result.items():
412        value = iter_to_table(value)
413        table.add_row([key, value])
414
415    return table
416
417
418def _format_list(result):
419    """Format list responses into a table."""
420
421    if not result:
422        return result
423
424    new_result = [item for item in result if item]
425
426    if isinstance(new_result[0], dict):
427        return _format_list_objects(new_result)
428
429    table = Table(['value'])
430    for item in new_result:
431        table.add_row([iter_to_table(item)])
432    return table
433
434
435def _format_list_objects(result):
436    """Format list of objects into a table."""
437
438    all_keys = set()
439    for item in result:
440        if isinstance(item, dict):
441            all_keys = all_keys.union(item.keys())
442
443    all_keys = sorted(all_keys)
444    table = Table(all_keys)
445
446    for item in result:
447        if not item:
448            continue
449        values = []
450        for key in all_keys:
451            value = iter_to_table(item.get(key))
452            values.append(value)
453
454        table.add_row(values)
455
456    return table
457