rename parsers, better journald integration
[logparse.git] / logparse / formatting.py
index aa4b71f6d79fe3e6b8170d34d5f278d4977ac339..5b5367166fe120046cde95cc9437be67170e4c01 100644 (file)
-#
-#   format.py
-#   
-#   This file contains global functions for formatting and printing data. This
-#   file should be imported into individual log-parsing scripts located in
-#   logs/*. Data is all formatted in HTML. Writing to disk and/or emailng data
-#   is left to __main__.py.
-#
+# -*- coding: utf-8 -*-
+
+"""   
+This file contains global functions for formatting and printing data. This file
+should be imported into individual log-parsing scripts located in the default
+logparse.parsers module or in the user-supplied parsers directory. Data is
+formatted in HTML or plaintext. Writing to disk and/or emailng data is left to
+interface.py.
+"""
 
 import os
 import re
 import locale
 from string import Template
+from math import floor, ceil
+from tabulate import tabulate
+import textwrap
 
 import logparse
-from . import interface, util, config
+from logparse import interface, util, mail, config
 
 import logging
+logger = None
 logger = logging.getLogger(__name__)
 
+
 locale.setlocale(locale.LC_ALL, '') # inherit system locale
-#DEG = "°".encode('unicode_escape')
+
+
 DEG = u'\N{DEGREE SIGN}'
 CEL = "C"
 TIMEFMT = "%X"
 DATEFMT = "%x"
+CORNERCHARS_DOUBLE = ['╚', '╝', '╗', '╔']
+CORNERCHARS_SINGLE = ['└', '┘', '┐', '┌']
+LINECHARS_DOUBLE = ['║', '═']
+LINECHARS_SINGLE = ['│', '─']
+JXNCHARS_DOUBLE = ['╠', '╣', '╦', '╩', '╬']
+JXNCHARS_SINGLE = ['├', '┤', '┬', '┴', '┼']
+BULLET = "• "
+INDENT = "  "
+SPLIT_CHARS = ['.', '(', ')', '[', ']', '&', r"/", "\\", ',', '-', '_']
 
-def init_varfilter():
-    global varfilter
-    global varpattern
-    global varsubst
-    varfilter = {"$title$": config.prefs['title'], "$date$": interface.start.strftime(DATEFMT),"$time$": interface.start.strftime(TIMEFMT), "$hostname$": util.hostname(config.prefs['hostname-path']), "$version$": logparse.__version__, "$css$": os.path.relpath(config.prefs['css'], os.path.dirname(config.prefs['output']))}
-    varfilter = dict((re.escape(k), v) for k, v in varfilter.items())
-    varpattern = re.compile("|".join(varfilter.keys()))
-    varsubst = dict(title=config.prefs['title'], date=interface.start.strftime(DATEFMT), time=interface.start.strftime(TIMEFMT), hostname=util.hostname(config.prefs['hostname-path']), version=logparse.__version__, css=os.path.relpath(config.prefs['css'], os.path.dirname(config.prefs['output'])))
-
-def writetitle(title):  # write title for a section
-    if (title == '' or '\n' in title):
-        logger.error("Invalid title")
-        raise ValueError 
-    logger.debug("Writing title for " + title)
-    return tag('h2', 0, title)
-
-def opentag(tag, block = 0, id = None, cl = None):   # write html opening tag
-    output = ""
-    if (block):
-        output += '\n'
-    output += '<' + tag
-    if (id != None):
-        output += " id='" + id + "'"
-    if (cl != None):
-        output += " class='" + cl + "'"
-    output += '>'
-    if (block):
-        output += '\n'
-    return output
 
-def closetag(tag, block = 0):  # write html closing tag
-    if (block == 0):
-        return "</" + tag + ">"
-    else:
-        return "\n</" + tag + ">\n"
+global VARSUBST
 
-def tag(tag, block = 0, content = ""):  # write html opening tag, content, and html closing tag
-    o = opentag(tag, block)
-    c = closetag(tag, block)
-    return o + content + c
 
-def header(template):   # return a parsed html header from file
-#    try:
-#        copyfile(config['css'], config['dest'] + '/' + os.path.basename(config['css']))
-#        logger.debug("copied main.css")
-#    except Exception as e:
-#        logger.warning("could not copy main.css - " + str(e))
-    init_varfilter()
-    headercontent = Template(open(template, 'r').read())
-    return headercontent.safe_substitute(varsubst)
-
-def orderbyfreq(l):     # order a list by the frequency of its elements and remove duplicates
-    temp_l = l[:]
-    l = list(set(l))
-    l = [[i, temp_l.count(i)] for i in l]   # add count of each element
-    l.sort(key=lambda x:temp_l.count(x[0])) # sort by count
-    l = [i[0] + ' (' + str(i[1]) + ')' for i in l]  # put element and count into string
-    l = l[::-1]     # reverse
-    return l
-
-def addtag(l, tag):  # add prefix and suffix tags to each item in a list
-    l2 = ['<' + tag + '>' + i + '</' + tag + '>' for i in l]
-    return l2
-
-def truncl(input, limit):      # truncate list
-    if (len(input) > limit):
-        more = str(len(input) - limit)
-        output = input[:limit]
-        output.append("+ " + more + " more")
-        return(output)
-    else:
-        return(input)
+def init_var():
+    """
+    Initialise variable substitution templates (should be called before doing
+    any substitutions)
+    """
+
+    global VARSUBST
+    css_path = config.prefs.get("html", "css")
+    if config.prefs.getboolean("html", "css-relpath"):
+        if interface.argparser.parse_args().no_write:
+            css_path = os.path.relpath(css_path, ".")
+        elif interface.argparser.parse_args().destination:
+            css_path = os.path.relpath(
+                    css_path, interface.argparser.parse_args().destination())
+        elif config.prefs.get("logparse", "output"):
+            css_path = os.path.relpath(
+                    css_path, config.prefs.get("logparse", "output"))
+    VARSUBST = {
+        "title": config.prefs.get("logparse", "title"),
+        "date": interface.start.strftime(DATEFMT),
+        "time": interface.start.strftime(TIMEFMT),
+        "hostname": util.hostname(config.prefs.get(
+            "logparse", "hostname-path")),
+        "version": logparse.__version__,
+        "css": css_path,
+        "period": util.LogPeriod("logparse").startdate.strftime(
+            TIMEFMT + " " + DATEFMT)
+    }
+
+
+class Output:
+    """
+    Base class for a data processor. 
+    """
+    
+    def __init__(self):
+        self.content = ""
+        self.destination = ""
+
+    def append(self, content):
+        """
+        Add a string
+        """
+
+        self.content += content
+
+    def write(self, destination=""):
+        """
+        Output contents into a file
+        """
+
+        if destination == "":
+            destination = self.destination
+        if destination == "":
+            logger.warning("No destination path provided")
+            return 1
+        with open(destination, 'w') as f:
+            f.write(self.content)
+            logger.info("Written output to {}".format(destination))
+
+    def print_stdout(self, lines=False):
+        """
+        Echo the contents to the console
+        """
+
+        print()
+        if lines:
+            line = PlaintextLine(linewidth=
+                    config.prefs.getint("plain", "linewidth"), double=True)
+            print(line.draw())
+        print(self.content)
+        if lines:
+            print(line.draw())
+        print()
+
+
+class PlaintextOutput(Output):
+    """
+    Processes & outputs data in a plaintext form which can be read with cat or
+    plaintext email.
+    """
+
+    def __init__(self, linewidth=80):
+        self.content = ""
+        self.destination = ""
+        self.linewidth = linewidth;
+
+    def append_header(self, template=''):
+        """
+        Print details with some primitive formatting
+        """
+        box = PlaintextBox(content=
+                Template("$title $version on $hostname\n\n$time $date"
+                    "\nParsing logs since $period")
+                .safe_substitute(VARSUBST),
+                vpadding=2, hpadding="\t\t", linewidth=self.linewidth)
+        self.append(box.draw() + "\n"*2)
+
+    def append_footer(self):
+        """
+        Append a horizontal line and some details
+        """
+        self.append(PlaintextLine(self.linewidth).draw())
+        self.append(Template("$hostname $time $date").safe_substitute(VARSUBST))
+
+    def append_section(self, section):
+        """
+        Call the appropriate methods to format a section (provided by a parser).
+        This should be run by interface.py after every instance of parse_log().
+        """
+
+        if section == None:
+            logger.warning("Received null section")
+            return
+        self.append(PlaintextBox(
+            content=section.title, double=False,
+            fullwidth=False, vpadding=0, hpadding=" ").draw())
+        if section.period and section.period.unique:
+            self.append("\n(since {0})".format(
+                section.period.startdate.strftime(DATEFMT + " " + TIMEFMT)))
+        self.append('\n'*2)
+        for data in section.data:
+            self.append(self._fmt_data(data.subtitle, data.items))
+            self.append('\n')
+        for table in section.tables:
+            self.append(table.draw_plain())
+        self.append("\n")
+
+    def _fmt_data(self, subtitle, data = None):   # write title and data
+        """
+        Format the properties of a data object into usable plaintext form with
+        a few fancy symbols/formatting tricks. Subtitle is required, data is
+        not. If only subtitle is supplied or subtitle + one data item, a single
+        line will be printed.
+        """
+
+        if (subtitle == ""):
+            logger.warning("No subtitle provided.. skipping section")
+            return
+
+        logger.debug("Processing data {}".format(subtitle))
+
+        if (data == None or len(data) == 0):
+            # If no list items are provided, just print the subtitle
+            return subtitle + "\n"
+        elif (len(data) == 1):
+            # If only one item is provided, print it inline with subtitle
+            return self._wrap_datum("{}: {}".format(subtitle, data[0]),
+                    bullet=False, indent=False) + "\n"
+        else:
+            # If many items are provided, print them all as a bulleted list
+            itemoutput = subtitle + ":\n"
+            for datum in data:
+                itemoutput += self._wrap_datum(datum) + "\n"
+            return itemoutput
+
+    def _wrap_datum(self, text, bullet=True, indent=True):
+        """
+        Use cpython's textwrap module to limit line width to the value 
+        specified in self.linewidth. This is much easier than doing it all
+        from scratch (which I tried to do originally). Note that line 
+        continuations are automatically indented even if they don't have a 
+        bullet. This is to make it clear which lines are continuations.
+        """
+
+        wrapper = textwrap.TextWrapper(
+                initial_indent=(INDENT if indent else "") \
+                        + (BULLET if bullet else ""),
+                subsequent_indent=INDENT + (' '*len(BULLET) if bullet else ""),
+                width=self.linewidth,
+                replace_whitespace=True)
+
+        return wrapper.fill(text)
+
+
+class HtmlOutput(Output):
+    """
+    Process and output data in HTML format. All HTML formatting functions now
+    reside in this class to differentiate them from plain text.
+    """
+
+    def __init__(self):
+        """
+        Initialise variables (no parameters required for initialisation)
+        """
+
+        self.content = ""
+        self.destination = ""
+        self.css = ""
+        self._embedded = ""
+
+    def embed_css(self, css):
+        """
+        Convert stylesheet to inline tags
+        """
+
+        if not self._embedded:
+            self._embedded = mail.mailprep(re.sub(
+                ".*" + re.escape(VARSUBST['css']) + ".*\n", "", self.content),
+                css)
+        return self._embedded
+
+    def write_embedded(self, destination = ""):
+        """
+        Write contents to file with inline CSS tags
+        """
+
+        logger.debug("Writing HTML with embedded styles to " + destination)
+        if not self._embedded:
+            logger.warning("Call to write_embedded before embed_css - \
+                    embedding stylesheets immediately")
+            self.embed_css(config.prefs.get("html", "css"))
+        if destination == "":
+            destination = self.destination
+        if destination == "":
+            logger.warning("No destination path provided")
+            return 1
+        with open(destination, 'w') as f:
+            f.write(self._embedded)
+            logger.info("Written output to {}".format(destination))
+
+
+    def append_header(self, template):
+        """
+        Insert variables into header template file and append HTML tags
+        """
+
+        self.headertemplate = template
+        headercontent = Template(open(template, 'r').read())
+        self.append(headercontent.safe_substitute(VARSUBST))
+        self.append(opentag('div', id='main'))
+
+    def append_footer(self):
+        """
+        Close HTML tags that were opened in the template.
+        TODO: add footer template similar to header template.
+        """
+
+        self.append(closetag('div') + closetag('body') + closetag('html'))
+
+    def append_section(self, section):
+        """
+        Call the appropriate methods to generate HTML tags for a section
+        (provided by a parser). This should be run by interface.py after every
+        instance of parse_log().
+        """
+
+        if section == None:
+            logger.warning("Received null section")
+            return
+        self.append(opentag('div', 1, section.title, 'section'))
+        self.append(self._gen_title(section.title))
+        if section.period and section.period.unique:
+            self.append(self._fmt_period(section.period))
+        for data in section.data:
+            self.append(self._fmt_data(data.subtitle, data.items, data.severity))
+        for table in section.tables:
+            self.append(table.draw_html())
+        self.append(closetag('div', 1))
+
+    def _gen_title(self, title):
+        """
+        Format the title for a section
+        """
+
+        if (title == '' or '\n' in title):
+            logger.error("Invalid title")
+            raise ValueError 
+        logger.debug("Writing title for " + title)
+        return tag('h2', False, title)
+
+    def _fmt_data(self, subtitle, data=None, severity=0):
+        """
+        Format the properties of a data object into usable HTML tags.
+        Subtitle is required, data is not. If only subtitle is supplied or
+        subtitle + one data item, a single line will be printed.
+        """
+
+        if (subtitle == ""):
+            logger.warning("No subtitle provided.. skipping section")
+            return
+
+        if (data == None or len(data) == 0):
+            logger.debug("No data provided.. just printing subtitle")
+            return tag('p', False, subtitle, cl="severity-" + str(severity))
+        else:
+            logger.debug("Received data {}: {}".format(subtitle, data))
+            subtitle += ':'
+            if (len(data) == 1):
+                return tag('p', False, subtitle + ' ' + data[0],
+                        cl="severity-" + str(severity))
+            else:
+                output = ""
+                output += tag('p', False, subtitle,
+                        cl="severity-" + str(severity))
+                output += opentag('ul', 1)
+                coderegex = re.compile('`(.*)`')
+                for datum in data:
+                    if datum == "" or datum == None:
+                        continue
+                    datum = coderegex.sub(r"<code>\1</code>", str(datum))
+                    output += tag('li', False, datum)
+                output += closetag('ul', True)
+                return output
+
+    def _fmt_period(self, period):
+        output = ''
+        output += opentag('span', cl='period')
+        output += "since " + period.startdate.strftime(DATEFMT + " " + TIMEFMT)
+        output += closetag('span')
+        return output
+
+    def print_stdout_embedded(self, lines=False):
+        """
+        Echo the version with embedded style tags to the console
+        """
+
+        if self._embedded == "":
+            self.embed_css(config.prefs.get("html", "css"))
+        print()
+        if lines:
+            line = PlaintextLine(linewidth=
+                    config.prefs.getint("plain", "linewidth"), double=True)
+            print(line.draw())
+        print(self._embedded)
+        if lines:
+            print(line.draw())
+        print()
+
+
+class Section:
+    """
+    Each parser should output a Section() which contains the title, returned
+    data, and applicable time period.
+    """
+
+    def __init__(self, title, period=None):
+        self.title = title
+        self.data = []
+        self.tables = []
+        self.period = util.LogPeriod(self.title)
+
+    def append_data(self, data):
+        self.data.append(data)
+
+    def append_table(self, table):
+        self.tables.append(table)
+
+
+class Data:
+    """
+    Each section (parser) can have one or more Data() objects which are
+    essentially glorified lists with titles (`self.subtitle`).
+    """
+    
+    def __init__(self, subtitle="", items=[], severity=0):
+        """
+        Initialise variables. No parameters are enforced upon initialisation,
+        but at least the subtitle is required for valid output. Severity refers
+        to the importance of the data (integer from 0 to 5). e.g. a failed
+        system should have severity 5 and will be formatted appropriately by
+        the Output object.
+        """
+
+        self.subtitle = subtitle
+        self.items = items 
+        self.severity = severity
+
+    def truncl(self, limit):      # truncate list
+        """
+        Truncate self.items to a specified value and state how many items are
+        hidden. Set limit to -1 to avoid truncating any items.
+        """
+
+        if limit == -1:
+            return self
+        if (len(self.items) > limit):
+            more = len(self.items) - limit
+            if more == 1:
+                return 0
+            self.items = self.items[:limit]
+            self.items.append("+ {0} more".format(str(more)))
+        return self
+
+    def orderbyfreq(self):
+        """
+        Order a list by frequency of each item, then remove duplicates and
+        append frequency in parentheses.
+        """
+
+        unsorted = list(self.items)
+        self.items = ["{0} ({1})".format(y, unsorted.count(y)) for y in sorted(
+            set(unsorted), key = lambda x: -unsorted.count(x))]
+        return self
+
+
+class Table(object):
+    """
+    A wrapper for python-tabulate's Tabulate type.
+    """
+    
+    def __init__(self, double=False, borders=False, hpadding=" ",
+            maxwidth=80, headers=[]):
+        """
+        Initialise variables. Note the keymap is used for a faster index map,
+        but is not currently used anywhere (may be removed in future).
+        """
+
+        self.rows =  []     # List of row objects
+        self.keymap = {}    # For fast lookup of row by value of first column 
+        self.double = double
+        self.borders = borders
+        self.align_cols = []
+        self.hpadding = hpadding
+        self.maxwidth = maxwidth
+        self.headers = headers
+        self._align_cols = []
+
+    def add_row(self, row):
+        """
+        Append a row to the list and amend index mapping
+        """
+
+        self.rows.append(row)
+        if len(row.columns) > 0:
+            self.keymap[row.columns[0]] = row
+
+        logger.debug("Added row with {0} columns".format(str(len(row.columns))))
+
+    def align_column(self, i, align):
+        """
+        Set alignment for the 'i'th column (`align` should be 'l', 'c' or 'r')
+        """
+
+        while len(self._align_cols) -1 < i:
+            self._align_cols.append("")
+        self._align_cols[i] = align
+        for row in self.rows:
+            row.columns[i].align = align
+        logger.debug("Column alignment is now {0}".format(self._align_cols))
+
+    def _gen_list(self):
+        """
+        Used locally for organising rows and columns into a 2D list structure
+        """
+
+        hierarchy = []
+        for row in self.rows:
+            row_data = []
+            for column in row.columns:
+                row_data.append(column.content)
+            hierarchy.append(row_data)
+        return hierarchy
+
+    def draw_html(self):
+        """
+        Output HTML string (wrapper for tabulate)
+        """
+
+        output = tabulate(self._gen_list(), self.headers, tablefmt="html",
+                colalign=tuple(self._align_cols))
+        return output
+
+    def draw_plain(self):
+        """
+        Output plain text string (wrapper for tabulate)
+        """
+
+        output = tabulate(self._gen_list(), self.headers,
+                tablefmt="fancy_grid" if self.borders
+                else "plain", colalign=tuple(self._align_cols))
+        return output + "\n"*2
+
+
+class Row(object):
+    """
+    Object representing a literal row in a 2D table with the individual cells
+    in the row represented by columns[].
+    """
+    
+    def __init__(self, columns=[], header=False):
+        """
+        Initialise variables. The variable n is used locally to keep track of
+        the row width.
+        """
+
+        self.columns = columns
+        self.header = header
+        self.n = len(self.columns)
+
+    def add_column(self, column):
+        """
+        Append a single cell horizontally and increment the cell count
+        """
+
+        self.columns.append(column)
+        self.n += 1
+
+    def rm_column(self, column):
+        """
+        Remove the specified column object and decrement the cell count
+        """
+
+        self.remove(column)
+        self.n -= 1
+
+
+class Column(object):
+    """
+    Object representing a single table cell. "Column" is somewhat of a misnomer 
+    - one column object exists for each cell in the table. Columns are children
+    of rows.
+    """
+
+    def __init__(self, content="", align="right"):
+        """
+        Initialise variables. The align property sets the alignment of a single
+        cell ('l', 'c', or 'r').
+        """
+
+        self.content = content
+        self.align = align
+
+
+class PlaintextLine:
+    """
+    Draw a horizontal line for plain text format, with optional padding/styling.
+    """
+
+    def __init__(self, linewidth=80, double=True, vpadding=0, hpadding=""):
+        """
+        Initialise variables
+        """
+
+        self.linewidth = linewidth
+        self.double = double
+        self.vpadding = vpadding
+        self.hpadding = hpadding
+
+    def draw(self):
+        """
+        Output a plain text string based on the current object parameters
+        """
+
+        line = (LINECHARS_DOUBLE[1] if self.double else LINECHARS_SINGLE[1])
+        return "\n" * self.vpadding + self.hpadding \
+                +  line * (self.linewidth - 2 * len(self.hpadding)) \
+                + self.hpadding + "\n" * (self.vpadding + 1)
+
+
+class PlaintextBox:
+    """
+    Draw a rectangular box around text, with customisable padding/size/style
+    """
+
+    def __init__(self, content="", double=True, fullwidth=True, linewidth=80,
+            hpadding="\t", vpadding=1):
+        """
+        Initialise variables
+        """
+        self.content = content
+        self.fullwidth = fullwidth
+        self.linewidth = linewidth
+        self.hpadding = hpadding 
+        self.vpadding = vpadding
+        self.double = double
+
+    def draw(self):
+        """
+        Output a plain text string based on the current object parameters. This
+        involves calculating the text width, breaking text at the maximum line
+        length, and then drawing a box around it all.
+        """
+
+        if self.double == True:
+            cornerchars = CORNERCHARS_DOUBLE
+            linechars = LINECHARS_DOUBLE
+        else:
+            cornerchars = CORNERCHARS_SINGLE
+            linechars = LINECHARS_SINGLE
+
+        # Check hpadding has a definite width
+        self.hpadding = self.hpadding.replace("\t", " "*4)
+
+        # Calculate number of characters per line
+        contentlines = self.content.splitlines()
+        contentwidth = int((self.linewidth if self.linewidth > 0 else 80)
+                if self.content.splitlines()
+                else len(max(contentlines, key=len)))
+        logger.debug("Content width is {0}".format(str(contentwidth)))
+        logger.debug("Longest line is {0}".format(
+            len(max(contentlines, key=len))))
+        contentwidth += -2*(len(self.hpadding)+1)
+        if not self.fullwidth:
+            longestline = len(max(contentlines, key=len))
+            if longestline <= self.linewidth - 2*(len(self.hpadding)+1):
+                contentwidth = longestline
+
+        # Split lines that are too long
+        for i, line in enumerate(contentlines):
+            if len(line) > contentwidth:
+                words = line.split()
+                if max(map(len, words)) > contentwidth:
+                    continue
+                res, part, others = [], words[0], words[1:]
+                for word in others:
+                    if len(' ') + len(word) > contentwidth - len(part):
+                        res.append(part)
+                        part = word
+                    else:
+                        part += ' ' + word
+                if part:
+                    res.append(part)
+                contentlines[i] = res
+
+        # Flatten list
+        #   Note list comprehension doesn't work here, so we must iterate
+        #   through each item
+        newlines = []
+        for line in contentlines:
+            if isinstance(line, list):
+                for subline in line:
+                    newlines.append(subline)
+            else:
+                newlines.append(line)
+        contentlines = newlines
+               
+        # Add vertical padding
+        for _ in range(self.vpadding):
+            contentlines.insert(0, ' '*contentwidth)
+            contentlines.append(' '*contentwidth)
+
+        # Insert horizontal padding on lines that are too short
+        contentlines = [linechars[0] + self.hpadding + x
+                + ' '*(self.linewidth-(len(x)+2*len(self.hpadding)+2)
+                    if len(x) < contentwidth else 0)
+                + self.hpadding + linechars[0] for x in contentlines]
+        contentlines.insert(0, cornerchars[3] + linechars[1] 
+                * (contentwidth + len(self.hpadding)*2) + cornerchars[2])
+        contentlines.append(cornerchars[0] + linechars[1]
+                * (contentwidth + len(self.hpadding)*2) + cornerchars[1])
+        return ('\n').join(contentlines)
+
+
+def backticks(l):
+    """
+    Surround every item in a list by backticks. Used for showing code in both
+    HTML and plain text formats (converted to <code> tags for HTML)
+    """
+
+    return ["`" + x + "`" for x in l]
+
+
+def plural(noun, quantity, print_quantity=True):
+    """
+    Return "1 noun" or "n nouns"
+    """
 
-def plural(noun, quantity): # return "1 noun" or "n nouns"
     if (quantity == 1):
-        return(str(quantity) + " " + noun)
+        if print_quantity:
+            return(str(quantity) + " " + noun)
+        else:
+            return noun
     else:
-        return(str(quantity) + " " + noun + "s")
+        if noun.endswith("s"):
+            noun += "e"
+        if print_quantity:
+            return(str(quantity) + " " + noun + "s")
+        else:
+            return noun + "s"
+
+
+def parsesize(num, suffix='B'):
+    """
+    Return human-readable size from number of bytes
+    """
 
-def parsesize(num, suffix='B'):     # return human-readable size from number of bytes
     for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
         if abs(num) < 1024.0:
             return "%3.1f %s%s" % (num, unit, suffix)
         num /= 1024.0
     return "%.1f%s%s" % (num, 'Yi', suffix)
 
-def fsubject(template): # Replace variables in the title template provided in config
-    r = varpattern.sub(lambda m: varfilter[re.escape(m.group(0))], template)
+
+def fsubject(subject):
+    """
+    Replace variables in the title template provided in config
+    """
+
+    r = Template(subject).safe_substitute(VARSUBST)
     logger.debug("Returning subject line " + r)
     return r
 
-def writedata(subtitle, data = None):   # write title and data
-    if (subtitle == ""):
-        logger.warning("No subtitle provided.. skipping section")
-        return
 
