Adding new module: tally_sheet.py

This module creates "tally sheets" such as this:

Boot Type                      Total Pass Fail
------------------------------ ----- ---- ----
BMC Power On                       0    0    0
BMC Power Off                      3    3    0
==============================================
Totals                             3    3    0

Change-Id: I1835c2fb5270cb32ee4db36e2388be766f4f99f3
Signed-off-by: Michael Walsh <micwalsh@us.ibm.com>
diff --git a/lib/tally_sheet.py b/lib/tally_sheet.py
new file mode 100755
index 0000000..e3280c3
--- /dev/null
+++ b/lib/tally_sheet.py
@@ -0,0 +1,318 @@
+#!/usr/bin/env python
+
+r"""
+Define the tally_sheet class.
+"""
+
+import sys
+import collections
+import copy
+import re
+
+try:
+    from robot.utils import DotDict
+except ImportError:
+    pass
+
+import gen_print as gp
+
+
+###############################################################################
+class tally_sheet:
+
+    r"""
+    This class is the implementation of a tally sheet.  The sheet can be
+    viewed as rows and columns.  Each row has a unique key field.
+
+    This class provides methods to tally the results (totals, etc.).
+
+    Example code:
+
+    # Create an ordered dict to represent your field names/initial values.
+    try:
+        boot_results_fields = collections.OrderedDict([('total', 0), ('pass',
+        0), ('fail', 0)])
+    except AttributeError:
+        boot_results_fields = DotDict([('total', 0), ('pass', 0), ('fail', 0)])
+    # Create the tally sheet.
+    boot_test_results = tally_sheet('boot type', boot_results_fields,
+    'boot_test_results')
+    # Set your sum fields (fields which are to be totalled).
+    boot_test_results.set_sum_fields(['total', 'pass', 'fail'])
+    # Set calc fields (within a row, a certain field can be derived from
+    # other fields in the row.
+    boot_test_results.set_calc_fields(['total=pass+fail'])
+
+    # Create some records.
+    boot_test_results.add_row('BMC Power On')
+    boot_test_results.add_row('BMC Power Off')
+
+    # Increment field values.
+    boot_test_results.inc_row_field('BMC Power On', 'pass')
+    boot_test_results.inc_row_field('BMC Power Off', 'pass')
+    boot_test_results.inc_row_field('BMC Power On', 'fail')
+    # Have the results tallied...
+    boot_test_results.calc()
+    # And printed...
+    boot_test_results.print_report()
+
+    Example result:
+
+    Boot Type                      Total Pass Fail
+    ------------------------------ ----- ---- ----
+    BMC Power On                       2    1    1
+    BMC Power Off                      1    1    0
+    ==============================================
+    Totals                             3    2    1
+
+    """
+
+    def __init__(self,
+                 row_key_field_name='Description',
+                 init_fields_dict=dict(),
+                 obj_name='tally_sheet'):
+
+        r"""
+        Create a tally sheet object.
+
+        Description of arguments:
+        row_key_field_name          The name of the row key field (e.g.
+                                    boot_type, team_name, etc.)
+        init_fields_dict            A dictionary which contains field
+                                    names/initial values.
+        obj_name                    The name of the tally sheet.
+        """
+
+        self.__obj_name = obj_name
+        # The row key field uniquely identifies the row.
+        self.__row_key_field_name = row_key_field_name
+        # Create a "table" which is an ordered dictionary.
+        # If we're running python 2.7 or later, collections has an
+        # OrderedDict we can use.  Otherwise, we'll try to use the DotDict (a
+        # robot library).  If neither of those are available, we fail.
+        try:
+            self.__table = collections.OrderedDict()
+        except AttributeError:
+            self.__table = DotDict()
+        # Save the initial fields dictionary.
+        self.__init_fields_dict = init_fields_dict
+        self.__totals_line = init_fields_dict
+        self.__sum_fields = []
+        self.__calc_fields = []
+
+    def init(self,
+             row_key_field_name,
+             init_fields_dict,
+             obj_name='tally_sheet'):
+        self.__init__(row_key_field_name,
+                      init_fields_dict,
+                      obj_name='tally_sheet')
+
+    def set_sum_fields(self, sum_fields):
+
+        r"""
+        Set the sum fields, i.e. create a list of field names which are to be
+        summed and included on the totals line of reports.
+
+        Description of arguments:
+        sum_fields                  A list of field names.
+        """
+
+        self.__sum_fields = sum_fields
+
+    def set_calc_fields(self, calc_fields):
+
+        r"""
+        Set the calc fields, i.e. create a list of field names within a given
+        row which are to be calculated for the user.
+
+        Description of arguments:
+        calc_fields                 A string expression such as
+                                    'total=pass+fail' which shows which field
+                                    on a given row is derived from other
+                                    fields in the same row.
+        """
+
+        self.__calc_fields = calc_fields
+
+    def add_row(self, row_key, init_fields_dict=None):
+
+        r"""
+        Add a row to the tally sheet.
+
+        Description of arguments:
+        row_key                     A unique key value.
+        init_fields_dict            A dictionary of field names/initial
+                                    values.  The number of fields in this
+                                    dictionary must be the same as what was
+                                    specified when the tally sheet was
+                                    created.  If no value is passed, the value
+                                    used to create the tally sheet will be
+                                    used.
+        """
+
+        if init_fields_dict is None:
+            init_fields_dict = self.__init_fields_dict
+        try:
+            self.__table[row_key] = collections.OrderedDict(init_fields_dict)
+        except AttributeError:
+            self.__table[row_key] = DotDict(init_fields_dict)
+
+    def update_row_field(self, row_key, field_key, value):
+
+        r"""
+        Update a field in a row with the specified value.
+
+        Description of arguments:
+        row_key                     A unique key value that identifies the row
+                                    to be updated.
+        field_key                   The key that identifies which field in the
+                                    row that is to be updated.
+        value                       The value to set into the specified
+                                    row/field.
+        """
+
+        self.__table[row_key][field_key] = value
+
+    def inc_row_field(self, row_key, field_key):
+
+        r"""
+        Increment the value of the specified field in the specified row.  The
+        value of the field must be numeric.
+
+        Description of arguments:
+        row_key                     A unique key value that identifies the row
+                                    to be updated.
+        field_key                   The key that identifies which field in the
+                                    row that is to be updated.
+        """
+
+        self.__table[row_key][field_key] += 1
+
+    def dec_row_field(self, row_key, field_key):
+
+        r"""
+        Decrement the value of the specified field in the specified row.  The
+        value of the field must be numeric.
+
+        Description of arguments:
+        row_key                     A unique key value that identifies the row
+                                    to be updated.
+        field_key                   The key that identifies which field in the
+                                    row that is to be updated.
+        """
+
+        self.__table[row_key][field_key] -= 1
+
+    def calc(self):
+
+        r"""
+        Calculate totals and row calc fields.  Also, return totals_line
+        dictionary.
+        """
+
+        self.__totals_line = copy.deepcopy(self.__init_fields_dict)
+        # Walk through the rows of the table.
+        for row_key, value in self.__table.items():
+            # Walk through the calc fields and process them.
+            for calc_field in self.__calc_fields:
+                tokens = [i for i in re.split(r'(\d+|\W+)', calc_field) if i]
+                cmd_buf = ""
+                for token in tokens:
+                    if token in ("=", "+", "-", "*", "/"):
+                        cmd_buf += token + " "
+                    else:
+                        # Note: Using "mangled" name for the sake of the exec
+                        # statement (below).
+                        cmd_buf += "self._" + self.__class__.__name__ +\
+                                   "__table['" + row_key + "']['" +\
+                                   token + "'] "
+                exec(cmd_buf)
+
+            for field_key, sub_value in value.items():
+                if field_key in self.__sum_fields:
+                    self.__totals_line[field_key] += sub_value
+
+        return self.__totals_line
+
+    def sprint_obj(self):
+
+        r"""
+        sprint the fields of this object.  This would normally be for debug
+        purposes.
+        """
+
+        buffer = ""
+
+        buffer += "class name: " + self.__class__.__name__ + "\n"
+        buffer += gp.sprint_var(self.__obj_name)
+        buffer += gp.sprint_var(self.__row_key_field_name)
+        buffer += gp.sprint_var(self.__table)
+        buffer += gp.sprint_var(self.__init_fields_dict)
+        buffer += gp.sprint_var(self.__sum_fields)
+        buffer += gp.sprint_var(self.__totals_line)
+        buffer += gp.sprint_var(self.__calc_fields)
+        buffer += gp.sprint_var(self.__table)
+
+        return buffer
+
+    def print_obj(self):
+
+        r"""
+        print the fields of this object to stdout.  This would normally be for
+        debug purposes.
+        """
+
+        sys.stdout.write(self.sprint_obj())
+
+    def sprint_report(self):
+
+        r"""
+        sprint the tally sheet in a formatted way.
+        """
+
+        buffer = ""
+        # Build format strings.
+        col_names = [self.__row_key_field_name.title()]
+        report_width = 30
+        key_width = 30
+        format_string = '{0:<' + str(key_width) + '}'
+        dash_format_string = '{0:-<' + str(key_width) + '}'
+        field_num = 0
+
+        first_rec = next(iter(self.__table.items()))
+        for row_key, value in first_rec[1].items():
+            field_num += 1
+            if type(value) is int:
+                align = ':>'
+            else:
+                align = ':<'
+            format_string += ' {' + str(field_num) + align +\
+                             str(len(row_key)) + '}'
+            dash_format_string += ' {' + str(field_num) + ':->' +\
+                                  str(len(row_key)) + '}'
+            report_width += 1 + len(row_key)
+            col_names.append(row_key.title())
+        num_fields = field_num + 1
+        totals_line_fmt = '{0:=<' + str(report_width) + '}'
+
+        buffer += format_string.format(*col_names) + "\n"
+        buffer += dash_format_string.format(*([''] * num_fields)) + "\n"
+        for row_key, value in self.__table.items():
+            buffer += format_string.format(row_key, *value.values()) + "\n"
+
+        buffer += totals_line_fmt.format('') + "\n"
+        buffer += format_string.format('Totals',
+                                       *self.__totals_line.values()) + "\n"
+
+        return buffer
+
+    def print_report(self):
+
+        r"""
+        print the tally sheet in a formatted way.
+        """
+
+        sys.stdout.write(self.sprint_report())
+
+###############################################################################