7e647a91643cef8bbc889a1b1334ca35f30dd2ee
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