llvmbot monitor: Convert table output to a builder class

We could import a library to do this but it doesn't seem
worth it just for tables.

This uses a content manager so you don't have to write the
begin and end of each cell/row/table. Each method returns
self so you can just keep calling methods to build what you
need.

Change-Id: I8eef664f41039a9fbc662a148f292efd61866694
diff --git a/monitor/bot-status.py b/monitor/bot-status.py
index be1801f..9bf60d7 100755
--- a/monitor/bot-status.py
+++ b/monitor/bot-status.py
@@ -21,6 +21,7 @@
 # to download multiple files.
 import requests
 from textwrap import dedent
+from make_table import Table
 
 from buildkite_status import get_buildkite_bots_status
 
@@ -130,6 +131,7 @@
 
   return bot_cache
 
+
 def write_bot_status(config, output_file, bots_status):
   temp = tempfile.NamedTemporaryFile(mode='w+', delete=False)
 
@@ -157,8 +159,6 @@
           "Build In Progress",
   ]
   num_columns = len(column_titles)
-  column_titles_html = "<tr>{}</tr>\n".format(
-          "".join(["<th>{}</th>".format(t) for t in column_titles]))
 
   # The first table should also say when this was generated.
   # If we were to put this in its own header only table, it would
@@ -167,85 +167,83 @@
 
   # Dump all servers / bots
   for server in filter(not_ignored, config):
-    base_url = server['base_url']
-    builder_url = server['builder_url']
-    build_url = server['build_url']
+    with Table(temp) as table:
+      table.Border(0).Cellspacing(1).Cellpadding(2)
 
-    temp.write("<table border=0 cellspacing=1 cellpadding=2>\n")
-    temp.write("<tr><td colspan={}>&nbsp;</td><tr>\n".format(num_columns))
-    if first:
-      temp.write("<tr><th colspan={}>Generated {} ({})</td><tr>\n"
-               .format(num_columns, datetime.today().ctime(),
-                       time.tzname[time.daylight]))
-      temp.write("<tr><td colspan={}>&nbsp;</td><tr>\n".format(num_columns))
-      first = False
+      table.AddRow().AddCell().Colspan(num_columns)
 
-    temp.write("<tr><th colspan={}>{}</td><tr>\n"
-               .format(num_columns, server['name']))
+      if first:
+        table.AddRow().AddHeader("Generated {} ({})".format(
+            datetime.today().ctime(), time.tzname[time.daylight])).Colspan(num_columns)
+        table.AddRow().AddCell().Colspan(num_columns)
+        first = False
 
-    for builder in server['builders']:
-      temp.write("<tr><td colspan={}>&nbsp;</td><tr>\n".format(num_columns))
-      temp.write("<tr><th colspan={}>{}</th><tr>\n".format(num_columns, builder['name']))
-      temp.write(column_titles_html)
-      temp.write("<tbody>\n")
-      for bot in builder['bots']:
-        temp.write("<tr>\n")
-        logging.debug("Writing out status for {}".format(bot['name']))
-        try:
-          status = bots_status[(base_url, bot['name'])]
-        except KeyError:
-          temp.write("  <td colspan={}>{} is offline!</td>\n</tr>\n"
-                      .format(num_columns, bot['name']))
-          continue
-        else:
-            if not status.get('valid', True):
-                temp.write("  <td colspan={}>Could not read status for {}!</td>\n</tr>\n"
-                            .format(num_columns, bot['name']))
+      table.AddRow().AddHeader(server['name']).Colspan(num_columns)
+
+      for builder in server['builders']:
+        table.AddRow().AddCell().Colspan(num_columns)
+        table.AddRow().AddHeader(builder['name']).Colspan(num_columns)
+        title_row = table.AddRow()
+        for title in column_titles:
+          title_row.AddHeader(title)
+
+        table.BeginBody()
+
+        for bot in builder['bots']:
+          logging.debug("Writing out status for {}".format(bot['name']))
+
+          row = table.AddRow()
+          base_url = server['base_url']
+          try:
+            status = bots_status[(base_url, bot['name'])]
+          except KeyError:
+            row.AddCell("{} is offline!".format(bot['name'])).Colspan(num_columns)
+            continue
+          else:
+              if not status.get('valid', True):
+                row.AddCell("Could not read status for {}!".format(
+                  bot['name'])).Colspan(num_columns)
                 continue
 
-        temp.write("  <td><a href='{}'>{}</a></td>\n".format(
-            status['builder_url'], bot['name']))
-        temp.write("  <td><font color='{}'>{}</font></td>\n"
-                   .format('red' if status['fail'] else 'green',
-                           'FAIL' if status['fail'] else 'PASS'))
-        empty_cell="  <td>&nbsp;</td>\n"
-        if 'time_since' in status:
-          time_since = status['time_since']
-          # No build should be taking more than a day
-          if time_since > timedelta(hours=24):
-              time_since = "<p style=\"color:red\">{}</p>".format(
-                      time_since)
-          else:
-              time_since = str(time_since)
+          row.AddCell("<a href='{}'>{}</a>".format(status['builder_url'], bot['name']))
+          row.AddCell("<font color='{}'>{}</font>"
+                     .format('red' if status['fail'] else 'green',
+                             'FAIL' if status['fail'] else 'PASS'))
 
