rename parsers, better journald integration
[logparse.git] / logparse / formatting.py
index 00ea9e45f663d71836bcc1a336d0eb0c4e3c2c08..5b5367166fe120046cde95cc9437be67170e4c01 100644 (file)
@@ -1,11 +1,11 @@
-#!/usr/bin/env python
 # -*- 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 logs/*. Data
-is formatted in HTML or plaintext. Writing to disk and/or emailng data is left
-to interface.py.
+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
@@ -14,11 +14,13 @@ import locale
 from string import Template
 from math import floor, ceil
 from tabulate import tabulate
+import textwrap
 
 import logparse
 from logparse import interface, util, mail, config
 
 import logging
+logger = None
 logger = logging.getLogger(__name__)
 
 
@@ -37,6 +39,7 @@ JXNCHARS_DOUBLE = ['╠', '╣', '╦', '╩', '╬']
 JXNCHARS_SINGLE = ['├', '┤', '┬', '┴', '┼']
 BULLET = "• "
 INDENT = "  "
+SPLIT_CHARS = ['.', '(', ')', '[', ']', '&', r"/", "\\", ',', '-', '_']
 
 
 global VARSUBST
@@ -54,16 +57,21 @@ def init_var():
         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())
+            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"))
+            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")),
+        "hostname": util.hostname(config.prefs.get(
+            "logparse", "hostname-path")),
         "version": logparse.__version__,
-        "css": css_path
+        "css": css_path,
+        "period": util.LogPeriod("logparse").startdate.strftime(
+            TIMEFMT + " " + DATEFMT)
     }
 
 
@@ -104,7 +112,8 @@ class Output:
 
         print()
         if lines:
-            line = PlaintextLine(linewidth=config.prefs.getint("plain", "linewidth"), double=True)
+            line = PlaintextLine(linewidth=
+                    config.prefs.getint("plain", "linewidth"), double=True)
             print(line.draw())
         print(self.content)
         if lines:
@@ -114,7 +123,8 @@ class Output:
 
 class PlaintextOutput(Output):
     """
-    Processes & outputs data in a plaintext form which can be read with cat or plaintext email.
+    Processes & outputs data in a plaintext form which can be read with cat or
+    plaintext email.
     """
 
     def __init__(self, linewidth=80):
@@ -126,15 +136,18 @@ class PlaintextOutput(Output):
         """
         Print details with some primitive formatting
         """
-        box = PlaintextBox(content=Template("$title $version on $hostname\n\n$time $date").safe_substitute(VARSUBST), vpadding=2, hpadding="\t\t", linewidth=self.linewidth)
-        line = PlaintextLine(self.linewidth)
-        self.append(box.draw() + line.draw())
+        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, vpadding=1).draw())
+        self.append(PlaintextLine(self.linewidth).draw())
         self.append(Template("$hostname $time $date").safe_substitute(VARSUBST))
 
     def append_section(self, section):
@@ -143,9 +156,15 @@ class PlaintextOutput(Output):
         This should be run by interface.py after every instance of parse_log().
         """
 
-        self.append(PlaintextBox(content=section.title, double=False, fullwidth=False, vpadding=0, hpadding=" ").draw())
+        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(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))
@@ -166,35 +185,40 @@ class PlaintextOutput(Output):
             logger.warning("No subtitle provided.. skipping section")
             return
 
+        logger.debug("Processing data {}".format(subtitle))
+
         if (data == None or len(data) == 0):
-            logger.debug("No data provided.. just printing subtitle")
-            return subtitle + '\n'
+            # 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:
-            logger.debug("Received data " + str(data))
-            subtitle += ':'
-            if (len(data) == 1):
-                return subtitle + ' ' + data[0] + '\n'
-            else:
-                itemoutput = subtitle + '\n'
-                for datum in data:
-                    datum = BULLET + datum
-                    if len(datum) > self.linewidth - 3:
-                        words = datum.split()
-                        if max(map(len, words)) > self.linewidth - len(INDENT):
-                            continue
-                        res, part, others = [], words[0], words[1:]
-                        for word in others:
-                            if 1 + len(word) > self.linewidth - len(part):
-                                res.append(part)
-                                part = word
-                            else:
-                                part += ' ' + word
-                        if part:
-                            res.append(part)
-                        datum = ('\n    ').join(res)
-                    datum = INDENT + datum
-                    itemoutput += datum + '\n'
-                return itemoutput
+            # 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):
     """
