logparse / formatting.pyon commit further bugfixing in parsers & clean up formatting.py (cbb12b9)
   1#
   2#   format.py
   3#   
   4#   This file contains global functions for formatting and printing data. This
   5#   file should be imported into individual log-parsing scripts located in
   6#   logs/*. Data is formatted in HTML or plaintext. Writing to disk and/or
   7#   emailng data is left to __main__.py.
   8#
   9
  10import os
  11import re
  12import locale
  13from string import Template
  14
  15import logparse
  16from . import interface, util, config, mail
  17
  18import logging
  19logger = logging.getLogger(__name__)
  20
  21
  22locale.setlocale(locale.LC_ALL, '') # inherit system locale
  23#DEG = "°".encode('unicode_escape')
  24DEG = u'\N{DEGREE SIGN}'
  25CEL = "C"
  26TIMEFMT = "%X"
  27DATEFMT = "%x"
  28CORNERCHARS_DOUBLE = ['╚', '╝', '╗', '╔']
  29CORNERCHARS_SINGLE = ['└', '┘', '┐', '┌']
  30LINECHARS_DOUBLE = ['║', '═']
  31LINECHARS_SINGLE = ['│', '─']
  32BULLET = "• "
  33INDENT = "  "
  34
  35
  36class Output:
  37    """
  38    Base class for a data processor. 
  39    """
  40    
  41    def __init__(self):
  42        self.content = ""
  43        self.destination = ""
  44
  45    def append(self, content):
  46        """
  47        Add a string
  48        """
  49        self.content += content
  50
  51    def write(self, destination=""):
  52        """
  53        Output contents into a file
  54        """
  55        if destination == "":
  56            destination = self.destination
  57        if destination == "":
  58            logger.warning("No destination path provided")
  59            return 1
  60        with open(destination, 'w') as f:
  61            f.write(self.content)
  62            logger.info("Written output to {}".format(destination))
  63
  64
  65class PlaintextOutput(Output):
  66    """
  67    Processes & outputs data in a plaintext form which can be read with cat or plaintext email.
  68    """
  69
  70    def __init__(self, linewidth=80):
  71        self.content = ""
  72        self.destination = ""
  73        self.linewidth = linewidth;
  74
  75    def append_header(self, template=''):
  76        """
  77        Print details with some primitive formatting
  78        """
  79        init_varfilter()
  80        box = PlaintextBox(content=Template("$title $version on $hostname\n\n$time $date").safe_substitute(varsubst), vpadding=2, hpadding="\t\t", linewidth=config.prefs['linewidth'])
  81        line = PlaintextLine(self.linewidth)
  82        self.append(box.draw() + line.draw())
  83
  84    def append_footer(self):
  85        """
  86        Append a horizontal line and some details
  87        """
  88        init_varfilter()
  89        self.append(PlaintextLine(self.linewidth, vpadding=1).draw())
  90        self.append(Template("$hostname $time $date").safe_substitute(varsubst))
  91
  92    def append_section(self, section):
  93        """
  94        Call the appropriate methods to format a section (provided by a parser).
  95        This should be run by interface.py after every instance of parse_log().
  96        """
  97        self.append(PlaintextBox(content=section.title, double=False, fullwidth=False, vpadding=0, hpadding=" ").draw())
  98        self.append('\n'*2)
  99        for data in section.data:
 100            self.append(self._fmt_data(data.subtitle, data.items))
 101            self.append('\n')
 102
 103    def _fmt_data(self, subtitle, data = None):   # write title and data
 104        """
 105        Format the properties of a data object into usable plaintext form with a few fancy symbols/formatting tricks.
 106        Subtitle is required, data is not. If only subtitle is supplied or subtitle + one data item, a single line will be printed.
 107        """
 108        if (subtitle == ""):
 109            logger.warning("No subtitle provided.. skipping section")
 110            return
 111
 112        if (data == None or len(data) == 0):
 113            logger.debug("No data provided.. just printing subtitle")
 114            return subtitle + '\n'
 115        else:
 116            logger.debug("Received data " + str(data))
 117            subtitle += ':'
 118            if (len(data) == 1):
 119                return subtitle + ' ' + data[0] + '\n'
 120            else:
 121                itemoutput = subtitle + '\n'
 122                for datum in data:
 123                    datum = BULLET + datum
 124                    if len(datum) > config.prefs['linewidth'] - 3:
 125                        words = datum.split()
 126                        if max(map(len, words)) > config.prefs['linewidth'] - len(INDENT):
 127                            continue
 128                        res, part, others = [], words[0], words[1:]
 129                        for word in others:
 130                            if 1 + len(word) > config.prefs['linewidth'] - len(part):
 131                                res.append(part)
 132                                part = word
 133                            else:
 134                                part += ' ' + word
 135                        if part:
 136                            res.append(part)
 137                        datum = ('\n    ').join(res)
 138                    datum = INDENT + datum
 139                    itemoutput += datum + '\n'
 140                return itemoutput
 141
 142
 143class HtmlOutput(Output):
 144    """
 145    Process and output data in HTML format.
 146    All HTML formatting functions now reside in this class to differentiate them from plaintext.
 147    """
 148
 149    def __init__(self):
 150        self.content = ""
 151        self.destination = ""
 152        self.css = ""
 153
 154    def embed_css(self, css):
 155        """
 156        Convert stylesheet to inline tags
 157        """
 158        self.content = mail.mailprep(self.content, css)
 159        return self.content
 160
 161    def append_header(self, template):
 162        """
 163        Insert variables into header template file and append HTML tags
 164        """
 165        init_varfilter()
 166        headercontent = Template(open(template, 'r').read())
 167        self.append(headercontent.safe_substitute(varsubst))
 168        self.append(self.opentag('div', id='main'))
 169
 170    def append_footer(self):
 171        """
 172        Close HTML tags that were opened in the template.
 173        TODO: add footer template similar to header template.
 174        """
 175        self.append(self.closetag('div') + self.closetag('body') + self.closetag('html'))
 176
 177    def append_section(self, section):
 178        """
 179        Call the appropriate methods to generate HTML tags for a section (provided by a parser).
 180        This should be run by interface.py after every instance of parse_log().
 181        """
 182        self.append(self.opentag('div', 1, section.title, 'section'))
 183        self.append(self._gen_title(section.title))
 184        for data in section.data:
 185            self.append(self._fmt_data(data.subtitle, data.items))
 186        self.append(self.closetag('div', 1))
 187
 188    def _gen_title(self, title):
 189        """
 190        Format the title for a section
 191        """
 192        if (title == '' or '\n' in title):
 193            logger.error("Invalid title")
 194            raise ValueError 
 195        logger.debug("Writing title for " + title)
 196        return self.tag('h2', False, title)
 197
 198    def _fmt_data(self, subtitle, data = None):
 199        """
 200        Format the properties of a data object into usable HTML tags.
 201        Subtitle is required, data is not. If only subtitle is supplied or subtitle + one data item, a single line will be printed.
 202        """
 203        if (subtitle == ""):
 204            logger.warning("No subtitle provided.. skipping section")
 205            return
 206
 207        if (data == None or len(data) == 0):
 208            logger.debug("No data provided.. just printing subtitle")
 209            return self.tag('p', False, subtitle)
 210        else:
 211            logger.debug("Received data " + str(data))
 212            subtitle += ':'
 213            if (len(data) == 1):
 214                return self.tag('p', False, subtitle + ' ' + data[0])
 215            else:
 216                output = ""
 217                output += self.tag('p', False, subtitle)
 218                output += self.opentag('ul', 1)
 219                coderegex = re.compile('`(.*)`')
 220                for datum in data:
 221                    if datum == "" or datum == None:
 222                        continue
 223                    datum = coderegex.sub(r"<code>\1</code>", str(datum))
 224                    output += self.tag('li', False, datum)
 225                output += self.closetag('ul', True)
 226                return output
 227
 228    def opentag(self, tag, block=False, id=None, cl=None):
 229        """
 230        Write HTML opening tag
 231        """
 232        output = ""
 233        if (block):
 234            output += '\n'
 235        output += '<' + tag
 236        if (id != None):
 237            output += " id='" + id + "'"
 238        if (cl != None):
 239            output += " class='" + cl + "'"
 240        output += '>'
 241        if (block):
 242            output += '\n'
 243        return output
 244
 245    def closetag(self, tag, block=False):
 246        """
 247        Write HTML closing tag
 248        """
 249        if block:
 250            return "\n</" + tag + ">\n"
 251        else:
 252            return "</" + tag + ">"
 253
 254    def tag(self, tag, block=False, content=""):
 255        """
 256        Write HTML opening tag, content, and closing tag
 257        """
 258        o = self.opentag(tag, block)
 259        c = self.closetag(tag, block)
 260        return o + content + c
 261
 262
 263class Section:
 264    """
 265    Each parser should output a Section() which contains the title and returned data.
 266    """
 267
 268    def __init__(self, title):
 269        self.title = title
 270        self.data = []
 271
 272    def append_data(self, data):
 273        self.data.append(data)
 274
 275
 276class Data:
 277    """
 278    Each section (parser) can have one or more Data() objects which are essentially glorified lists.
 279    """
 280    
 281    def __init__(self, subtitle="", items=[]):
 282        self.subtitle = subtitle
 283        self.items = items 
 284
 285    def truncl(self, limit):      # truncate list
 286        """
 287        Truncate self.items to a specified value and state how many items are hidden.
 288        """
 289        if (len(self.items) > limit):
 290            more = str(len(self.items) - limit)
 291            self.items = self.items[:limit]
 292            self.items.append("+ {0} more".format(more))
 293
 294    def orderbyfreq(self):
 295        """
 296        Order a list by frequency of each item, then remove duplicates and append frequency in parentheses.
 297        """
 298        unsorted = list(self.items)
 299        self.items = ["{0} ({1})".format(y, unsorted.count(y)) for y in sorted(set(unsorted), key = lambda x: -unsorted.count(x))]
 300
 301
 302class PlaintextLine:
 303    """
 304    Draw a horizontal line for plain text format, with optional padding/styling.
 305    """
 306
 307    def __init__(self, linewidth=80, double=True, vpadding=1, hpadding=""):
 308        self.linewidth = linewidth
 309        self.double = False
 310        self.vpadding = vpadding
 311        self.hpadding = hpadding
 312
 313    def draw(self):
 314        line = (LINECHARS_DOUBLE[1] if self.double else LINECHARS_SINGLE[1])
 315        return "\n" * self.vpadding + self.hpadding +  line * (self.linewidth - 2 * len(self.hpadding)) + self.hpadding + "\n" * self.vpadding
 316
 317
 318class PlaintextBox:
 319    """
 320    Draw a rectangular box around text, with customisable padding/size/style
 321    """
 322
 323    def __init__(self, content="", double=True, fullwidth=True, linewidth=80, hpadding="\t", vpadding=1):
 324        self.content = content
 325        self.fullwidth = fullwidth
 326        self.linewidth = linewidth
 327        self.hpadding = hpadding 
 328        self.vpadding = vpadding
 329        self.double = double
 330
 331    def draw(self):
 332
 333        if self.double == True:
 334            cornerchars = CORNERCHARS_DOUBLE
 335            linechars = LINECHARS_DOUBLE
 336        else:
 337            cornerchars = CORNERCHARS_SINGLE
 338            linechars = LINECHARS_SINGLE
 339
 340        # Check hpadding has a definite width
 341        self.hpadding = self.hpadding.replace("\t", " "*4)
 342
 343        # Calculate number of characters per line
 344        contentlines = self.content.splitlines()
 345        contentwidth = int((self.linewidth if self.linewidth > 0 else 80) if self.content.splitlines() else len(max(contentlines, key=len)))
 346        logger.debug("Contentwidth is {0}".format(str(contentwidth)))
 347        logger.debug("Longest line is {0}".format(len(max(contentlines, key=len))))
 348        contentwidth += -2*(len(self.hpadding)+1)
 349        if not self.fullwidth:
 350            longestline = len(max(contentlines, key=len))
 351            if longestline <= self.linewidth - 2*(len(self.hpadding)+1):
 352                contentwidth = longestline
 353
 354        # Split lines that are too long
 355        for i, line in enumerate(contentlines):
 356            if len(line) > contentwidth:
 357                words = line.split()
 358                if max(map(len, words)) > contentwidth:
 359                    continue
 360                res, part, others = [], words[0], words[1:]
 361                for word in others:
 362                    if len(' ') + len(word) > contentwidth - len(part):
 363                        res.append(part)
 364                        part = word
 365                    else:
 366                        part += ' ' + word
 367                if part:
 368                    res.append(part)
 369                contentlines[i] = res
 370
 371        # Flatten list
 372        #   Note list comprehension doesn't work here, so we must iterate through each item
 373        newlines = []
 374        for line in contentlines:
 375            if isinstance(line, list):
 376                for subline in line:
 377                    newlines.append(subline)
 378            else:
 379                newlines.append(line)
 380        contentlines = newlines
 381               
 382        # Add vertical padding
 383        for _ in range(self.vpadding):
 384            contentlines.insert(0, ' '*contentwidth)
 385            contentlines.append(' '*contentwidth)
 386
 387        # Insert horizontal padding on lines that are too short
 388        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]
 389        contentlines.insert(0, cornerchars[3] + linechars[1] * (contentwidth + len(self.hpadding)*2) + cornerchars[2])
 390        contentlines.append(cornerchars[0] + linechars[1] * (contentwidth + len(self.hpadding)*2) + cornerchars[1])
 391        return ('\n').join(contentlines)
 392
 393
 394def init_varfilter():
 395    global varfilter
 396    global varpattern
 397    global varsubst
 398    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']))}
 399    varfilter = dict((re.escape(k), v) for k, v in varfilter.items())
 400    varpattern = re.compile("|".join(varfilter.keys()))
 401    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'])))
 402
 403def backticks(l):
 404    return ["`" + x + "`" for x in l]
 405
 406
 407def plural(noun, quantity):
 408    """
 409    Return "1 noun" or "n nouns"
 410    """
 411    if (quantity == 1):
 412        return(str(quantity) + " " + noun)
 413    else:
 414        return(str(quantity) + " " + noun + "s")
 415
 416
 417def parsesize(num, suffix='B'):
 418    """
 419    Return human-readable size from number of bytes
 420    """
 421    for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
 422        if abs(num) < 1024.0:
 423            return "%3.1f %s%s" % (num, unit, suffix)
 424        num /= 1024.0
 425    return "%.1f%s%s" % (num, 'Yi', suffix)
 426
 427
 428def fsubject(template):
 429    """
 430    Replace variables in the title template provided in config
 431    """
 432    r = varpattern.sub(lambda m: varfilter[re.escape(m.group(0))], template)
 433    logger.debug("Returning subject line " + r)
 434    return r