-          temp.write("  <td>{}</td>\n".format(time_since))
-        else:
-          temp.write(empty_cell)
-        if 'duration' in status:
-          temp.write("  <td>{}</td>\n".format(status['duration']))
-        else:
-          temp.write(empty_cell)
-        if 'number' in status:
-          temp.write("  <td><a href='{}'>{}</a></td>\n".format(
-              status['build_url'], status['number']))
-        else:
-          temp.write(empty_cell)
-        if 'steps' in status and status['steps']:
-          def render_step(name, result):
-            return "<font color='{}'>{}</font>".format(RESULT_COLORS[result], name)
-          step_list = ', '.join(render_step(name, result) for name, result in status['steps'])
-          temp.write("  <td style=\"text-align:center\">{}</td>\n".format(step_list))
-        else:
-          temp.write(empty_cell)
-        if 'next_in_progress' in status:
-          temp.write("  <td>{}</td>\n".format(
-                     "Yes" if status['next_in_progress'] else "No"))
-        else:
-          # No value means we don't know either way.
-          temp.write(empty_cell)
-        temp.write("</tr>\n")
-      temp.write("</tbody>\n")
-    temp.write("</table>\n")
+          time_since_cell = row.AddCell()
+          if 'time_since' in status:
+            time_since = status['time_since']
+            # No build should be taking more than a day
+            if time_since > timedelta(hours=24):
+                time_since = "<p style=\"color:red\">{}</p>".format(
+                        time_since)
+            else:
+                time_since = str(time_since)
+
+            time_since_cell.Content(time_since)
+
+          duration_cell = row.AddCell()
+          if 'duration' in status:
+            duration_cell.Content(status['duration'])
+
+          number_cell = row.AddCell()
+          if 'number' in status:
+            number_cell.Content("<a href='{}'>{}</a>".format(
+                status['build_url'], status['number']))
+
+          steps_cell = row.AddCell()
+          if 'steps' in status and status['steps']:
+            def render_step(name, result):
+              return "<font color='{}'>{}</font>".format(RESULT_COLORS[result], name)
+            step_list = ', '.join(render_step(name, result) for name, result in status['steps'])
+            steps_cell.Style("text-align:center").Content(step_list)
+
+          next_in_progress_cell = row.AddCell()
+          if 'next_in_progress' in status:
+            next_in_progress_cell.Content(
+                       "Yes" if status['next_in_progress'] else "No")
+
+        table.EndBody()
 
   # Move temp to main (atomic change)
   temp.close()
diff --git a/monitor/make_table.py b/monitor/make_table.py
new file mode 100644
index 0000000..2f115b5
--- /dev/null
+++ b/monitor/make_table.py
@@ -0,0 +1,132 @@
+# This file contains a basic "builder" style API for making HTML tables
+# and writing them to a file once finished.
+#
+# Use it as follows:
+# with Table(outfile) as table:
+#  table.AddRow().AddCell("foo")
+#
+# To get:
+# <table>
+#   <td>foo</td>
+# </table>
+#
+# Methods return a reference to self, or to the new thing you added.
+# This means you can keep chaining calls to build what you want.
+#
+# table.AddRow().AddCell("foo").Colspan(1).Style("mystyle")
+
+
+class TableCell(object):
+    def __init__(self, name, content=None):
+        self.name = name
+        self.content = content
+        self.style = None
+        self.colspan = None
+
+    def Style(self, style):
+        self.style = style
+        return self
+
+    def Colspan(self, colspan):
+        self.colspan = colspan
+        return self
+
+    def Content(self, content):
+        self.content = content
+        return self
+
+    def __str__(self):
+        return "    <{}{}{}>{}</{}>".format(
+            self.name,
+            "" if self.style is None else ' style="{}"'.format(self.style),
+            "" if self.colspan is None else " colspan={}".format(self.colspan),
+            "&nbsp;" if self.content is None else self.content,
+            self.name,
+        )
+
+
+class Cell(TableCell):
+    def __init__(self, content=None):
+        super(Cell, self).__init__("td", content)
+
+
+class Header(TableCell):
+    def __init__(self, content=None):
+        super(Header, self).__init__("th", content)
+
+
+class Row(object):
+    def __init__(self):
+        self.cells = []
+
+    def AddCell(self, content=None):
+        self.cells.append(Cell(content))
+        return self.cells[-1]
+
+    def AddHeader(self, content=None):
+        self.cells.append(Header(content))
+        return self.cells[-1]
+
+    def __str__(self):
+        return "\n".join(["  <tr>", *map(str, self.cells), "  </tr>"])
+
+
+class TableBody(object):
+    def __init__(self, close=False):
+        self.close = close
+
+    def __str__(self):
+        return "<{}tbody>".format("/" if self.close else "")
+
+
+class Table(object):
+    def __init__(self, out):
+        self.out = out
+        self.rows = []
+        self.border = None
+        self.cellspacing = None
+        self.cellpadding = None
+        self.body_begins = None
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, *args):
+        self.out.write("\n" + str(self))
+
+    def __str__(self):
+        open_tag = "<table{}{}{}>".format(
+            "" if self.border is None else " border={}".format(self.border),
+            ""
+            if self.cellspacing is None
+            else " cellspacing={}".format(self.cellspacing),
+            ""
+            if self.cellpadding is None
+            else " cellpadding={}".format(self.cellpadding),
+        )
+        rows = map(str, self.rows)
+        close_tag = "</table>"
+
+        return "\n".join([open_tag, *rows, close_tag])
+
+    def AddRow(self):
+        self.rows.append(Row())
+        return self.rows[-1]
+
+    def Border(self, border):
+        self.border = border
+        return self
+
+    def Cellspacing(self, cellspacing):
+        self.cellspacing = cellspacing
+        return self
+
+    def Cellpadding(self, cellpadding):
+        self.cellpadding = cellpadding
+        return self
+
+    def BeginBody(self):
+        self.rows.append(TableBody())
+
+    def EndBody(self):
+        self.rows.append(TableBody(close=True))