@@ -218,7 +242,9 @@ class HtmlOutput(Output):
         """
 
         if not self._embedded:
-            self._embedded = mail.mailprep(re.sub(".*" + re.escape(VARSUBST['css']) + ".*\n", "", self.content), css)
+            self._embedded = mail.mailprep(re.sub(
+                ".*" + re.escape(VARSUBST['css']) + ".*\n", "", self.content),
+                css)
         return self._embedded
 
     def write_embedded(self, destination = ""):
@@ -261,10 +287,14 @@ class HtmlOutput(Output):
 
     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().
+        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:
@@ -301,13 +331,15 @@ class HtmlOutput(Output):
             logger.debug("No data provided.. just printing subtitle")
             return tag('p', False, subtitle, cl="severity-" + str(severity))
         else:
-            logger.debug("Received data " + str(data))
+            logger.debug("Received data {}: {}".format(subtitle, data))
             subtitle += ':'
             if (len(data) == 1):
-                return tag('p', False, subtitle + ' ' + data[0], cl="severity-" + str(severity))
+                return tag('p', False, subtitle + ' ' + data[0],
+                        cl="severity-" + str(severity))
             else:
                 output = ""
-                output += tag('p', False, subtitle, cl="severity-" + str(severity))
+                output += tag('p', False, subtitle,
+                        cl="severity-" + str(severity))
                 output += opentag('ul', 1)
                 coderegex = re.compile('`(.*)`')
                 for datum in data:
@@ -334,7 +366,8 @@ class HtmlOutput(Output):
             self.embed_css(config.prefs.get("html", "css"))
         print()
         if lines:
-            line = PlaintextLine(linewidth=config.prefs.getint("plain", "linewidth"), double=True)
+            line = PlaintextLine(linewidth=
+                    config.prefs.getint("plain", "linewidth"), double=True)
             print(line.draw())
         print(self._embedded)
         if lines:
@@ -382,9 +415,12 @@ class Data:
 
     def truncl(self, limit):      # truncate list
         """
-        Truncate self.items to a specified value and state how many items are hidden.
+        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:
@@ -400,7 +436,8 @@ class Data:
         """
 
         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))]
+        self.items = ["{0} ({1})".format(y, unsorted.count(y)) for y in sorted(
+            set(unsorted), key = lambda x: -unsorted.count(x))]
         return self
 
 
@@ -409,7 +446,8 @@ class Table(object):
     A wrapper for python-tabulate's Tabulate type.
     """
     
-    def __init__(self, double=False, borders=False, hpadding=" ", maxwidth=80, headers=[]):
+    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).
@@ -446,7 +484,7 @@ class Table(object):
         self._align_cols[i] = align
         for row in self.rows:
             row.columns[i].align = align
-        logger.debug("Column alignment is now {0}".format(str(self._align_cols)))
+        logger.debug("Column alignment is now {0}".format(self._align_cols))
 
     def _gen_list(self):
         """
@@ -466,7 +504,8 @@ class Table(object):
         Output HTML string (wrapper for tabulate)
         """
 
-        output = tabulate(self._gen_list(), self.headers, tablefmt="html", colalign=tuple(self._align_cols))
+        output = tabulate(self._gen_list(), self.headers, tablefmt="html",
+                colalign=tuple(self._align_cols))
         return output
 
     def draw_plain(self):
@@ -474,7 +513,9 @@ class Table(object):
         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))
+        output = tabulate(self._gen_list(), self.headers,
+                tablefmt="fancy_grid" if self.borders
+                else "plain", colalign=tuple(self._align_cols))
         return output + "\n"*2
 
 
@@ -513,8 +554,8 @@ class Row(object):
 
 class Column(object):
     """
-    Object representing a single table cell. This is somewhat of a misnomer - 
-    one column object exists for each cell in the table. Columns are children
+    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.
     """
 
@@ -533,7 +574,7 @@ class PlaintextLine:
     Draw a horizontal line for plain text format, with optional padding/styling.
     """
 
-    def __init__(self, linewidth=80, double=True, vpadding=1, hpadding=""):
+    def __init__(self, linewidth=80, double=True, vpadding=0, hpadding=""):
         """
         Initialise variables
         """
@@ -549,7 +590,9 @@ class PlaintextLine:
         """
 
         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
+        return "\n" * self.vpadding + self.hpadding \
+                +  line * (self.linewidth - 2 * len(self.hpadding)) \
+                + self.hpadding + "\n" * (self.vpadding + 1)
 
 
 class PlaintextBox:
@@ -557,7 +600,8 @@ 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):
+    def __init__(self, content="", double=True, fullwidth=True, linewidth=80,
+            hpadding="\t", vpadding=1):
         """
         Initialise variables
         """
@@ -587,9 +631,12 @@ class PlaintextBox:
 
         # 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("Contentwidth is {0}".format(str(contentwidth)))
-        logger.debug("Longest line is {0}".format(len(max(contentlines, key=len))))
+        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))
@@ -614,7 +661,8 @@ class PlaintextBox:
                 contentlines[i] = res
 
         # Flatten list
-        #   Note list comprehension doesn't work here, so we must iterate through each item
+        #   Note list comprehension doesn't work here, so we must iterate
+        #   through each item
         newlines = []
         for line in contentlines:
             if isinstance(line, list):
@@ -630,9 +678,14 @@ class PlaintextBox:
             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])
+        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)
 
 
@@ -701,7 +754,8 @@ def opentag(tag, block=False, id=None, cl=None, style=None):
         output += " class='" + cl + "'"
     if style:
         output += " style='"
-        output += " ".join("{0}: {1};".format(attr, value) for attr, value in style.items())
+        output += " ".join("{0}: {1};".format(attr, value)
+                for attr, value in style.items())
         output += "'"
     output += '>'
     if block: