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(' ', ' ')
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