1#!/usr/bin/env python3
  2
  3"""Utilities for displaying Python objects in nice ways."""
  4
  5import sys
  6import math
  7import operator
  8
  9from io import StringIO
 10import itertools
 11from datetime import datetime, timedelta
 12
 13from chart.common import traits
 14from chart.project import settings
 15from chart.common.util import round_sf
 16
 17# minimum value considered valid before the value is adjusted to 0
 18# Take care of AMSUA1 calibrations such as <coeff>1.024858E-14</coeff>
 19EPSILON_VALUE = 1e-18
 20
 21try:
 22    import fabulous
 23    import fabulous.color
 24
 25except ImportError:
 26    fabulous = None
 27
 28if settings.COLOUR_THEME is None or fabulous is None:
 29    theme = None
 30
 31elif settings.COLOUR_THEME == '8bit':
 32    theme = '8bit'
 33
 34elif settings.COLOUR_THEME == 'pastels':
 35    theme = 'pastels'
 36    palette = {
 37        'base00': '#151515',
 38        'base01': '#202020',
 39        'base02': '#303030',
 40        'base03': '#505050',
 41        'base03.5': '#909090',
 42        'base04': '#b0b0b0',
 43        'base05': '#d0d0d0',
 44        'base06': '#e0e0e0',
 45        'base07': '#f5f5f5',
 46        'base08': '#ac4142',
 47        'base09': '#d28445',
 48        'base0A': '#f4bf75',
 49        'base0B': '#90a959',
 50        'base0C': '#75b5aa',
 51        'base0D': '#6a9fb5',
 52        'base0E': '#aa759f',
 53        'base0F': '#8f5536',
 54        }
 55
 56else:
 57    theme = None
 58
 59
 60# def col(text, fg, bg):
 61    # pass
 62
 63
 64def markup(text, style):
 65    """Colour `text` as appropriate for `style` type text.
 66    Args:
 67        `text` (str): String to be marked up.
 68        `style` (str): See list below.
 69
 70    border
 71    failed
 72    success
 73    timeout
 74    <none>
 75    retry
 76    in_progress
 77    running (?)
 78    terminated
 79    heading
 80    """
 81
 82    if theme is None:
 83        return text
 84
 85    elif theme == '8bit':
 86        return text
 87
 88    elif theme == 'pastels':
 89        if style == 'border':
 90            return fabulous.color.bg256(palette['base03'], text)
 91
 92        elif style == 'FAILED':
 93            return str(fabulous.color.fg256(palette['base08'], text))
 94
 95        elif style == 'IN_PROGRESS':
 96            return str(fabulous.color.fg256(palette['base0C'], text))
 97
 98        elif style == 'COMPLETED':
 99            return str(fabulous.color.fg256(palette['base0B'], text))
