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