diff frontends/src/jp/common.py @ 2298:276e546b7619

jp (common): new ansi_ljust, ansi_rjust and ansi_center command + table: - ansi_ljust, ansi_rjust and ansi_center are equivalent to string.ljust, rjust and center, but handle strings with ANSI escape codes - new Table class to handle tabular data and display them with ASCII borders/separations. - Table is quite flexible, filters can be applied to data, header can be shown or not, columns/headers can be left/right aligned. - table can be displayed without decoration to align text data, using display_blank helping method
author Goffi <goffi@goffi.org>
date Sun, 02 Jul 2017 20:09:21 +0200
parents 4bc9a2c2d6c9
children 07deebea71f3
line wrap: on
line diff
--- a/frontends/src/jp/common.py	Sun Jul 02 19:53:44 2017 +0200
+++ b/frontends/src/jp/common.py	Sun Jul 02 20:09:21 2017 +0200
@@ -19,8 +19,10 @@
 
 from sat_frontends.jp.constants import Const as C
 from sat.core.i18n import _
+from sat.core import exceptions
 from sat.tools.common import regex
 from sat.tools.common import uri
+from sat.tools.common.ansi import ANSI as A
 from sat.tools import config
 from ConfigParser import NoSectionError, NoOptionError
 import json
@@ -48,6 +50,26 @@
 METADATA_SUFF = '_metadata.json'
 
 
