logparse / formatting.pyon commit add parser-specific docs & rewrite sudo parser for journald (aa62c56)
   1# -*- coding: utf-8 -*-
   2
   3"""   
   4This file contains global functions for formatting and printing data. This file
   5should be imported into individual log-parsing scripts located in the default
   6logparse.parsers module or in the user-supplied parsers directory. Data is
   7formatted in HTML or plaintext. Writing to disk and/or emailng data is left to
   8interface.py.
   9"""
  10
  11import os
  12import re
  13import locale
  14from string import Template
  15from math import floor, ceil
  16from tabulate import tabulate
  17
  18import logparse
  19from logparse import interface, util, mail, config
  20
  21import logging
  22logger = None
  23logger = logging.getLogger(__name__)
  24
  25
  26locale.setlocale(locale.LC_ALL, '') # inherit system locale
  27
  28
  29DEG = u'\N{DEGREE SIGN}'
  30CEL = "C"
  31TIMEFMT = "%X"
  32DATEFMT = "%x"
  33CORNERCHARS_DOUBLE = ['╚', '╝', '╗', '╔']
  34CORNERCHARS_SINGLE = ['└', '┘', '┐', '┌']
  35LINECHARS_DOUBLE = ['║', '═']
  36LINECHARS_SINGLE = ['│', '─']
  37JXNCHARS_DOUBLE = ['╠', '╣', '╦', '╩', '╬']
  38JXNCHARS_SINGLE = ['├', '┤', '┬', '┴', '┼']
  39BULLET = "• "
  40INDENT = "  "
  41
  42
  43global VARSUBST
  44
  45
  46def init_var():
  47    """
  48    Initialise variable substitution templates (should be called before doing
  49    any substitutions)
  50    """
  51
  52    global VARSUBST
  53    css_path = config.prefs.get("html", "css")
  54    if config.prefs.getboolean("html", "css-relpath"):
  55        if interface.argparser.parse_args().no_write:
  56            css_path = os.path.relpath(css_path, ".")
  57        elif interface.argparser.parse_args().destination:
  58            css_path = os.path.relpath(
  59                    css_path, interface.argparser.parse_args().destination())
  60        elif config.prefs.get("logparse", "output"):
  61            css_path = os.path.relpath(
  62                    css_path, config.prefs.get("logparse", "output"))
  63    VARSUBST = {
  64        "title": config.prefs.get("logparse", "title"),
  65        "date": interface.start.strftime(DATEFMT),
  66        "time": interface.start.strftime(TIMEFMT),
  67        "hostname": util.hostname(config.prefs.get(
  68            "logparse", "hostname-path")),
  69        "version": logparse.__version__,
  70        "css": css_path
  71    }
  72
  73
  74class Output:
  75    """
  76    Base class for a data processor. 
  77    """
  78    
  79    def __init__(self):
  80        self.content = ""
  81        self.destination = ""
  82
  83    def append(self, content):
  84        """
  85        Add a string
  86        """
  87
  88        self.content += content
  89
  90    def write(self, destination=""):
  91        """
  92        Output contents into a file
  93        """
  94
  95        if destination == "":
  96            destination = self.destination
  97        if destination == "":
  98            logger.warning("No destination path provided")
  99            return 1
 100        with open(destination, 'w') as f:
 101            f.write(self.content)
 102            logger.info("Written output to {}".format(destination))
 103
 104    def print_stdout(self, lines=False):
 105        """
 106        Echo the contents to the console
 107        """
 108
 109        print()
 110        if lines:
 111            line = PlaintextLine(linewidth=
 112                    config.prefs.getint("plain", "linewidth"), double=True)
 113            print(line.draw())
 114        print(self.content)
 115        if lines:
 116            print(line.draw())
 117        print()
 118
 119
 120class PlaintextOutput(Output):
 121    """
 122    Processes & outputs data in a plaintext form which can be read with cat or
 123    plaintext email.
 124    """
 125
 126    def __init__(self, linewidth=80):
 127        self.content = ""
 128        self.destination = ""
 129        self.linewidth = linewidth;
 130
 131    def append_header(self, template=''):
 132        """
 133        Print details with some primitive formatting
 134        """
 135        box = PlaintextBox(content=
 136                Template("$title $version on $hostname\n\n$time $date")
 137                .safe_substitute(VARSUBST),
 138                vpadding=2, hpadding="\t\t", linewidth=self.linewidth)
 139        line = PlaintextLine(self.linewidth)
 140        self.append(box.draw() + line.draw())
 141
 142    def append_footer(self):
 143        """
 144        Append a horizontal line and some details
 145        """
 146        self.append(PlaintextLine(self.linewidth, vpadding=1).draw())
 147        self.append(Template("$hostname $time $date").safe_substitute(VARSUBST))
 148
 149    def append_section(self, section):
 150        """
 151        Call the appropriate methods to format a section (provided by a parser).
 152        This should be run by interface.py after every instance of parse_log().
 153        """
 154
 155        if section == None:
 156            logger.warning("Received null section")
 157            return
 158        self.append(PlaintextBox(
 159            content=section.title, double=False,
 160            fullwidth=False, vpadding=0, hpadding=" ").draw())
 161        if section.period and section.period.unique:
 162            self.append("\n(since {0})".format(
 163                section.period.startdate.strftime(DATEFMT + " " + TIMEFMT)))
 164        self.append('\n'*2)
 165        for data in section.data:
 166            self.append(self._fmt_data(data.subtitle, data.items))
 167            self.append('\n')
 168        for table in section.tables:
 169            self.append(table.draw_plain())
 170        self.append("\n")
 171
 172    def _fmt_data(self, subtitle, data = None):   # write title and data
 173        """
 174        Format the properties of a data object into usable plaintext form with
 175        a few fancy symbols/formatting tricks. Subtitle is required, data is
 176        not. If only subtitle is supplied or subtitle + one data item, a single
 177        line will be printed.
 178        """
 179
 180        if (subtitle == ""):
 181            logger.warning("No subtitle provided.. skipping section")
 182            return
 183
 184        if (data == None or len(data) == 0):
 185            logger.debug("No data provided.. just printing subtitle")
 186            return subtitle + '\n'
 187        else:
 188            logger.debug("Received data " + str(data))
 189            subtitle += ':'
 190            if (len(data) == 1):
 191                return subtitle + ' ' + data[0] + '\n'
 192            else:
 193                itemoutput = subtitle + '\n'
 194                for datum in data:
 195                    datum = BULLET + datum
 196                    if len(datum) > self.linewidth - 3:
 197                        words = datum.split()
 198                        if max(map(len, words)) > self.linewidth - len(INDENT):
 199                            continue
 200                        res, part, others = [], words[0], words[1:]
 201                        for word in others:
 202                            if 1 + len(word) > self.linewidth - len(part):
 203                                res.append(part)
 204                                part = word
 205                            else:
 206                                part += ' ' + word
 207                        if part:
 208                            res.append(part)
 209                        datum = ('\n    ').join(res)
 210                    datum = INDENT + datum
 211                    itemoutput += datum + '\n'
 212                return itemoutput
 213
 214class HtmlOutput(Output):
 215    """
 216    Process and output data in HTML format. All HTML formatting functions now
 217    reside in this class to differentiate them from plain text.
 218    """
 219
 220    def __init__(self):
 221        """
 222        Initialise variables (no parameters required for initialisation)
 223        """
 224
 225        self.content = ""
 226        self.destination = ""
 227        self.css = ""
 228        self._embedded = ""
 229
 230    def embed_css(self, css):
 231        """
 232        Convert stylesheet to inline tags
 233        """
 234
 235        if not self._embedded:
 236            self._embedded = mail.mailprep(re.sub(
 237                ".*" + re.escape(VARSUBST['css']) + ".*\n", "", self.content),
 238                css)
 239        return self._embedded
 240
 241    def write_embedded(self, destination = ""):
 242        """
 243        Write contents to file with inline CSS tags
 244        """
 245
 246        logger.debug("Writing HTML with embedded styles to " + destination)
 247        if not self._embedded:
 248            logger.warning("Call to write_embedded before embed_css - \
 249                    embedding stylesheets immediately")
 250            self.embed_css(config.prefs.get("html", "css"))
 251        if destination == "":
 252            destination = self.destination
 253        if destination == "":
 254            logger.warning("No destination path provided")
 255            return 1
 256        with open(destination, 'w') as f:
 257            f.write(self._embedded)
 258            logger.info("Written output to {}".format(destination))
 259
 260
 261    def append_header(self, template):
 262        """
 263        Insert variables into header template file and append HTML tags
 264        """
 265
 266        self.headertemplate = template
 267        headercontent = Template(open(template, 'r').read())
 268        self.append(headercontent.safe_substitute(VARSUBST))
 269        self.append(opentag('div', id='main'))
 270
 271    def append_footer(self):
 272        """
 273        Close HTML tags that were opened in the template.
 274        TODO: add footer template similar to header template.
 275        """
 276
 277        self.append(closetag('div') + closetag('body') + closetag('html'))
 278
 279    def append_section(self, section):
 280        """
 281        Call the appropriate methods to generate HTML tags for a section
 282        (provided by a parser). This should be run by interface.py after every
 283        instance of parse_log().
 284        """
 285
 286        if section == None:
 287            logger.warning("Received null section")
 288            return
 289        self.append(opentag('div', 1, section.title, 'section'))
 290        self.append(self._gen_title(section.title))
 291        if section.period and section.period.unique:
 292            self.append(self._fmt_period(section.period))
 293        for data in section.data:
 294            self.append(self._fmt_data(data.subtitle, data.items, data.severity))
 295        for table in section.tables:
 296            self.append(table.draw_html())
 297        self.append(closetag('div', 1))
 298
 299    def _gen_title(self, title):
 300        """
 301        Format the title for a section
 302        """
 303
 304        if (title == '' or '\n' in title):
 305            logger.error("Invalid title")
 306            raise ValueError 
 307        logger.debug("Writing title for " + title)
 308        return tag('h2', False, title)
 309
 310    def _fmt_data(self, subtitle, data=None, severity=0):
 311        """
 312        Format the properties of a data object into usable HTML tags.
 313        Subtitle is required, data is not. If only subtitle is supplied or
 314        subtitle + one data item, a single line will be printed.
 315        """
 316
 317        if (subtitle == ""):
 318            logger.warning("No subtitle provided.. skipping section")
 319            return
 320
 321        if (data == None or len(data) == 0):
 322            logger.debug("No data provided.. just printing subtitle")
 323            return tag('p', False, subtitle, cl="severity-" + str(severity))
 324        else:
 325            logger.debug("Received data " + str(data))
 326            subtitle += ':'
 327            if (len(data) == 1):
 328                return tag('p', False, subtitle + ' ' + data[0],
 329                        cl="severity-" + str(severity))
 330            else:
 331                output = ""
 332                output += tag('p', False, subtitle,
 333                        cl="severity-" + str(severity))
 334                output += opentag('ul', 1)
 335                coderegex = re.compile('`(.*)`')
 336                for datum in data:
 337                    if datum == "" or datum == None:
 338                        continue
 339                    datum = coderegex.sub(r"<code>\1</code>", str(datum))
 340                    output += tag('li', False, datum)
 341                output += closetag('ul', True)
 342                return output
 343
 344    def _fmt_period(self, period):
 345        output = ''
 346        output += opentag('span', cl='period')
 347        output += "since " + period.startdate.strftime(DATEFMT + " " + TIMEFMT)
 348        output += closetag('span')
 349        return output
 350
 351    def print_stdout_embedded(self, lines=False):
 352        """
 353        Echo the version with embedded style tags to the console
 354        """
 355
 356        if self._embedded == "":
 357            self.embed_css(config.prefs.get("html", "css"))
 358        print()
 359        if lines:
 360            line = PlaintextLine(linewidth=
 361                    config.prefs.getint("plain", "linewidth"), double=True)
 362            print(line.draw())
 363        print(self._embedded)
 364        if lines:
 365            print(line.draw())
 366        print()
 367
 368
 369class Section:
 370    """
 371    Each parser should output a Section() which contains the title, returned
 372    data, and applicable time period.
 373    """
 374
 375    def __init__(self, title, period=None):
 376        self.title = title
 377        self.data = []
 378        self.tables = []
 379        self.period = util.LogPeriod(self.title)
 380
 381    def append_data(self, data):
 382        self.data.append(data)
 383
 384    def append_table(self, table):
 385        self.tables.append(table)
 386
 387
 388class Data:
 389    """
 390    Each section (parser) can have one or more Data() objects which are
 391    essentially glorified lists with titles (`self.subtitle`).
 392    """
 393    
 394    def __init__(self, subtitle="", items=[], severity=0):
 395        """
 396        Initialise variables. No parameters are enforced upon initialisation,
 397        but at least the subtitle is required for valid output. Severity refers
 398        to the importance of the data (integer from 0 to 5). e.g. a failed
 399        system should have severity 5 and will be formatted appropriately by
 400        the Output object.
 401        """
 402
 403        self.subtitle = subtitle
 404        self.items = items 
 405        self.severity = severity
 406
 407    def truncl(self, limit):      # truncate list
 408        """
 409        Truncate self.items to a specified value and state how many items are
 410        hidden.
 411        """
 412
 413        if (len(self.items) > limit):
 414            more = len(self.items) - limit
 415            if more == 1:
 416                return 0
 417            self.items = self.items[:limit]
 418            self.items.append("+ {0} more".format(str(more)))
 419        return self
 420
 421    def orderbyfreq(self):
 422        """
 423        Order a list by frequency of each item, then remove duplicates and
 424        append frequency in parentheses.
 425        """
 426
 427        unsorted = list(self.items)
 428        self.items = ["{0} ({1})".format(y, unsorted.count(y)) for y in sorted(
 429            set(unsorted), key = lambda x: -unsorted.count(x))]
 430        return self
 431
 432
 433class Table(object):
 434    """
 435    A wrapper for python-tabulate's Tabulate type.
 436    """
 437    
 438    def __init__(self, double=False, borders=False, hpadding=" ",
 439            maxwidth=80, headers=[]):
 440        """
 441        Initialise variables. Note the keymap is used for a faster index map,
 442        but is not currently used anywhere (may be removed in future).
 443        """
 444
 445        self.rows =  []     # List of row objects
 446        self.keymap = {}    # For fast lookup of row by value of first column 
 447        self.double = double
 448        self.borders = borders
 449        self.align_cols = []
 450        self.hpadding = hpadding
 451        self.maxwidth = maxwidth
 452        self.headers = headers
 453        self._align_cols = []
 454
 455    def add_row(self, row):
 456        """
 457        Append a row to the list and amend index mapping
 458        """
 459
 460        self.rows.append(row)
 461        if len(row.columns) > 0:
 462            self.keymap[row.columns[0]] = row
 463
 464        logger.debug("Added row with {0} columns".format(str(len(row.columns))))
 465
 466    def align_column(self, i, align):
 467        """
 468        Set alignment for the 'i'th column (`align` should be 'l', 'c' or 'r')
 469        """
 470
 471        while len(self._align_cols) -1 < i:
 472            self._align_cols.append("")
 473        self._align_cols[i] = align
 474        for row in self.rows:
 475            row.columns[i].align = align
 476        logger.debug("Column alignment is now {0}".format(self._align_cols))
 477
 478    def _gen_list(self):
 479        """
 480        Used locally for organising rows and columns into a 2D list structure
 481        """
 482
 483        hierarchy = []
 484        for row in self.rows:
 485            row_data = []
 486            for column in row.columns:
 487                row_data.append(column.content)
 488            hierarchy.append(row_data)
 489        return hierarchy
 490
 491    def draw_html(self):
 492        """
 493        Output HTML string (wrapper for tabulate)
 494        """
 495
 496        output = tabulate(self._gen_list(), self.headers, tablefmt="html",
 497                colalign=tuple(self._align_cols))
 498        return output
 499
 500    def draw_plain(self):
 501        """
 502        Output plain text string (wrapper for tabulate)
 503        """
 504
 505        output = tabulate(self._gen_list(), self.headers,
 506                tablefmt="fancy_grid" if self.borders
 507                else "plain", colalign=tuple(self._align_cols))
 508        return output + "\n"*2
 509
 510
 511class Row(object):
 512    """
 513    Object representing a literal row in a 2D table with the individual cells
 514    in the row represented by columns[].
 515    """
 516    
 517    def __init__(self, columns=[], header=False):
 518        """
 519        Initialise variables. The variable n is used locally to keep track of
 520        the row width.
 521        """
 522
 523        self.columns = columns
 524        self.header = header
 525        self.n = len(self.columns)
 526
 527    def add_column(self, column):
 528        """
 529        Append a single cell horizontally and increment the cell count
 530        """
 531
 532        self.columns.append(column)
 533        self.n += 1
 534
 535    def rm_column(self, column):
 536        """
 537        Remove the specified column object and decrement the cell count
 538        """
 539
 540        self.remove(column)
 541        self.n -= 1
 542
 543
 544class Column(object):
 545    """
 546    Object representing a single table cell. This is somewhat of a misnomer - 
 547    one column object exists for each cell in the table. Columns are children
 548    of rows.
 549    """
 550
 551    def __init__(self, content="", align="right"):
 552        """
 553        Initialise variables. The align property sets the alignment of a single
 554        cell ('l', 'c', or 'r').
 555        """
 556
 557        self.content = content
 558        self.align = align
 559
 560
 561class PlaintextLine:
 562    """
 563    Draw a horizontal line for plain text format, with optional padding/styling.
 564    """
 565
 566    def __init__(self, linewidth=80, double=True, vpadding=1, hpadding=""):
 567        """
 568        Initialise variables
 569        """
 570
 571        self.linewidth = linewidth
 572        self.double = double
 573        self.vpadding = vpadding
 574        self.hpadding = hpadding
 575
 576    def draw(self):
 577        """
 578        Output a plain text string based on the current object parameters
 579        """
 580
 581        line = (LINECHARS_DOUBLE[1] if self.double else LINECHARS_SINGLE[1])
 582        return "\n" * self.vpadding + self.hpadding \
 583                +  line * (self.linewidth - 2 * len(self.hpadding)) \
 584                + self.hpadding + "\n" * self.vpadding
 585
 586
 587class PlaintextBox:
 588    """
 589    Draw a rectangular box around text, with customisable padding/size/style
 590    """
 591
 592    def __init__(self, content="", double=True, fullwidth=True, linewidth=80,
 593            hpadding="\t", vpadding=1):
 594        """
 595        Initialise variables
 596        """
 597        self.content = content
 598        self.fullwidth = fullwidth
 599        self.linewidth = linewidth
 600        self.hpadding = hpadding 
 601        self.vpadding = vpadding
 602        self.double = double
 603
 604    def draw(self):
 605        """
 606        Output a plain text string based on the current object parameters. This
 607        involves calculating the text width, breaking text at the maximum line
 608        length, and then drawing a box around it all.
 609        """
 610
 611        if self.double == True:
 612            cornerchars = CORNERCHARS_DOUBLE
 613            linechars = LINECHARS_DOUBLE
 614        else:
 615            cornerchars = CORNERCHARS_SINGLE
 616            linechars = LINECHARS_SINGLE
 617
 618        # Check hpadding has a definite width
 619        self.hpadding = self.hpadding.replace("\t", " "*4)
 620
 621        # Calculate number of characters per line
 622        contentlines = self.content.splitlines()
 623        contentwidth = int((self.linewidth if self.linewidth > 0 else 80)
 624                if self.content.splitlines()
 625                else len(max(contentlines, key=len)))
 626        logger.debug("Contentwidth is {0}".format(str(contentwidth)))
 627        logger.debug("Longest line is {0}".format(
 628            len(max(contentlines, key=len))))
 629        contentwidth += -2*(len(self.hpadding)+1)
 630        if not self.fullwidth:
 631            longestline = len(max(contentlines, key=len))
 632            if longestline <= self.linewidth - 2*(len(self.hpadding)+1):
 633                contentwidth = longestline
 634
 635        # Split lines that are too long
 636        for i, line in enumerate(contentlines):
 637            if len(line) > contentwidth:
 638                words = line.split()
 639                if max(map(len, words)) > contentwidth:
 640                    continue
 641                res, part, others = [], words[0], words[1:]
 642                for word in others:
 643                    if len(' ') + len(word) > contentwidth - len(part):
 644                        res.append(part)
 645                        part = word
 646                    else:
 647                        part += ' ' + word
 648                if part:
 649                    res.append(part)
 650                contentlines[i] = res
 651
 652        # Flatten list
 653        #   Note list comprehension doesn't work here, so we must iterate
 654        #   through each item
 655        newlines = []
 656        for line in contentlines:
 657            if isinstance(line, list):
 658                for subline in line:
 659                    newlines.append(subline)
 660            else:
 661                newlines.append(line)
 662        contentlines = newlines
 663               
 664        # Add vertical padding
 665        for _ in range(self.vpadding):
 666            contentlines.insert(0, ' '*contentwidth)
 667            contentlines.append(' '*contentwidth)
 668
 669        # Insert horizontal padding on lines that are too short
 670        contentlines = [linechars[0] + self.hpadding + x
 671                + ' '*(self.linewidth-(len(x)+2*len(self.hpadding)+2)
 672                    if len(x) < contentwidth else 0)
 673                + self.hpadding + linechars[0] for x in contentlines]
 674        contentlines.insert(0, cornerchars[3] + linechars[1] 
 675                * (contentwidth + len(self.hpadding)*2) + cornerchars[2])
 676        contentlines.append(cornerchars[0] + linechars[1]
 677                * (contentwidth + len(self.hpadding)*2) + cornerchars[1])
 678        return ('\n').join(contentlines)
 679
 680
 681def backticks(l):
 682    """
 683    Surround every item in a list by backticks. Used for showing code in both
 684    HTML and plain text formats (converted to <code> tags for HTML)
 685    """
 686
 687    return ["`" + x + "`" for x in l]
 688
 689
 690def plural(noun, quantity, print_quantity=True):
 691    """
 692    Return "1 noun" or "n nouns"
 693    """
 694
 695    if (quantity == 1):
 696        if print_quantity:
 697            return(str(quantity) + " " + noun)
 698        else:
 699            return noun
 700    else:
 701        if noun.endswith("s"):
 702            noun += "e"
 703        if print_quantity:
 704            return(str(quantity) + " " + noun + "s")
 705        else:
 706            return noun + "s"
 707
 708
 709def parsesize(num, suffix='B'):
 710    """
 711    Return human-readable size from number of bytes
 712    """
 713
 714    for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
 715        if abs(num) < 1024.0:
 716            return "%3.1f %s%s" % (num, unit, suffix)
 717        num /= 1024.0
 718    return "%.1f%s%s" % (num, 'Yi', suffix)
 719
 720
 721def fsubject(subject):
 722    """
 723    Replace variables in the title template provided in config
 724    """
 725
 726    r = Template(subject).safe_substitute(VARSUBST)
 727    logger.debug("Returning subject line " + r)
 728    return r
 729
 730
 731def opentag(tag, block=False, id=None, cl=None, style=None):
 732    """
 733    Write HTML opening tag
 734    """
 735
 736    output = ""
 737    if block:
 738        output += '\n'
 739    output += '<' + tag
 740    if id:
 741        output += " id='" + id + "'"
 742    if cl:
 743        output += " class='" + cl + "'"
 744    if style:
 745        output += " style='"
 746        output += " ".join("{0}: {1};".format(attr, value)
 747                for attr, value in style.items())
 748        output += "'"
 749    output += '>'
 750    if block:
 751        output += '\n'
 752    return output
 753
 754
 755def closetag(tag, block=False):
 756    """
 757    Write HTML closing tag
 758    """
 759
 760    if block:
 761        return "\n</" + tag + ">\n"
 762    else:
 763        return "</" + tag + ">"
 764
 765
 766def tag(tag, block=False, content="", id=None, cl=None, style=None):
 767    """
 768    Write HTML opening tag, content, and closing tag
 769    """
 770
 771    o = opentag(tag, block, id, cl, style)
 772    c = closetag(tag, block)
 773    return o + content + c