100
101        elif style == 'activity':
102            return str(fabulous.color.bold(fabulous.color.fg256(palette['base06'], text)))
103
104        elif style == 'id':
105            return str(fabulous.color.fg256(palette['base0F'], text))
106
107        else:
108            return text
109
110    else:
111        return text
112
113
114#
115# Numerical print functions
116#
117
118def float_to_str(val, sf, always_float=False):
119    """Represent `val` as a string.
120    It will be rounded to `sf` significant figures if a float, or written as an integer without
121    decimal point if an integer value.
122    If `always_float` is set any integer values will be recorded as floats i.e. 5 -> "5.0".
123    """
124
125    if val is None:
126        return 'None'
127
128    elif math.isnan(val):
129        return 'NaN'
130
131    # adjust to 0 if the value is sufficiently small
132    if abs(val) <= EPSILON_VALUE:
133        return '0.0'
134
135    elif math.isinf(val):
136        return 'inf'
137
138    else:
139        rounded = round_sf(val, sf)
140
141    if float(int(rounded)) == rounded:
142        if always_float:
143            return str(int(rounded)) + '.0'
144
145        else:
146            # integral values are written as '5' not '5.0'
147            return str(int(rounded))
148
149    else:
150        return str(rounded)
151
152#
153# Date / time print functions
154#
155
156
157def show_date(obj):
158    """Display only the date component of a datetime object."""
159    return str(obj.date())
160
161
162def show_time_ms(obj):
163    """Display a datetime to millisecond accuracy."""
164    return '{coarse}.{fine:03d}'.format(coarse=obj.strftime('%Y-%m-%d %H:%M:%S'),
165                                        fine=int(obj.microsecond/1000))
166
167
168def show_time_us(obj):
169    """Display a datetime to microsecond accuracy."""
170    return '{coarse}.{fine:06d}'.format(coarse=obj.strftime('%Y-%m-%d %H:%M:%S'),
171                                        fine=int(obj.microsecond))
172
173def show_time(obj):
174    """Display a datetime to second accuracy."""
175    return obj.strftime('%Y-%m-%d %H:%M:%S')
176
177
178def show_time_m(obj):
179    """Display a datetime to minute accuracy."""
180
181    return obj.strftime('%Y-%m-%d %H:%M')
182
183
184def show_timedelta(obj, fmt='{days}d {hours:02}:{minutes:02}:{seconds:02}.{ms:03}', html=False):
185    """Convert a timedelta object to a readable string.
186    Note this can produce a counter-intuitive result with negative timedeltas i.e.:
187
188    >>> show_timedelta(timedelta(days=-1, hours=-1))
189    '-2d 23:00:00.000'
190    """
191    days = obj.days
192    hours = obj.seconds // 3600
193    minutes = (obj.seconds % 3600) // 60
194    seconds = obj.seconds % 60
195    ms = obj.microseconds // 1000
196
197    # res = ''
198    # if days > 0:
199    #     res = str(days) + 'd'
200
201    # if days > 0 or hours > 0:
202    #     res += str(hours) + 'h'
203
204    # if days > 0 or hours > 0 or minutes > 0:
205    #     res += str(minutes) + 'm'
206
207    # if ms == 0:
208    #     res += str(seconds) + 's'
209    # else:
210    #     res += str(seconds) + '.' + str(ms) + 's'
211
212    result = fmt.format(days=days, hours=hours, minutes=minutes, seconds=seconds, ms=ms)
213
214    if html:
215        return result.replace(' ', '&nbsp;')
216
217    else:
218        return result
219
220
221def show_timedelta_relaxed(obj):
222    """Display a timedelta object in an informal way
223
224    >>> show_timedelta_relaxed(timedelta(0, 3602))
225    '1 hour 2 seconds'
226
227    Note, a show_timedelta_very_relaxed() function could say "One hour and two seconds"
228    or even "About one hour"
229    """
230
231    days = obj.days
232    hours = obj.seconds // 3600
233    minutes = (obj.seconds % 3600) // 60
234    seconds = obj.seconds % 60
235    ms = obj.microseconds // 1000
236
237    def plural(value, unit, plural='s'):
238        """Return plural of `value` with units.
239
240        >>> plural(5, 'minute')
241        '5 minutes'
242
243        >>> plural(1, 'satelite')
244        '1 satelite'
245
246        """
247
248        # return '' if value == 1 else 's'
249        if value == 0:
250            return ''
251
252        elif value == 1:
253            return ' 1 ' + unit
254
255        else:
256            return ' {value} {unit}{plural}'.format(value=value, unit=unit, plural=plural)
257
258    res = ''
259    res += plural(days, 'day')
260    res += plural(hours, 'hour')
261    res += plural(minutes, 'minute')
262    res += plural(seconds, 'second')
263    res += plural(ms, 'ms', '')
264    return res.strip()
265
266
267def show_timedelta_s(obj):
268    """Display a timedelta to seconds accuracy."""
269    if obj is None:
270        return 'None'
271
272    return show_timedelta(timedelta(obj.days,
273                              obj.seconds))
274
275
276def show_timedelta_m(obj):
277    """Display a timedelta to minute accuracy."""
278    return show_timedelta(timedelta(obj.days,
279                                    obj.seconds - obj.seconds % 60))
280
281
282def timedelta_zero_us(obj):
283    """Set the microseconds part of a timedelta object to zero."""
284    return timedelta(obj.days,
285                     obj.seconds,
286                     int(obj.microseconds / 1000) * 1000)
287
288
289def show_timedelta_ms(obj):
290    """Display a timedelta to millisecond accuracy."""
291    return show_obj(timedelta_zero_us(obj))
292
293
294def show_obj(obj):
295    """Display `obj` nicely, especially if it is a timedelta
296
297    >>> show_obj(timedelta(days=4,minutes=3,seconds=1))
298    '4d 00:03:01.000'
299    """
300    if isinstance(obj, datetime):
301        return obj.strftime('%Y-%m-%d %H:%M:%S') + '.' + '%06d' % (obj.microsecond,)
302
303    elif isinstance(obj, timedelta):
304        return show_timedelta(obj)
305
306    else:
307        return str(obj)
308
309
310def hexstring(s):
311    """Convert input buffer into a hex-format string
312
313    >>> hexstring('hello')
314    '0x68,0x65,0x6C,0x6C,0x6F'
315
316    """
317
318    if isinstance(s, str):
319        return ','.join(['0x%02X' % (ord(s[i])) for i in range(len(s))])
320
321    elif isinstance(s, bytes):
322        return ','.join(['0x%02X' % (s[i]) for i in range(len(s))])
323
324    else:
325        return ','.join(['0x%02X' % (s[i]) for i in range(len(s))])
326
327
328def to_str(obj):
329    """Display any object including Unicode as ASCII."""
330    if isinstance(obj, str):
331        # return obj.encode('ISO-8859-1')
332        return obj
333
334    elif obj is None:
335        return ''
336
337    else:
338        return str(obj)
339
340
341class Table:
342    r"""Create a table and display it to either terminal (with nicely laid out columns
343    and coloured text), or to CSV, or to HTML with optional styles.
344
345    Rows are added with the append() function which accepts a list of either strings
346    or dictionaries containing some mixture of:
347        'text': The actual content
348        'colour': Text foreground colour
349        'bgcolour': Text background colour
350        'style': Content of HTML style attribute
351        'class': Content of HTML class attribute
352
353    A simple table to terminal:
354
355    >> t = Table(title='A sample table', headings=('one','two','three'))
356    >> t.append((1, 2, 3))
357    >> t.append((11,\
358                  {'text': '12',\
359                   'colour': 'red',\
360                   'bgcolour': '#b0c0d0',\
361                   'style': 'font-weight:bold',\
362                   'class':'nok'},\
363                  13))
364    >> t.append((21, 22, 33))
365    >> t.write()
366
367
368`A sample table`
369`--------------`
370
371    ===   ===   =====
372    one   two   three
373    ===   ===   =====
374    1     2     3
375    11    12    13
376    21    22    33
377    ===   ===   =====
378
379    An HTML table with multiple headers and a column span:
380    >> t = Table()
381    >> t.append_headers(('One', 'Two', {'merge': 'left'}, 'Three'))
382    >> t.append_headers(('^', 'Two and a third', 'Two and two thirds', '^'))
383    >> t.append((1, 2.33, 2.67, 3))
384    >> t.write_html()
385    """
386    def __init__(self,
387                 title=None,
388                 title_underline=' ',
389                 cssclass='table',
390                 headings=None,
391                 indent='',
392                 # header_overline,
393                 header_underline='-',
394                 header_blanklines=0,
395                 header_split=' ',
396                 # trailer_underline,
397                 column_split=' ',
398                 cross=' ',
399                 multiline=False,
400                 coda='\n',
401                 # border_fg_col=None,
402                 old_ie_support=True):
403                 # terse_spans=True):
404        r"""Constructor.
405
406        Args:
407            `headings` (list of str): Column headings
408
409            `cssclass` (str): For HTML output only set the style attribute for the <table> element.
410
411            `title` (str): Table title (ascii) or caption (html).
412
413            `title_underline` (str): Gap between title and headings. Set to ' ' for a blank
414                line, None for nothing.
415
416            `indent` (str): string to prefix each output line.
417
418            `header_underline` (str): Char to use to underline the header rows (ascii only).
419
420            `header_blankline` (int): Number of empty lines underneath the header(s) (ascii only).
421
422            `header_split` (str): String to separate entries on header rows.
423
424            `column_split` (str): column separator character (ascii output only).
425
426            `cross` (str): Char to use where the header underline intersects the column split
427                (ascii only).
428
429            `multiline` (bool): for HTML output only, replace '. ' sequences with <br>
430
431            `terse_spans` (bool): Allow '<', '>', '^', 'v' as the cell contents to indicate
432                the cell is empty and should be merged with the neighbouring cell.
433                (otherwise {'merge': 'left'} etc must be used
434                Singles can be escaped with '\' to use the literal character as the cell text.
435
436            `coda` (str): Text to print at the end of the table.
437
438            `old_ie_support` (bool): Include a hack to force older (<=8) versions of
439                Internet Explorer to show borders around empty table cells.
440        """
441
442        self.title = title
443        self.title_underline = title_underline
444        self.cssclass = cssclass
445        self.indent = indent
446        self.rows = []  # do not change or rename this. it should always count the data
447        # (non-header) rows
448        self.meta_rows = []
449        self.column_lens = None
450        self.header_underline = header_underline
451        self.header_blanklines = header_blanklines
452        self.column_split = column_split
453        self.header_split = header_split
454        self.cross = cross
455        self.multiline = multiline
456        self.coda = coda
457        self.old_ie_support = old_ie_support
458        self.headings = 0
459        if headings is not None:
460            self.append_headers(headings)
461
462    def update_column_lens(self, row):
463        """Update columns lengths for table, given new `row`."""
464
465        if self.column_lens is None:
466            self.column_lens = [0] * len(row)
467
468        # str(j) is wrong and may corrupt the tables
469        # This actually needs a 2-pass system where pass 1 just converts everything
470        # to text and pass 2 chooses the column widths
471        self.column_lens = [max(i, len(str(j))) for i, j in zip(self.column_lens, row)]
472
473    def append(self, row):
474        """Add a list of objects to the list of rows.
475        Row is a list of either strings or dictionaries.
476        If dictionaries, each must include a 'text' element containing the actual text,
477        and may also include:
478
479         - `tag` (str): 'td' or 'th' as the css cell element tag
480         - `cssclass` (str): css class name
481         - `em` (bool): Emphasis
482         - `markup`: A string saying what kind of thing the contents are. See `markup()` function
483             for current values. Currently for ascii output only.
484         - `tooltip` (str): a tooltip which shows up when mouse hovered over.
485
486        I.e. t.append(['a',
487                       {'text': 'b', 'em': True, 'tag': 'th'}])
488        ."""
489
490        str_row = []
491        meta_row = []
492
493        for cell in row:
494            if isinstance(cell, dict):
495                str_row.append(cell['text'])
496                meta_row.append(cell)
497
498            else:
499                str_row.append(cell)
500                meta_row.append(None)
501
502        if self.multiline:
503            for i in range(len(row)):
504                row[i] = row[i].replace('. ', '.<br>')
505
506        self.rows.append(str_row)
507        self.meta_rows.append(meta_row)
508
509    def append_headers(self, headings):
510        """Add a row of header cells."""
511
512        str_row = []
513        meta_row = []
514
515        for cell in headings:
516            if isinstance(cell, dict):
517                # a header cell might just be: {'merge': 'left'} w/o text
518                str_row.append(to_str(cell.get('text')))
519                meta_row.append(cell)
520
521            else:
522                str_row.append(to_str(cell))
523                meta_row.append({})
524
525            meta_row[-1]['tag'] = 'th'
526
527        self.rows.append(str_row)
528        self.meta_rows.append(meta_row)
529        self.update_column_lens(str_row)
530        self.headings += 1
531
532    def write(self, include_headings=True, target=sys.stdout):
533        """Write table as plain text to `target`.
534
535        Args:
536            `include_headings`: Show title and header lines
537            `target`: Text stream to output to
538        """
539        for row in self.rows:
540            self.update_column_lens(row)
541
542        if self.title is not None and include_headings:
543            target.write('{indent}{title}:\n{underline}'.format(
544                    indent=self.indent,
545                    title=self.title,
546                    underline='' if self.title_underline is None else '\n'))
547
548        for cc, (row, meta_row) in enumerate(zip(self.rows, self.meta_rows)):
549            if cc < self.headings:
550                # this is a header row
551                headings = traits.to_term(
552                    self.indent + self._format_row(row, meta_row, self.header_split))
553
554                if include_headings:
555                    target.write(headings)
556
557            if (cc == self.headings and
558                self.header_underline is not None and
559                self.header_split is not None and
560                self.headings != 0 and
561                include_headings):
562                # build a string to use as the underline to the header(s)
563                underline = self.cross.join(
564                    self.header_underline * c for c in self.column_lens)
565                target.write('{indent}{underline}\n'.format(
566                        indent=self.indent,
567                        underline=markup(underline, 'border')))
568
569                for _ in range(self.header_blanklines):
570                    target.write(self.indent + ' ' * len(underline) + '\n')
571
572            if cc >= self.headings:
573                # write a normal row
574                target.write(traits.to_term(
575                        self.indent + self._format_row(row, meta_row, self.column_split)))
576
577        if include_headings:
578            target.write(self.coda)
579
580    def _format_row(self, row, meta_row, separator):
581        """Render row for ascii output.
582
583        Args:
584            `row` (list of str): Plain text contents.
585            `meta_row` (list of None or dict): Additional markup such as 'tag', 'markup'.
586            `separator` (str): Column delimiter
587
588        Returns:
589            str
590        """
591
592        items = []
593        for i, (cell, meta) in enumerate(zip(row, meta_row)):
594            cell = to_str(cell)
595            # right hand padding up to column widget
596            if i < len(row) - 1:
597                padding = ' ' * (self.column_lens[i] - len(cell))
598                # items.append(row[i].ljust(self.column_lens[i]))
599
600            else:
601                padding = ''
602                # items.append(row[i])
603
604            if meta is not None:
605                if 'markup' in meta:
606                    cell = markup(cell, meta['markup'])
607            # colour codes
608            # if (meta_row is not None and
609                # meta_row[i] is not None and
610                # 'colour' in meta_row[i] and
611                # meta_row[i]['colour'] is not None and
612                # term is not None):
613
614                # cell = getattr(term, meta_row[i]['colour'])(cell)
615
616            # cell done
617            # This is the real conversion to text - should use a proper
618            # converter or even Traits module
619            items.append(str(cell) + padding)
620
621        return separator.join(items) + '\n'
622
623    def write_csv(self, target=sys.stdout):
624        """Write table in CSV format to `target`."""
625
626        import csv
627        f = csv.writer(target, dialect='excel')
628        # if self.headings is not None:
629            # f.writerow(self.headings)
630
631        # headings are not special
632
633        for row in self.rows:
634            f.writerow(row)
635
636    def write_html(self, target=sys.stdout):
637        """Write out the table as HTML."""
638        if self.cssclass is None:
639            target.write('<table>\n')
640
641        else:
642            target.write('<table class=\'{cssclass}\'>\n'.format(cssclass=self.cssclass))
643
644        if self.title is not None:
645            target.write('<caption>{caption}</caption>'.format(caption=self.title))
646
647        if self.headings > 0:
648            target.write('<thead>')
649
650        for cc, (row, meta_row) in enumerate(zip(self.rows, self.meta_rows)):
651            if cc > 0 and cc == self.headings:
652                # end of headings
653                target.write('</thead><tbody>')
654
655            target.write(self._format_html_row(row, meta_row) + '\n')
656
657        # target.write('\n')
658        if self.headings > 0:
659            target.write('</tbody>')
660
661        target.write('</table>\n')
662
663    def _format_html_row(self, row, meta_row):
664        """Convert a row of cells and meta cells to an HTML fragment."""
665        items = []
666        for cell, meta in zip(row, meta_row):
667            styles = []
668            cssclass = ''
669            tooltip = ''
670            pre_markup = ''
671            post_markup = ''
672            tag = 'td'
673
674            if meta is not None:
675                if 'style' in meta:
676                    # style = ' style="{style}"'.format(style=meta['style'])
677                    styles.append(meta['style'])
678
679                if 'bgcolour' in meta:
680                    styles.append('background-color:{c}'.format(c=meta['bgcolour']))
681
682                if 'colour' in meta:
683                    styles.append('color:{c}'.format(c=meta['colour']))
684
685                if 'class' in meta:  # I think we only use cssclass
686                    cssclass = ' class="{cssclass}"'.format(cssclass=meta['class'])
687
688                if 'cssclass' in meta:
689                    cssclass = ' class="{cssclass}"'.format(cssclass=meta['cssclass'])
690
691                if 'tooltip' in meta:
692                    tooltip = ' title="{title}"'.format(title=meta['tooltip'])
693
694                if 'tag' in meta:
695                    tag = meta['tag']
696
697                if 'em' in meta and meta['em'] is True:
698                    pre_markup += '<em>'
699                    post_markup += '</em>'
700
701                if 'url' in meta:
702                    pre_markup = '<a class="anchor" href="{url}">{markup}'.format(
703                        url=meta['url'], markup=pre_markup)
704                    post_markup += '</a>'
705
706            # IE8 strips borders from white-space only or empty table cells
707            # if self.old_ie_support and (cell.isspace() or len(cell)) == 0:
708                # cell = '<hr class="novis">'
709
710            try:
711                if len(styles) > 0:
712                    style = ' style=\'{s}\''.format(s=';'.join(s for s in styles))
713
714                else:
715                    style = ''
716                items.append(
717                    '<{tag}{style}{cssclass}{title}>{markup}{cell}{unmarkup}</{tag}>'.format(
718                        cell=traits.to_htmlstr(cell),
719                        style=style,
720                        cssclass=cssclass,
721                        title=tooltip,
722                        tag=tag,
723                        markup=pre_markup,
724                        unmarkup=post_markup))
725            except UnicodeDecodeError:
726                # APT-ANOMALY 2013-01-28
727                items.append(
728                    '<{tag}{style}{cssclass}{title}>{markup}{cell}{unmarkup}</{tag}>'.format(
729                        cell=traits.to_htmlstr(cell.decode('latin')),
730                        style=style,
731                        cssclass=cssclass,
732                        title=tooltip,
733                        tag=tag,
734                        markup=pre_markup,
735                        unmarkup=post_markup))
736
737        return '<tr>{items}</tr>'.format(items=''.join(items))
738
739    def reverse(self):
740        """Reverse the order of the table rows."""
741        header = self.rows[:self.headings]
742        body = self.rows[self.headings:]
743        body.reverse()
744        self.rows = header + body
745
746    def to_str(self):
747        """Return the entire table as a string."""
748        res = StringIO()
749        self.write(res)
750        return res.getvalue()
751
752    def to_html_str(self):
753        """Render the table as HTML and return as a string."""
754        res = StringIO()
755        self.write_html(res)
756        return res.getvalue()
757
758    def rowcount(self):
759        """Return the number of non-header rows in the table."""
760        return len(self.rows) - self.headings
761
762    def sort_alphabetically(self, column, reverse=False):
763        """Sort table by column alphabetically (lexically)."""
764
765        header = self.rows[:self.headings]
766        body = self.rows[self.headings:]
767        sortedbody = sorted(body, key=operator.itemgetter(column), reverse=reverse)
768        self.rows = header + sortedbody
769
770    def _key(self, column):
771        """To allow sorting numerically."""
772
773        def imp(row):
774            return float(row[column])
775
776        return imp
777
778    def sort_numerically(self, column, reverse=False):
779        """Sort table by column numerically (smallest first)."""
780
781        header = self.rows[:self.headings]
782        body = self.rows[self.headings:]
783        sortedbody = sorted(body, key=self._key(column), reverse=reverse)
784        self.rows = header + sortedbody