+def ansi_ljust(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansiRemove(s)
+    return s + u' ' * (width - len(cleaned))
+
+
+def ansi_center(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansiRemove(s)
+    diff = width - len(cleaned)
+    half = diff/2
+    return half * u' ' + s + (half + diff % 2) * u' '
+
+
+def ansi_rjust(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansiRemove(s)
+    return u' ' * (width - len(cleaned)) + s
+
+
 def getTmpDir(sat_conf, cat_dir, sub_dir=None):
     """Return directory used to store temporary files
 
@@ -474,3 +496,219 @@
             return pubsub_service, pubsub_node, pubsub_item, content_file_path, content_file_obj, metadata
         else:
             return pubsub_service, pubsub_node, pubsub_item, content_file_path, content_file_obj
+
+
+class Table(object):
+
+    def __init__(self, host, data, headers=None, filters=None):
+        """
+        @param data(list[list]): table data
+            all lines must have the same number of columns
+        @param headers(iterable[unicode], None): names/titles of the columns
+            if not None, must have same number of columns as data
+        @param filters(iterable[(callable, unicode)], None): values filters
+            the callable will get col value as argument and must return a string
+            if it's unicode, it will be used with .format and must countain u'{}' which will be replaced with the string
+            if not None, must have same number of columns as data
+        """
+        self.host = host
+        # headers are columns names/titles, can be None
+        self.headers = headers
+        # sizes fof columns without headers,
+        # headers may be larger
+        self.sizes = []
+        # rows countains one list per row with columns values
+        self.rows = []
+
+        size = None
+        for line in data:
+            new_row = []
+            for idx, value in enumerate(line):
+                if filters is not None and filters[idx] is not None:
+                    filter_ = filters[idx]
+                    if isinstance(filter_, basestring):
+                        col_value = filter_.format(value)
+                    else:
+                        col_value = filter_(value)
+                    # we count size without ANSI code as they will change length of the string
+                    # when it's mostly style/color changes.
+                    col_size = len(regex.ansiRemove(col_value))
+                else:
+                    col_value = unicode(value)
+                    col_size = len(col_value)
+                new_row.append(col_value)
+                if size is None:
+                    self.sizes.append(col_size)
+                else:
+                    self.sizes[idx] = max(self.sizes[idx], col_size)
+            if size is None:
+                size = len(new_row)
+                if headers is not None and len(headers) != size:
+                    raise exceptions.DataError(u'headers size is not coherent with rows')
+            else:
+                if len(new_row) != size:
+                    raise exceptions.DataError(u'rows size is not coherent')
+            self.rows.append(new_row)
+
+    @staticmethod
+    def readDictValues(data, keys, defaults=None):
+        if defaults is None:
+            defaults = {}
+        for key in keys:
+            try:
+                yield data[key]
+            except KeyError as e:
+                default = defaults.get(key)
+                if default is not None:
+                    yield default
+                else:
+                    raise e
+
+    @classmethod
+    def fromDict(cls, host, data, keys=None, headers=None, filters=None, defaults=None):
+        """Prepare a table to display it
+
+        the whole data will be read and kept into memory,
+        to be printed
+        @param data(list[dict[unicode, unicode]]): data to create the table from
+        @param keys(iterable[unicode], None): keys to get
+            if None, all keys will be used
+        @param headers(iterable[unicode], None): name of the columns
+            names must be in same order as keys
+        @param filters(dict[unicode, (callable,unicode)), None): filter to use on values
+            keys correspond to keys to filter, and value is a callable or unicode which
+            will get the value as argument and must return a string
+        @param defaults(dict[unicode, unicode]): default value to use
+            if None, an exception will be raised if not value is found
+        """
+        if keys is None and headers is not None:
+            # FIXME: keys are not needed with OrderedDict,
+            raise exceptions.DataError(u'You must specify keys order to used headers')
+        if keys is None:
+            keys = data[0].keys()
+        if headers is None:
+            headers = keys
+        filters = [filters.get(k) for k in keys]
+        return cls(host, (cls.readDictValues(d, keys, defaults) for d in data), headers, filters)
+
+    def _headers(self, head_sep, alignment=u'left', style=None):
+        """Render headers
+
+        @param head_sep(unicode): sequence to use as separator
+        @param alignment(unicode): how to align, can be left, center or right
+        @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply
+        """
+        headers = []
+        if isinstance(style, basestring):
+            style = [style]
+        for idx, header in enumerate(self.headers):
+            size = self.sizes[idx]
+            if alignment == u'left':
+                rendered = header[:size].ljust(size)
+            elif alignment == u'center':
+                rendered = header[:size].center(size)
+            elif alignment == u'right':
+                rendered = header[:size].rjust(size)
+            else:
+                raise exceptions.InternalError(u'bad alignment argument')
+            if style:
+                args = style + [rendered]
+                rendered = A.color(*args)
+            headers.append(rendered)
+        return head_sep.join(headers)
+
+    def display(self,
+                head_alignment = u'left',
+                columns_alignment = u'left',
+                head_style = None,
+                show_header=True,
+                show_borders=True,
+                col_sep=u' │ ',
+                top_left=u'┌',
+                top=u'─',
+                top_sep=u'─┬─',
+                top_right=u'┐',
+                left=u'│',
+                right=None,
+                head_sep=None,
+                head_line=u'┄',
+                head_line_left=u'├',
+                head_line_sep=u'┄┼┄',
+                head_line_right=u'┤',
+                bottom_left=u'└',
+                bottom=None,
+                bottom_sep=u'─┴─',
+                bottom_right=u'┘'
+                ):
+        """Print the table
+
+        @param show_header(bool): True if header need no be shown
+        @param show_borders(bool): True if borders need no be shown
+        @param head_alignment(unicode): how to align headers, can be left, center or right
+        @param columns_alignment(unicode): how to align columns, can be left, center or right
+        @param col_sep(unicode): separator betweens columns
+        @param head_line(unicode): character to use to make line under head
+        """
+        col_sep_size = len(regex.ansiRemove(col_sep))
+        if right is None:
+            right = left
+        if top_sep is None:
+            top_sep = col_sep_size * top
+        if head_sep is None:
+            head_sep = col_sep
+        if bottom is None:
+            bottom = top
+        if bottom_sep is None:
+            bottom_sep = col_sep_size * bottom
+        if not show_borders:
+            left = right = head_line_left = head_line_right = u''
+        # top border
+        if show_borders:
+            self.host.disp(
+                top_left
+                + top_sep.join([top*size for size in self.sizes])
+                + top_right
+                )
+
+        # headers
+        if show_header:
+            self.host.disp(
+                left
+                + self._headers(head_sep, head_alignment, head_style)
+                + right
+                )
+            # header line
+            self.host.disp(
+                head_line_left
+                + head_line_sep.join([head_line*size for size in self.sizes])
+                + head_line_right
+                )
+
+        # content
+        if columns_alignment == u'left':
+            alignment = lambda idx, s: ansi_ljust(s, self.sizes[idx])
+        elif columns_alignment == u'center':
+            alignment = lambda idx, s: ansi_center(s, self.sizes[idx])
+        elif columns_alignment == u'right':
+            alignment = lambda idx, s: ansi_rjust(s, self.sizes[idx])
+        else:
+            raise exceptions.InternalError(u'bad columns alignment argument')
+
+        for row in self.rows:
+            self.host.disp(left + col_sep.join([alignment(idx,c) for idx,c in enumerate(row)]) + right)
+
+        if show_borders:
+            # bottom border
+            self.host.disp(
+                bottom_left
+                + bottom_sep.join([bottom*size for size in self.sizes])
+                + bottom_right
+                )
+
+    def display_blank(self, **kwargs):
+        """Display table without visible borders"""
+        kwargs_ = {'col_sep':u' ', 'head_line_sep':u' ', 'show_borders':False}
+        kwargs_.update(kwargs)
+        self.display(self,
+                     **kwargs_
+                     )