-    if (data == None or len(data) == 0):
-        logger.debug("No data provided.. just printing subtitle")
-        return tag('p', 0, subtitle)
+def opentag(tag, block=False, id=None, cl=None, style=None):
+    """
+    Write HTML opening tag
+    """
+
+    output = ""
+    if block:
+        output += '\n'
+    output += '<' + tag
+    if id:
+        output += " id='" + id + "'"
+    if cl:
+        output += " class='" + cl + "'"
+    if style:
+        output += " style='"
+        output += " ".join("{0}: {1};".format(attr, value)
+                for attr, value in style.items())
+        output += "'"
+    output += '>'
+    if block:
+        output += '\n'
+    return output
+
+
+def closetag(tag, block=False):
+    """
+    Write HTML closing tag
+    """
+
+    if block:
+        return "\n</" + tag + ">\n"
     else:
-        logger.debug("Received data " + str(data))
-        subtitle += ':'
-        if (len(data) == 1):
-            return tag('p', 0, subtitle + ' ' + data[0])
-        else:
-            output = ""
-            output += tag('p', 0, subtitle)
-            output += opentag('ul', 1)
-            for datum in data:
-                output += tag('li', 0, datum)
-            output += closetag('ul', 1)
-            return output
+        return "</" + tag + ">"
 
+
+def tag(tag, block=False, content="", id=None, cl=None, style=None):
+    """
+    Write HTML opening tag, content, and closing tag
+    """
+
+    o = opentag(tag, block, id, cl, style)
+    c = closetag(tag, block)
+    return o + content + c