changeset 203:e6ddca1e5712

Huge refactoring to remove dependency on BDB XML. Report data is now stored in the Trac database (SQLite/PostgreSQL). Also: * Fix reporting of lines of code for modules that have partial coverage. * Get coverage results also in case of test failures. * Improve database upgrade procedure (will now give feedback). * Introduce report categories to allow the output of other tools (JUnit, Clover, etc) to be visualized by the same components in the server-side without modification.
author cmlenz
date Mon, 19 Sep 2005 15:22:14 +0000
parents ae4b03619d9a
children e1a53e70b43f
files bitten/__init__.py bitten/build/pythontools.py bitten/build/tests/pythontools.py bitten/master.py bitten/model.py bitten/recipe.py bitten/slave.py bitten/store.py bitten/tests/__init__.py bitten/tests/model.py bitten/tests/store.py bitten/trac_ext/api.py bitten/trac_ext/charts.py bitten/trac_ext/main.py bitten/trac_ext/summarizers.py bitten/trac_ext/templates/bitten_summary_coverage.cs bitten/trac_ext/templates/bitten_summary_tests.cs bitten/trac_ext/web_ui.py bitten/upgrades.py bitten/util/testrunner.py scripts/build.py
diffstat 21 files changed, 702 insertions(+), 698 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/__init__.py
+++ b/bitten/__init__.py
@@ -7,4 +7,4 @@
 # you should have received as part of this distribution. The terms
 # are also available at http://bitten.cmlenz.net/wiki/License.
 
-__version__ = '0.4'
+__version__ = '0.5'
--- a/bitten/build/pythontools.py
+++ b/bitten/build/pythontools.py
@@ -69,7 +69,7 @@
                         r'(?P<msg>.*)$')
     msg_categories = dict(W='warning', E='error', C='convention', R='refactor')
 
-    problems = xmlio.Element('problems')
+    problems = xmlio.Fragment()
     try:
         fd = open(ctxt.resolve(file_), 'r')
         try:
@@ -88,7 +88,7 @@
                     xmlio.SubElement(problems, 'problem', category=category,
                                      type=msg_type, tag=tag, file=filename,
                                      line=lineno)[match.group('msg') or '']
-            ctxt.report(problems)
+            ctxt.report('lint', problems)
         finally:
             fd.close()
     except IOError, e:
@@ -127,8 +127,8 @@
                             continue
                         missing_files.remove(filename)
                         covered_modules.add(modname)
-                        module = xmlio.Element('coverage', file=filename,
-                                               module=modname, percentage=cov)
+                        module = xmlio.Element('coverage', name=modname,
+                                               file=filename, percentage=cov)
                         coverage_path = ctxt.resolve(coverdir,
                                                      modname + '.cover')
                         if not os.path.exists(coverage_path):
@@ -136,17 +136,24 @@
                                         modname, coverage_path)
                             continue
                         coverage_file = open(coverage_path, 'r')
+                        num_lines = 0
+                        lines = []
                         try:
                             for num, coverage_line in enumerate(coverage_file):
                                 match = coverage_line_re.search(coverage_line)
                                 if match:
                                     hits = match.group(1)
                                     if hits:
-                                        line = xmlio.Element('line', line=num,
-                                                             hits=int(hits))
-                                        module.append(line)
+                                        lines.append(hits)
+                                        num_lines += 1
+                                    else:
+                                        if coverage_line.startswith('>'):
+                                            num_lines += 1
+                                        lines.append('0')
                         finally:
                             coverage_file.close()
+                        module.attr['lines'] = len(lines)
+                        module.append(xmlio.Element('line_hits')[' '.join(lines)])
                         coverage.append(module)
 
             for filename in missing_files:
@@ -154,20 +161,21 @@
                 if modname in covered_modules:
                     continue
                 covered_modules.add(modname)
-                module = xmlio.Element('coverage', module=modname,
+                module = xmlio.Element('coverage', name=modname,
                                        file=filename, percentage=0)
                 filepath = ctxt.resolve(filename)
                 fileobj = file(filepath, 'r')
                 try:
+                    lines = 0
                     for lineno, linetype, line in loc.count(fileobj):
                         if linetype == loc.CODE:
-                            line = xmlio.Element('line', line=lineno, hits=0)
-                            module.append(line)
+                            lines += 1
+                    module.attr['lines'] = lines
                 finally:
                     fileobj.close()
                 coverage.append(module)
 
-            ctxt.report(coverage)
+            ctxt.report('coverage', coverage)
         finally:
             summary_file.close()
     except IOError, e:
@@ -192,9 +200,9 @@
                             continue
                     test.attr[name] = value
                 for grandchild in child.children():
-                    test.append(grandchild)
+                    test.append(xmlio.Element(grandchild.name)[grandchild.gettext()])
                 results.append(test)
-            ctxt.report(results)
+            ctxt.report('test', results)
         finally:
             fd.close()
     except IOError, e:
--- a/bitten/build/tests/pythontools.py
+++ b/bitten/build/tests/pythontools.py
@@ -44,12 +44,12 @@
         self.summary.close()
         pythontools.trace(self.ctxt, summary=self.summary.name, include='*.py',
                           coverdir=self.coverdir)
-        type, function, xml = self.ctxt.output.pop()
+        type, category, generator, xml = self.ctxt.output.pop()
         self.assertEqual(Recipe.REPORT, type)
+        self.assertEqual('coverage', category)
         self.assertEqual(0, len(xml.children))
 
 
-
 class UnittestTestCase(unittest.TestCase):
 
     def setUp(self):
@@ -71,8 +71,9 @@
                               '</unittest-results>')
         self.results_xml.close()
         pythontools.unittest(self.ctxt, self.results_xml.name)
-        type, function, xml = self.ctxt.output.pop()
+        type, category, generator, xml = self.ctxt.output.pop()
         self.assertEqual(Recipe.REPORT, type)
+        self.assertEqual('test', category)
         self.assertEqual(0, len(xml.children))
 
     def test_successful_test(self):
@@ -85,8 +86,7 @@
                               % os.path.join(self.ctxt.basedir, 'bar_test.py'))
         self.results_xml.close()
         pythontools.unittest(self.ctxt, self.results_xml.name)
-        type, function, xml = self.ctxt.output.pop()
-        self.assertEqual(Recipe.REPORT, type)
+        type, category, generator, xml = self.ctxt.output.pop()
         self.assertEqual(1, len(xml.children))
         test_elem = xml.children[0]
         self.assertEqual('test', test_elem.name)
@@ -105,7 +105,8 @@
                               % os.path.join(self.ctxt.basedir, 'bar_test.py'))
         self.results_xml.close()
         pythontools.unittest(self.ctxt, self.results_xml.name)
-        type, function, xml = self.ctxt.output.pop()
+        type, category, generator, xml = self.ctxt.output.pop()
+        self.assertEqual(1, len(xml.children))
         self.assertEqual('bar_test.py', xml.children[0].attr['file'])
 
     def test_missing_file_attribute(self):
@@ -116,7 +117,8 @@
                               '</unittest-results>')
         self.results_xml.close()
         pythontools.unittest(self.ctxt, self.results_xml.name)
-        type, function, xml = self.ctxt.output.pop()
+        type, category, generator, xml = self.ctxt.output.pop()
+        self.assertEqual(1, len(xml.children))
         self.assertEqual(None, xml.children[0].attr.get('file'))
 
 
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -19,8 +19,8 @@
 import time
 
 from trac.env import Environment
-from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, BuildLog
-from bitten.store import get_store
+from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \
+                         BuildLog, Report
 from bitten.util import archive, beep, xmlio
 
 log = logging.getLogger('bitten.master')
@@ -127,20 +127,15 @@
     def _cleanup_orphaned_builds(self):
         # Reset all in-progress builds
         db = self.env.get_db_cnx()
-        store = get_store(self.env)
         for build in Build.select(self.env, status=Build.IN_PROGRESS, db=db):
             build.status = Build.PENDING
             build.slave = None
             build.slave_info = {}
             build.started = 0
-            for step in BuildStep.select(self.env, build=build.id):
+            for step in BuildStep.select(self.env, build=build.id, db=db):
                 step.delete(db=db)
-            for build_log in BuildLog.select(self.env, build=build.id):
-                build_log.delete(db=db)
             build.update(db=db)
-            store.delete(build=build)
         db.commit()
-        store.commit()
 
     def _cleanup_snapshots(self, when):
         log.debug('Checking for unused snapshot archives...')
@@ -206,8 +201,8 @@
         db = self.env.get_db_cnx()
         for build in Build.select(self.env, slave=handler.name,
                                   status=Build.IN_PROGRESS, db=db):
-            log.info('Build [%s] of "%s" by %s cancelled', build.rev,
-                     build.config, handler.name)
+            log.info('Build %d ("%s" as of [%s]) cancelled by  %s', build.id,
+                     build.rev, build.config, handler.name)
             for step in BuildStep.select(self.env, build=build.id):
                 step.delete(db=db)
 
@@ -381,20 +376,31 @@
             step.status = BuildStep.SUCCESS
         step.insert(db=db)
 
-        for log_elem in elem.children('log'):
+        for idx, log_elem in enumerate(elem.children('log')):
             build_log = BuildLog(self.env, build=build.id, step=step.name,
-                                 type=log_elem.attr.get('type'))
+                                 generator=log_elem.attr.get('generator'),
+                                 orderno=idx)
             for message_elem in log_elem.children('message'):
                 build_log.messages.append((message_elem.attr['level'],
                                            message_elem.gettext()))
             build_log.insert(db=db)
 
-        store = get_store(self.env)
-        for report in elem.children('report'):
-            store.store(build, step, report)
+        report_types = {'unittest': 'test', 'trace': 'coverage',
+                        'pylint': 'lint'}
+        for report_elem in elem.children('report'):
+            generator = report_elem.attr.get('generator')
+            report = Report(self.env, build=build.id, step=step.name,
+                            category=report_types[generator],
+                            generator=generator)
+            for item_elem in report_elem.children():
+                item = {'type': item_elem.name}
+                item.update(item_elem.attr)
+                for child_elem in item_elem.children():
+                    item[child_elem.name] = child_elem.gettext()
+                report.items.append(item)
+            report.insert(db=db)
 
         db.commit()
-        store.commit()
 
     def _build_completed(self, build, elem, timestamp_delta=None):
         log.info('Slave %s completed build %d ("%s" as of [%s]) with status %s',
@@ -425,11 +431,7 @@
         build.slave_info = {}
         build.update(db=db)
 
-        store = get_store(self.env)
-        store.delete(build=build)
-
         db.commit()
-        store.commit()
 
 
 def _parse_iso_datetime(string):
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -7,6 +7,11 @@
 # you should have received as part of this distribution. The terms
 # are also available at http://bitten.cmlenz.net/wiki/License.
 
+try:
+    set
+except NameError:
+    from sets import Set as set
+
 from trac.db_default import Table, Column, Index
 
 
@@ -51,10 +56,10 @@
         else:
             handle_ta = False
 
-        for platform in TargetPlatform.select(self.env, self.name, db=db):
+        for platform in list(TargetPlatform.select(self.env, self.name, db=db)):
             platform.delete(db=db)
 
-        for build in Build.select(self.env, config=self.name, db=db):
+        for build in list(Build.select(self.env, config=self.name, db=db)):
             build.delete(db=db)
 
         cursor = db.cursor()
@@ -363,7 +368,7 @@
         else:
             handle_ta = False
 
-        for step in BuildStep.select(self.env, build=self.id):
+        for step in list(BuildStep.select(self.env, build=self.id)):
             step.delete(db=db)
 
         cursor = db.cursor()
@@ -528,8 +533,12 @@
         else:
             handle_ta = False
 
-        for log in BuildLog.select(self.env, build=self.build, step=self.name):
+        for log in list(BuildLog.select(self.env, build=self.build,
+                                        step=self.name, db=db)):
             log.delete(db=db)
+        for report in list(Report.select(self.env, build=self.build,
+                                         step=self.name, db=db)):
+            report.delete(db=db)
 
         cursor = db.cursor()
         cursor.execute("DELETE FROM bitten_step WHERE build=%s AND name=%s",
@@ -607,7 +616,8 @@
     _schema = [
         Table('bitten_log', key='id')[
             Column('id', auto_increment=True), Column('build', type='int'),
-            Column('step'), Column('type')
+            Column('step'), Column('generator'), Column('orderno', type='int'),
+            Index(['build', 'step'])
         ],
         Table('bitten_log_message', key=('log', 'line'))[
             Column('log', type='int'), Column('line', type='int'),
@@ -621,7 +631,8 @@
     WARNING = 'W'
     ERROR = 'E'
 
-    def __init__(self, env, build=None, step=None, type=None):
+    def __init__(self, env, build=None, step=None, generator=None,
+                 orderno=None):
         """Initialize a new build log with the specified attributes.
 
         To actually create this build log in the database, the `insert` method
@@ -631,7 +642,8 @@
         self.id = None
         self.build = build
         self.step = step
-        self.type = type
+        self.generator = generator or ''
+        self.orderno = orderno and int(orderno) or 0
         self.messages = []
 
     exists = property(fget=lambda self: self.id is not None)
@@ -665,8 +677,9 @@
         assert self.build and self.step
 
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,type) "
-                       "VALUES (%s,%s,%s)", (self.build, self.step, self.type))
+        cursor.execute("INSERT INTO bitten_log (build,step,generator,orderno) "
+                       "VALUES (%s,%s,%s,%s)", (self.build, self.step,
+                       self.generator, self.orderno))
         id = db.get_last_id(cursor, 'bitten_log')
         cursor.executemany("INSERT INTO bitten_log_message "
                            "(log,line,level,message) VALUES (%s,%s,%s,%s)",
@@ -683,12 +696,12 @@
             db = env.get_db_cnx()
 
         cursor = db.cursor()
-        cursor.execute("SELECT build,step,type FROM bitten_log "
+        cursor.execute("SELECT build,step,generator,orderno FROM bitten_log "
                        "WHERE id=%s", (id,))
         row = cursor.fetchone()
         if not row:
             return None
-        log = BuildLog(env, int(row[0]), row[1], row[2] or '')
+        log = BuildLog(env, int(row[0]), row[1], row[2], row[3])
         log.id = id
         cursor.execute("SELECT level,message FROM bitten_log_message "
                        "WHERE log=%s ORDER BY line", (id,))
@@ -698,7 +711,7 @@
 
     fetch = classmethod(fetch)
 
-    def select(cls, env, build=None, step=None, type=None, db=None):
+    def select(cls, env, build=None, step=None, generator=None, db=None):
         """Retrieve existing build logs from the database that match the
         specified criteria.
         """
@@ -710,15 +723,15 @@
             where_clauses.append(("build=%s", build))
         if step is not None:
             where_clauses.append(("step=%s", step))
-        if type is not None:
-            where_clauses.append(("type=%s", type))
+        if generator is not None:
+            where_clauses.append(("generator=%s", generator))
         if where_clauses:
             where = "WHERE " + " AND ".join([wc[0] for wc in where_clauses])
         else:
             where = ""
 
         cursor = db.cursor()
-        cursor.execute("SELECT id FROM bitten_log %s ORDER BY type"
+        cursor.execute("SELECT id FROM bitten_log %s ORDER BY orderno"
                        % where, [wc[1] for wc in where_clauses])
         for (id, ) in cursor:
             yield BuildLog.fetch(env, id, db=db)
@@ -726,6 +739,147 @@
     select = classmethod(select)
 
 
+class Report(object):
+    """Represents a generated report."""
+
+    _schema = [
+        Table('bitten_report', key='id')[
+            Column('id', auto_increment=True), Column('build', type='int'),
+            Column('step'), Column('category'), Column('generator'),
+            Index(['build', 'step', 'category'])
+        ],
+        Table('bitten_report_item', key=('report', 'item', 'name'))[
+            Column('report', type='int'), Column('item', type='int'),
+            Column('name'), Column('value')
+        ]
+    ]
+
+    def __init__(self, env, build=None, step=None, category=None,
+                 generator=None):
+        """Initialize a new report with the specified attributes.
+
+        To actually create this build log in the database, the `insert` method
+        needs to be called.
+        """
+        self.env = env
+        self.id = None
+        self.build = build
+        self.step = step
+        self.category = category
+        self.generator = generator or ''
+        self.items = []
+
+    exists = property(fget=lambda self: self.id is not None)
+
+    def delete(self, db=None):
+        """Remove the report from the database."""
+        assert self.exists, 'Cannot delete a non-existing report'
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        cursor = db.cursor()
+        cursor.execute("DELETE FROM bitten_report_item WHERE report=%s",
+                       (self.id,))
+        cursor.execute("DELETE FROM bitten_report WHERE id=%s", (self.id,))
+
+        if handle_ta:
+            db.commit()
+        self.id = None
+
+    def insert(self, db=None):
+        """Insert a new build log into the database."""
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        assert self.build and self.step and self.category
+
+        # Enforce uniqueness of build-step-category.
+        # This should be done by the database, but the DB schema helpers in Trac
+        # currently don't support UNIQUE() constraints
+        assert not list(Report.select(self.env, build=self.build,
+                                      step=self.step, category=self.category,
+                                      db=db)), 'Report already exists'
+
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_report "
+                       "(build,step,category,generator) VALUES (%s,%s,%s,%s)",
+                       (self.build, self.step, self.category, self.generator))
+        id = db.get_last_id(cursor, 'bitten_report')
+        for idx, item in enumerate(self.items):
+            cursor.executemany("INSERT INTO bitten_report_item "
+                               "(report,item,name,value) VALUES (%s,%s,%s,%s)",
+                               [(id, idx, key, value) for key, value
+                                in item.items()])
+
+        if handle_ta:
+            db.commit()
+        self.id = id
+
+    def fetch(cls, env, id, db=None):
+        """Retrieve an existing build from the database by ID."""
+        if not db:
+            db = env.get_db_cnx()
+
+        cursor = db.cursor()
+        cursor.execute("SELECT build,step,category,generator "
+                       "FROM bitten_report WHERE id=%s", (id,))
+        row = cursor.fetchone()
+        if not row:
+            return None
+        report = Report(env, int(row[0]), row[1], row[2] or '', row[3] or '')
+        report.id = id
+
+        cursor.execute("SELECT item,name,value FROM bitten_report_item "
+                       "WHERE report=%s ORDER BY item", (id,))
+        items = {}
+        for item, name, value in cursor:
+            items.setdefault(item, {})[name] = value
+        report.items = items.values()
+
+        return report
+
+    fetch = classmethod(fetch)
+
+    def select(cls, env, config=None, build=None, step=None, category=None,
+               db=None):
+        """Retrieve existing reports from the database that match the specified
+        criteria.
+        """
+        where_clauses = []
+        joins = []
+        if config is not None:
+            where_clauses.append(("config=%s", config))
+            joins.append("INNER JOIN bitten_build ON (bitten_build.id=build)")
+        if build is not None:
+            where_clauses.append(("build=%s", build))
+        if step is not None:
+            where_clauses.append(("step=%s", step))
+        if category is not None:
+            where_clauses.append(("category=%s", category))
+
+        if where_clauses:
+            where = "WHERE " + " AND ".join([wc[0] for wc in where_clauses])
+        else:
+            where = ""
+
+        if not db:
+            db = env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT bitten_report.id FROM bitten_report %s %s "
+                       "ORDER BY category" % (' '.join(joins), where),
+                       [wc[1] for wc in where_clauses])
+        for (id, ) in cursor:
+            yield Report.fetch(env, id, db=db)
+
+    select = classmethod(select)
+
+
 schema = BuildConfig._schema + TargetPlatform._schema + Build._schema + \
-         BuildStep._schema + BuildLog._schema
-schema_version = 4
+         BuildStep._schema + BuildLog._schema + Report._schema
+schema_version = 5
--- a/bitten/recipe.py
+++ b/bitten/recipe.py
@@ -34,13 +34,14 @@
         self.output = []
 
     def error(self, message):
-        self.output.append((Recipe.ERROR, self.current_function, message))
+        self.output.append((Recipe.ERROR, None, self.current_function, message))
 
     def log(self, xml_elem):
-        self.output.append((Recipe.LOG, self.current_function, xml_elem))
+        self.output.append((Recipe.LOG, None, self.current_function, xml_elem))
 
-    def report(self, xml_elem):
-        self.output.append((Recipe.REPORT, self.current_function, xml_elem))
+    def report(self, category, xml_elem):
+        self.output.append((Recipe.REPORT, category, self.current_function,
+                            xml_elem))
 
     def resolve(self, *path):
         return os.path.normpath(os.path.join(self.basedir, *path))
@@ -80,10 +81,10 @@
             ctxt.current_step = None
         errors = []
         while ctxt.output:
-            type, function, output = ctxt.output.pop(0)
-            yield type, function, output
+            type, category, generator, output = ctxt.output.pop(0)
+            yield type, category, generator, output
             if type == Recipe.ERROR:
-                errors.append((function, output))
+                errors.append((generator, output))
         if errors:
             if self.onerror == 'fail':
                 raise BuildError, 'Build step %s failed' % self.id
--- a/bitten/slave.py
+++ b/bitten/slave.py
@@ -177,10 +177,12 @@
                                         time=started.isoformat())
                     step_failed = False
                     try:
-                        for type, function, output in step.execute(recipe.ctxt):
+                        for type, category, generator, output in \
+                                step.execute(recipe.ctxt):
                             if type == Recipe.ERROR:
                                 step_failed = True
-                            xmlio.SubElement(xml, type, type=function)[output]
+                            xmlio.SubElement(xml, type, category=category,
+                                             generator=generator)[output]
                     except BuildError, e:
                         log.error('Build step %s failed', step.id)
                         failed = True
deleted file mode 100644
--- a/bitten/store.py
+++ /dev/null
@@ -1,238 +0,0 @@
-# -*- coding: iso8859-1 -*-
-#
-# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
-# All rights reserved.
-#
-# This software is licensed as described in the file COPYING, which
-# you should have received as part of this distribution. The terms
-# are also available at http://bitten.cmlenz.net/wiki/License.
-
-import logging
-import os
-
-from trac.core import *
-from bitten.util import xmlio
-
-
-class ReportStore(object):
-
-    def close(self):
-        raise NotImplementedError
-
-    def commit(self):
-        raise NotImplementedError
-
-    def rollback(self):
-        raise NotImplementedError
-
-    def delete(self, config=None, build=None, step=None, type=None):
-        raise NotImplementedError
-
-    def query(self, xquery, config=None, build=None, step=None,
-                     type=None):
-        raise NotImplementedError
-
-    def retrieve(self, build, step=None, type=None):
-        raise NotImplementedError
-
-    def store(self, build, step, xml):
-        raise NotImplementedError
-
-
-class NullReportStore(ReportStore):
-
-    def close(self):
-        pass
-
-    def commit(self):
-        pass
-
-    def rollback(self):
-        pass
-
-    def delete(self, config=None, build=None, step=None, type=None):
-        return
-
-    def query(self, xquery, config=None, build=None, step=None,
-                     type=None):
-        return []
-
-    def retrieve(self, build, step=None, type=None):
-        return []
-
-    def store(self, build, step, xml):
-        return
-
-
-try:
-    from bsddb3 import db
-    import dbxml
-except ImportError:
-    db = None
-    dbxml = None
-
-
-class BDBXMLReportStore(ReportStore):
-
-    indices = [
-        ('config', 'node-metadata-equality-string'),
-        ('build', 'node-metadata-equality-decimal'),
-        ('step',  'node-metadata-equality-string'),
-        ('type',  'node-attribute-equality-string'),
-        ('file',  'node-attribute-equality-string'),
-        ('line',  'node-attribute-equality-decimal')
-    ]
-
-
-    class XmlValueAdapter(xmlio.ParsedElement):
-
-        def __init__(self, value):
-            self._value = value
-            self.attr = {}
-            for attr in value.getAttributes():
-                self.attr[attr.getLocalName()] = attr.getNodeValue()
-
-        name = property(fget=lambda self: self._value.getLocalName())
-        namespace = property(fget=lambda self: self._value.getNamespaceURI())
-
-        def children(self, name=None):
-            child = self._value.getFirstChild()
-            while child:
-                if child.isNode() and name in (None, child.getLocalName()):
-                    yield BDBXMLBackend.XmlValueAdapter(child)
-                elif child.isNull():
-                    break
-                child = child.getNextSibling()
-
-        def gettext(self):
-            text = []
-            child = self._value.getFirstChild()
-            while child:
-                if child.isNode() and child.getNodeName() == '#text':
-                    text.append(child.getNodeValue())
-                elif child.isNull():
-                    break
-                child = child.getNextSibling()
-            return ''.join(text)
-
-        def write(self, out, newlines=False):
-            return self._value.asString()
-
-
-    def __init__(self, path):
-        self.path = path
-        self.env = None
-        self.mgr = None
-        self.container = None
-        self.xtn = None
-
-    def _lazyinit(self, create=False):
-        if self.container is not None:
-            if self.xtn is None:
-                self.xtn = self.mgr.createTransaction()
-            return True
-
-        exists = os.path.exists(self.path)
-        if not exists and not create:
-            return False
-
-        self.env = db.DBEnv()
-        self.env.open(os.path.dirname(self.path),
-                      db.DB_CREATE | db.DB_INIT_LOCK | db.DB_INIT_LOG |
-                      db.DB_INIT_MPOOL | db.DB_INIT_TXN, 0)
-        self.mgr = dbxml.XmlManager(self.env, 0)
-        self.xtn = self.mgr.createTransaction()
-
-        if not exists:
-            self.container = self.mgr.createContainer(self.path,
-                                                      dbxml.DBXML_TRANSACTIONAL)
-            ctxt = self.mgr.createUpdateContext()
-            for name, index in self.indices:
-                self.container.addIndex(self.xtn, '', name, index, ctxt)
-        else:
-            self.container = self.mgr.openContainer(self.path,
-                                                    dbxml.DBXML_TRANSACTIONAL)
-
-        return True
-
-    def __del__(self):
-        self.close()
-
-    def close(self):
-        if self.xtn:
-            self.xtn.abort()
-            self.xtn = None
-        if self.container is not None:
-            self.container.close()
-            self.container = None
-        if self.env is not None:
-            self.env.close(0)
-            self.env = None
-
-    def commit(self):
-        if not self.xtn:
-            return
-        self.xtn.commit()
-        self.xtn = None
-
-    def rollback(self):
-        if not self.xtn:
-            return
-        self.xtn.abort()
-        self.xtn = None
-
-    def delete(self, config=None, build=None, step=None, type=None):
-        if not self._lazyinit(create=False):
-            return
-
-        ctxt = self.mgr.createUpdateContext()
-        for elem in self.query('return $reports', config=config, build=build,
-                               step=step, type=type):
-            self.container.deleteDocument(self.xtn, elem._value.asDocument(),
-                                          ctxt)
-
-    def store(self, build, step, xml):
-        assert xml.name == 'report' and 'type' in xml.attr
-        assert self._lazyinit(create=True)
-
-        ctxt = self.mgr.createUpdateContext()
-        doc = self.mgr.createDocument()
-        doc.setContent(str(xml))
-        doc.setMetaData('', 'config', dbxml.XmlValue(build.config))
-        doc.setMetaData('', 'build', dbxml.XmlValue(build.id))
-        doc.setMetaData('', 'step', dbxml.XmlValue(step.name))
-        self.container.putDocument(self.xtn, doc, ctxt, dbxml.DBXML_GEN_NAME)
-
-    def query(self, xquery, config=None, build=None, step=None, type=None):
-        if not self._lazyinit(create=False):
-            return
-
-        ctxt = self.mgr.createQueryContext()
-
-        constraints = []
-        if config:
-            constraints.append("dbxml:metadata('config')='%s'" % config.name)
-        if build:
-            constraints.append("dbxml:metadata('build')=%d" % build.id)
-        if step:
-            constraints.append("dbxml:metadata('step')='%s'" % step.name)
-        if type:
-            constraints.append("@type='%s'" % type)
-
-        query = "let $reports := collection('%s')/report" % self.path
-        if constraints:
-            query += '[%s]' % ' and '.join(constraints)
-        query += '\n' + (xquery or 'return $reports')
-
-        results = self.mgr.query(self.xtn, query, ctxt, dbxml.DBXML_LAZY_DOCS)
-        for value in results:
-            yield BDBXMLReportStore.XmlValueAdapter(value)
-
-    def retrieve(self, build, step=None, type=None):
-        return self.query('', build=build, step=step, type=type)
-
-
-def get_store(env):
-    if dbxml is None:
-        return NullReportStore()
-    return BDBXMLReportStore(os.path.join(env.path, 'db', 'bitten.dbxml'))
--- a/bitten/tests/__init__.py
+++ b/bitten/tests/__init__.py
@@ -9,7 +9,7 @@
 
 import unittest
 
-from bitten.tests import model, recipe, store
+from bitten.tests import model, recipe
 from bitten.build import tests as build
 from bitten.util import tests as util
 from bitten.trac_ext import tests as trac_ext
@@ -18,7 +18,6 @@
     suite = unittest.TestSuite()
     suite.addTest(model.suite())
     suite.addTest(recipe.suite())
-    suite.addTest(store.suite())
     suite.addTest(build.suite())
     suite.addTest(trac_ext.suite())
     suite.addTest(util.suite())
--- a/bitten/tests/model.py
+++ b/bitten/tests/model.py
@@ -11,7 +11,7 @@
 
 from trac.test import EnvironmentStub
 from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \
-                         BuildLog, schema
+                         BuildLog, Report, schema
 
 
 class BuildConfigTestCase(unittest.TestCase):
@@ -358,11 +358,11 @@
         self.assertEqual(None, log.id)
         self.assertEqual(None, log.build)
         self.assertEqual(None, log.step)
-        self.assertEqual(None, log.type)
+        self.assertEqual('', log.generator)
         self.assertEqual([], log.messages)
 
     def test_insert(self):
-        log = BuildLog(self.env, build=1, step='test', type='distutils')
+        log = BuildLog(self.env, build=1, step='test', generator='distutils')
         log.messages = [
             (BuildLog.INFO, 'running tests'),
             (BuildLog.ERROR, 'tests failed')
@@ -372,7 +372,7 @@
 
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("SELECT build,step,type FROM bitten_log "
+        cursor.execute("SELECT build,step,generator FROM bitten_log "
                        "WHERE id=%s", (log.id,))
         self.assertEqual((1, 'test', 'distutils'), cursor.fetchone())
         cursor.execute("SELECT level,message FROM bitten_log_message "
@@ -390,7 +390,7 @@
     def test_delete(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+        cursor.execute("INSERT INTO bitten_log (build,step,generator) "
                        "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
         id = db.get_last_id(cursor, 'bitten_log')
         cursor.executemany("INSERT INTO bitten_log_message "
@@ -409,13 +409,13 @@
         self.assertEqual(True, not cursor.fetchall())
 
     def test_delete_new(self):
-        log = BuildLog(self.env, build=1, step='test', type='foo')
+        log = BuildLog(self.env, build=1, step='test', generator='foo')
         self.assertRaises(AssertionError, log.delete)
 
     def test_fetch(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+        cursor.execute("INSERT INTO bitten_log (build,step,generator) "
                        "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
         id = db.get_last_id(cursor, 'bitten_log')
         cursor.executemany("INSERT INTO bitten_log_message "
@@ -428,14 +428,14 @@
         self.assertEqual(id, log.id)
         self.assertEqual(1, log.build)
         self.assertEqual('test', log.step)
-        self.assertEqual('distutils', log.type)
+        self.assertEqual('distutils', log.generator)
         self.assertEqual((BuildLog.INFO, 'running tests'), log.messages[0])
         self.assertEqual((BuildLog.ERROR, 'tests failed'), log.messages[1])
 
     def test_select(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+        cursor.execute("INSERT INTO bitten_log (build,step,generator) "
                        "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
         id = db.get_last_id(cursor, 'bitten_log')
         cursor.executemany("INSERT INTO bitten_log_message "
@@ -449,12 +449,158 @@
         self.assertEqual(id, log.id)
         self.assertEqual(1, log.build)
         self.assertEqual('test', log.step)
-        self.assertEqual('distutils', log.type)
+        self.assertEqual('distutils', log.generator)
         self.assertEqual((BuildLog.INFO, 'running tests'), log.messages[0])
         self.assertEqual((BuildLog.ERROR, 'tests failed'), log.messages[1])
         self.assertRaises(StopIteration, logs.next)
 
 
+class ReportTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        for table in Report._schema:
+            for stmt in db.to_sql(table):
+                cursor.execute(stmt)
+        db.commit()
+
+    def test_delete(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_report "
+                       "(build,step,category,generator) VALUES (%s,%s,%s,%s)",
+                       (1, 'test', 'test', 'unittest'))
+        report_id = db.get_last_id(cursor, 'bitten_report')
+        cursor.executemany("INSERT INTO bitten_report_item "
+                           "(report,item,name,value) VALUES (%s,%s,%s,%s)",
+                           [(report_id, 0, 'file', 'tests/foo.c'),
+                            (report_id, 0, 'result', 'failure'),
+                            (report_id, 1, 'file', 'tests/bar.c'),
+                            (report_id, 1, 'result', 'success')])
+
+        report = Report.fetch(self.env, report_id, db=db)
+        report.delete(db=db)
+        self.assertEqual(False, report.exists)
+        report = Report.fetch(self.env, report_id, db=db)
+        self.assertEqual(None, report)
+
+    def test_insert(self):
+        report = Report(self.env, build=1, step='test', category='test',
+                        generator='unittest')
+        report.items = [
+            {'file': 'tests/foo.c', 'status': 'failure'},
+            {'file': 'tests/bar.c', 'status': 'success'}
+        ]
+        report.insert()
+
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT build,step,category,generator "
+                       "FROM bitten_report WHERE id=%s", (report.id,))
+        self.assertEqual((1, 'test', 'test', 'unittest'),
+                         cursor.fetchone())
+        cursor.execute("SELECT item,name,value FROM bitten_report_item "
+                       "WHERE report=%s ORDER BY item", (report.id,))
+        items = []
+        prev_item = None
+        for item, name, value in cursor:
+            if item != prev_item:
+                items.append({name: value})
+                prev_item = item
+            else:
+                items[-1][name] = value
+        self.assertEquals(2, len(items))
+        seen_foo, seen_bar = False, False
+        for item in items:
+            if item['file'] == 'tests/foo.c':
+                self.assertEqual('failure', item['status'])
+                seen_foo = True
+            if item['file'] == 'tests/bar.c':
+                self.assertEqual('success', item['status'])
+                seen_bar = True
+        self.assertEquals((True, True), (seen_foo, seen_bar))
+
+    def test_insert_dupe(self):
+        report = Report(self.env, build=1, step='test', category='test',
+                        generator='unittest')
+        report.insert()
+
+        report = Report(self.env, build=1, step='test', category='test',
+                        generator='unittest')
+        self.assertRaises(AssertionError, report.insert)
+
+    def test_fetch(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_report "
+                       "(build,step,category,generator) VALUES (%s,%s,%s,%s)",
+                       (1, 'test', 'test', 'unittest'))
+        report_id = db.get_last_id(cursor, 'bitten_report')
+        cursor.executemany("INSERT INTO bitten_report_item "
+                           "(report,item,name,value) VALUES (%s,%s,%s,%s)",
+                           [(report_id, 0, 'file', 'tests/foo.c'),
+                            (report_id, 0, 'result', 'failure'),
+                            (report_id, 1, 'file', 'tests/bar.c'),
+                            (report_id, 1, 'result', 'success')])
+
+        report = Report.fetch(self.env, report_id)
+        self.assertEquals(report_id, report.id)
+        self.assertEquals('test', report.step)
+        self.assertEquals('test', report.category)
+        self.assertEquals('unittest', report.generator)
+        self.assertEquals(2, len(report.items))
+        assert {'file': 'tests/foo.c', 'result': 'failure'} in report.items
+        assert {'file': 'tests/bar.c', 'result': 'success'} in report.items
+
+    def test_select(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_report "
+                       "(build,step,category,generator) VALUES (%s,%s,%s,%s)",
+                       (1, 'test', 'test', 'unittest'))
+        report1_id = db.get_last_id(cursor, 'bitten_report')
+        cursor.execute("INSERT INTO bitten_report "
+                       "(build,step,category,generator) VALUES (%s,%s,%s,%s)",
+                       (1, 'test', 'coverage', 'trace'))
+        report2_id = db.get_last_id(cursor, 'bitten_report')
+        cursor.executemany("INSERT INTO bitten_report_item "
+                           "(report,item,name,value) VALUES (%s,%s,%s,%s)",
+                           [(report1_id, 0, 'file', 'tests/foo.c'),
+                            (report1_id, 0, 'result', 'failure'),
+                            (report1_id, 1, 'file', 'tests/bar.c'),
+                            (report1_id, 1, 'result', 'success'),
+                            (report2_id, 0, 'file', 'tests/foo.c'),
+                            (report2_id, 0, 'loc', '12'),
+                            (report2_id, 0, 'cov', '50'),
+                            (report2_id, 1, 'file', 'tests/bar.c'),
+                            (report2_id, 1, 'loc', '20'),
+                            (report2_id, 1, 'cov', '25')])
+
+        reports = Report.select(self.env, build=1, step='test')
+        for idx, report in enumerate(reports):
+            if report.id == report1_id:
+                self.assertEquals('test', report.step)
+                self.assertEquals('test', report.category)
+                self.assertEquals('unittest', report.generator)
+                self.assertEquals(2, len(report.items))
+                assert {'file': 'tests/foo.c', 'result': 'failure'} \
+                       in report.items
+                assert {'file': 'tests/bar.c', 'result': 'success'} \
+                       in report.items
+            elif report.id == report1_id:
+                self.assertEquals('test', report.step)
+                self.assertEquals('coverage', report.category)
+                self.assertEquals('trace', report.generator)
+                self.assertEquals(2, len(report.items))
+                assert {'file': 'tests/foo.c', 'loc': '12', 'cov': '50'} \
+                       in report.items
+                assert {'file': 'tests/bar.c', 'loc': '20', 'cov': '25'} \
+                       in report.items
+        self.assertEqual(1, idx)
+
+
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(BuildConfigTestCase, 'test'))
@@ -462,6 +608,7 @@
     suite.addTest(unittest.makeSuite(BuildTestCase, 'test'))
     suite.addTest(unittest.makeSuite(BuildStepTestCase, 'test'))
     suite.addTest(unittest.makeSuite(BuildLogTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(ReportTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
deleted file mode 100644
--- a/bitten/tests/store.py
+++ /dev/null
@@ -1,112 +0,0 @@
-# -*- coding: iso8859-1 -*-
-#
-# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
-# All rights reserved.
-#
-# This software is licensed as described in the file COPYING, which
-# you should have received as part of this distribution. The terms
-# are also available at http://bitten.cmlenz.net/wiki/License.
-
-import os
-import shutil
-import sys
-import tempfile
-import unittest
-
-from trac.test import EnvironmentStub, Mock
-from bitten.store import BDBXMLReportStore
-from bitten.util import xmlio
-
-
-class BDBXMLReportStoreTestCase(unittest.TestCase):
-
-    def setUp(self):
-        self.path = tempfile.mkdtemp(prefix='bitten-test')
-        self.store = BDBXMLReportStore(os.path.join(self.path, 'test.dbxml'))
-
-    def tearDown(self):
-        self.store.close()
-        shutil.rmtree(self.path)
-
-    def test_store_report(self):
-        """
-        Verify that storing a single report in the database works as expected.
-        """
-        build = Mock(id=42, config='trunk')
-        step = Mock(name='foo')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(build, step, xml)
-
-        self.assertEqual(1, len(list(self.store.retrieve(build, step, 'test'))))
-
-    def test_retrieve_reports_for_step(self):
-        """
-        Verify that all reports for a build step are retrieved if the report
-        type parameter is omitted.
-        """
-        build = Mock(id=42, config='trunk')
-        step = Mock(name='foo')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(build, step, xml)
-        xml = xmlio.Element('report', type='lint')[xmlio.Element('dummy')]
-        self.store.store(build, step, xml)
-
-        other_step = Mock(name='bar')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(build, other_step, xml)
-
-        self.assertEqual(2, len(list(self.store.retrieve(build, step))))
-
-    def test_retrieve_reports_for_build(self):
-        """
-        Verify that all reports for a build are retrieved if the build step and
-        report type parameters are omitted.
-        """
-        build = Mock(id=42, config='trunk')
-        step_foo = Mock(name='foo')
-        step_bar = Mock(name='bar')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(build, step_foo, xml)
-        xml = xmlio.Element('report', type='lint')[xmlio.Element('dummy')]
-        self.store.store(build, step_bar, xml)
-
-        other_build = Mock(id=66, config='trunk')
-        step_baz = Mock(name='foo')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(other_build, step_baz, xml)
-
-        self.assertEqual(2, len(list(self.store.retrieve(build))))
-
-    def test_delete_reports_for_build(self):
-        """
-        Verify that the reports for a build can be deleted.
-        """
-        build = Mock(id=42, config='trunk')
-        step_foo = Mock(name='foo')
-        step_bar = Mock(name='bar')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(build, step_foo, xml)
-        xml = xmlio.Element('report', type='lint')[xmlio.Element('dummy')]
-        self.store.store(build, step_bar, xml)
-
-        other_build = Mock(id=66, config='trunk')
-        step_baz = Mock(name='foo')
-        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
-        self.store.store(other_build, step_baz, xml)
-
-        self.store.delete(build=build)
-        self.assertEqual(0, len(list(self.store.retrieve(build))))
-        self.assertEqual(1, len(list(self.store.retrieve(other_build))))
-
-
-def suite():
-    suite = unittest.TestSuite()
-    try:
-        import dbxml
-        suite.addTest(unittest.makeSuite(BDBXMLReportStoreTestCase, 'test'))
-    except ImportError:
-        print>>sys.stderr, 'Skipping unit tests for BDB XML backend'
-    return suite
-
-if __name__ == '__main__':
-    unittest.main(defaultTest='suite')
--- a/bitten/trac_ext/api.py
+++ b/bitten/trac_ext/api.py
@@ -26,11 +26,11 @@
     """Extension point interface for components that render a summary of reports
     of some kind."""
 
-    def get_supported_report_types():
+    def get_supported_categories():
         """Return a list of strings identifying the types of reports this 
         component supports."""
 
-    def render_report_summary(req, build, step, report):
+    def render_summary(req, build, step, category):
         """Render a summary for the given report and return the results HTML as
         a string."""
 
@@ -39,11 +39,11 @@
     """Extension point interface for components that generator a chart for a
     set of reports."""
 
-    def get_supported_report_types():
+    def get_supported_categories():
         """Return a list of strings identifying the types of reports this 
         component supports."""
 
-    def generate_chart_data(req, config, type):
+    def generate_chart_data(req, config, category):
         """Generate the data for the chart.
         
         This method should store the data in the HDF of the request and return
--- a/bitten/trac_ext/charts.py
+++ b/bitten/trac_ext/charts.py
@@ -12,13 +12,12 @@
 
 from trac.core import *
 from trac.web import IRequestHandler
-from bitten.model import BuildConfig, Build
-from bitten.store import get_store
+from bitten.model import BuildConfig, Build, Report
 from bitten.trac_ext.api import IReportChartGenerator
 from bitten.util import xmlio
 
 
-class BittenChartRenderer(Component):
+class ReportChartController(Component):
     implements(IRequestHandler)
 
     generators = ExtensionPoint(IReportChartGenerator)
@@ -29,20 +28,20 @@
         match = re.match(r'/build/([\w.-]+)/chart/(\w+)', req.path_info)
         if match:
             req.args['config'] = match.group(1)
-            req.args['type'] = match.group(2)
+            req.args['category'] = match.group(2)
             return True
 
     def process_request(self, req):
-        report_type = req.args.get('type')
+        category = req.args.get('category')
         config = BuildConfig.fetch(self.env, name=req.args.get('config'))
 
         for generator in self.generators:
-            if report_type in generator.get_supported_report_types():
+            if category in generator.get_supported_categories():
                 template = generator.generate_chart_data(req, config,
-                                                         report_type)
+                                                         category)
                 break
         else:
-            raise TracError, 'Unknown report type "%s"' % report_type
+            raise TracError, 'Unknown report category "%s"' % category
 
         return template, 'text/xml'
 
@@ -52,105 +51,87 @@
 
     # IReportChartGenerator methods
 
-    def get_supported_report_types(self):
-        return ['unittest']
-
-    def generate_chart_data(self, req, config, report_type):
-        rev_map = {}
-        for build in Build.select(self.env, config=config.name):
-            if build.status in (Build.PENDING, Build.IN_PROGRESS):
-                continue
-            rev_map[str(build.id)] = (build.rev,
-                                      datetime.fromtimestamp(build.rev_time))
+    def get_supported_categories(self):
+        return ['test']
 
-        store = get_store(self.env)
-        xquery = """
-for $report in $reports
-return
-    <tests build="{dbxml:metadata('build', $report)}"
-           total="{count($report/test)}"
-           failed="{count($report/test[@status='error' or @status='failure'])}">
-    </tests>
-"""
+    def generate_chart_data(self, req, config, category):
+        assert category == 'test'
 
-        # FIXME: It should be possible to aggregate the test counts by revision
-        #        in the XQuery above, somehow. For now, we do that in the Python
-        #        code
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("""
+SELECT build.rev, item_status.value AS status, COUNT(*) AS num
+FROM bitten_build AS build
+ LEFT OUTER JOIN bitten_report AS report ON (report.build=build.id)
+ LEFT OUTER JOIN bitten_report_item AS item_status
+  ON (item_status.report=report.id AND item_status.name='status')
+WHERE build.config=%s AND report.category='test'
+GROUP BY build.rev, build.platform, item_status.value
+ORDER BY build.rev_time""", (config.name,))
 
-        tests = {} # Accumulated test numbers by revision
-        for test in store.query(xquery, config=config, type='unittest'):
-            rev, rev_time = rev_map.get(test.attr['build'])
-            if rev not in tests:
-                tests[rev] = [rev_time, 0, 0]
-            tests[rev][1] = max(int(test.attr['total']), tests[rev][1])
-            tests[rev][2] = max(int(test.attr['failed']), tests[rev][2])
-
-        tests = [(rev_time, rev, total, failed) for
-                 rev, (rev_time, total, failed) in tests.items()]
-        tests.sort()
+        prev_rev = None
+        tests = []
+        for rev, status, num in cursor:
+            if rev != prev_rev:
+                tests.append([rev, 0, 0])
+            slot = int(status != 'success') + 1
+            if num > tests[-1][slot]:
+                tests[-1][slot] = num
+            prev_rev = rev
 
         req.hdf['chart.title'] = 'Unit Tests'
         req.hdf['chart.data'] = [
-            [''] + [item[1] for item in tests],
-            ['Total'] + [item[2] for item in tests],
-            ['Failures'] + [int(item[3]) for item in tests]
+            [''] + [item[0] for item in tests],
+            ['Total'] + [item[1] for item in tests],
+            ['Failures'] + [item[2] for item in tests]
         ]
 
         return 'bitten_chart_tests.cs'
 
 
-class TestResultsChartGenerator(Component):
+class CodeCoverageChartGenerator(Component):
     implements(IReportChartGenerator)
 
     # IReportChartGenerator methods
 
-    def get_supported_report_types(self):
-        return ['trace']
-
-    def generate_chart_data(self, req, config, report_type):
-        rev_map = {}
-        for build in Build.select(self.env, config=config.name):
-            if build.status in (Build.PENDING, Build.IN_PROGRESS):
-                continue
-            rev_map[str(build.id)] = (build.rev,
-                                      datetime.fromtimestamp(build.rev_time))
+    def get_supported_categories(self):
+        return ['coverage']
 
-        store = get_store(self.env)
-        xquery = """
-for $report in $reports
-return
-    <coverage build="{dbxml:metadata('build', $report)}"
-              loc="{count($report/coverage/line)}">
-    {
-        for $coverage in $report/coverage
-        return
-            count($coverage/line) * ($coverage/@percentage/text() div 100)
-    }
-    </coverage>
-"""
+    def generate_chart_data(self, req, config, category):
+        assert category == 'coverage'
 
-        # FIXME: It should be possible to aggregate the coverage info by
-        #        revision in the XQuery above, somehow. For now, we do that in
-        #        the Python code
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("""
+SELECT build.rev, SUM(item_lines.value) AS loc,
+       SUM(item_lines.value * item_percentage.value / 100) AS cov
+FROM bitten_build AS build
+ LEFT OUTER JOIN bitten_report AS report ON (report.build=build.id)
+ LEFT OUTER JOIN bitten_report_item AS item_lines
+  ON (item_lines.report=report.id AND item_lines.name='lines')
+ LEFT OUTER JOIN bitten_report_item AS item_percentage
+  ON (item_percentage.report=report.id AND item_percentage.name='percentage' AND
+      item_percentage.item=item_lines.item)
+WHERE build.config=%s AND report.category='coverage'
+GROUP BY build.rev, build.platform
+ORDER BY build.rev_time""", (config.name,))
 
-        coverage = {} # Accumulated coverage info by revision
-        for test in store.query(xquery, config=config, type='trace'):
-            rev, rev_time = rev_map.get(test.attr['build'])
-            if rev not in coverage:
-                coverage[rev] = [rev_time, 0, 0]
-            coverage[rev][1] = max(int(test.attr['loc']), coverage[rev][1])
-            cov_lines = sum([float(val) for val in test.gettext().split()])
-            coverage[rev][2] = max(cov_lines, coverage[rev][2])
-
-        coverage = [(rev_time, rev, loc, cov) for
-                    rev, (rev_time, loc, cov) in coverage.items()]
-        coverage.sort()
+        prev_rev = None
+        coverage = []
+        for rev, loc, cov in cursor:
+            if rev != prev_rev:
+                coverage.append([rev, 0, 0])
+            if loc > coverage[-1][1]:
+                coverage[-1][1] = loc
+            if cov > coverage[-1][2]:
+                coverage[-1][2] = cov
+            prev_rev = rev
 
         req.hdf['chart.title'] = 'Code Coverage'
         req.hdf['chart.data'] = [
-            [''] + [item[1] for item in coverage],
-            ['Lines of code'] + [item[2] for item in coverage],
-            ['Coverage'] + [int(item[3]) for item in coverage]
+            [''] + [item[0] for item in coverage],
+            ['Lines of code'] + [item[1] for item in coverage],
+            ['Coverage'] + [int(item[2]) for item in coverage]
         ]
 
         return 'bitten_chart_coverage.cs'
--- a/bitten/trac_ext/main.py
+++ b/bitten/trac_ext/main.py
@@ -7,7 +7,9 @@
 # you should have received as part of this distribution. The terms
 # are also available at http://bitten.cmlenz.net/wiki/License.
 
+import inspect
 import os
+import textwrap
 
 from trac.core import *
 from trac.env import IEnvironmentSetupParticipant
@@ -59,7 +61,9 @@
             from bitten import upgrades
             for version in range(current_version + 1, schema_version + 1):
                 for function in upgrades.map.get(version):
+                    print textwrap.fill(inspect.getdoc(function))
                     function(self.env, db)
+                    print 'Done.'
             cursor.execute("UPDATE system SET value=%s WHERE "
                            "name='bitten_version'", (schema_version,))
             self.log.info('Upgraded Bitten tables from version %d to %d',
--- a/bitten/trac_ext/summarizers.py
+++ b/bitten/trac_ext/summarizers.py
@@ -12,73 +12,89 @@
 from trac.web.chrome import Chrome
 from trac.web.clearsilver import HDFWrapper
 from bitten.model import BuildConfig
-from bitten.store import get_store
 from bitten.trac_ext.api import IReportSummarizer
 
 
-class XQuerySummarizer(Component):
-    abstract = True
+class TestResultsSummarizer(Component):
     implements(IReportSummarizer)
 
-    query = None
-    report_type = None
-    template = None
+    def get_supported_categories(self):
+        return ['test']
 
-    def get_supported_report_types(self):
-        return [self.report_type]
+    def render_summary(self, req, build, step, category):
+        assert category == 'test'
 
-    def render_report_summary(self, req, build, step, report):
         hdf = HDFWrapper(loadpaths=Chrome(self.env).get_all_templates_dirs())
-        config = BuildConfig.fetch(self.env, name=build.config)
-        store = get_store(self.env)
-        results = store.query(self.query, config=config, build=build, step=step,
-                              type=self.report_type)
-        for idx, elem in enumerate(results):
-            data = {}
-            for name, value in elem.attr.items():
-                if name == 'file':
-                    data['href'] = escape(self.env.href.browser(config.path,
-                                                                value,
-                                                                rev=build.rev))
-                data[name] = escape(value)
-            hdf['data.%d' % idx] = data
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("""
+SELECT item_fixture.value AS fixture, item_file.value AS file,
+       COUNT(item_success.value) AS num_success,
+       COUNT(item_failure.value) AS num_failure,
+       COUNT(item_error.value) AS num_error
+FROM bitten_report AS report
+ LEFT OUTER JOIN bitten_report_item AS item_fixture
+  ON (item_fixture.report=report.id AND item_fixture.name='fixture')
+ LEFT OUTER JOIN bitten_report_item AS item_file
+  ON (item_file.report=report.id AND item_file.item=item_fixture.item AND
+      item_file.name='file')
+ LEFT OUTER JOIN bitten_report_item AS item_success
+  ON (item_success.report=report.id AND item_success.item=item_fixture.item AND
+      item_success.name='status' AND item_success.value='success')
+ LEFT OUTER JOIN bitten_report_item AS item_failure
+  ON (item_failure.report=report.id AND item_failure.item=item_fixture.item AND
+      item_failure.name='status' AND item_failure.value='failure')
+ LEFT OUTER JOIN bitten_report_item AS item_error
+  ON (item_error.report=report.id AND item_error.item=item_fixture.item AND
+      item_error.name='status' AND item_error.value='error')
+WHERE category='test' AND build=%s
+GROUP BY file, fixture ORDER BY fixture""", (build.id,))
 
-        return hdf.render(self.template)
+        data = []
+        for fixture, file, num_success, num_failure, num_error in cursor:
+            data.append({'name': fixture, 'href': self.env.href.browser(file),
+                         'num_success': num_success, 'num_error': num_error,
+                         'num_failure': num_failure})
+
+        hdf['data'] = data
+        return hdf.render('bitten_summary_tests.cs')
 
 
-class TestResultsSummarizer(XQuerySummarizer):
-
-    report_type = 'unittest'
-    template = 'bitten_summary_tests.cs'
+class CodeCoverageSummarizer(Component):
+    implements(IReportSummarizer)
 
-    query = """
-for $report in $reports
-return
-    for $fixture in distinct-values($report/test/@fixture)
-    order by $fixture
-    return
-        let $tests := $report/test[@fixture=$fixture]
-        return
-            <test name="{$fixture}" file="{$tests[1]/@file}"
-                  success="{count($tests[@status='success'])}"
-                  errors="{count($tests[@status='error'])}"
-                  failures="{count($tests[@status='failure'])}"/>
-"""
-
+    def get_supported_categories(self):
+        return ['coverage']
 
-
-class CodeCoverageSummarizer(XQuerySummarizer):
-
-    report_type = 'trace'
-    template = 'bitten_summary_coverage.cs'
+    def render_summary(self, req, build, step, category):
+        assert category == 'coverage'
 
-    query = """
-for $report in $reports
-where $report/@type = 'trace'
-return
-    for $coverage in $report/coverage
-    order by $coverage/@file
-    return
-        <unit file="{$coverage/@file}" name="{$coverage/@module}"
-              loc="{count($coverage/line)}" cov="{$coverage/@percentage}%"/>
-"""
+        hdf = HDFWrapper(loadpaths=Chrome(self.env).get_all_templates_dirs())
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("""
+SELECT item_name.value AS unit, item_file.value AS file,
+       item_lines.value AS loc, item_percentage.value AS cov
+FROM bitten_report AS report
+ LEFT OUTER JOIN bitten_report_item AS item_name
+  ON (item_name.report=report.id AND item_name.name='name')
+ LEFT OUTER JOIN bitten_report_item AS item_file
+  ON (item_file.report=report.id AND item_file.item=item_name.item AND
+      item_file.name='file')
+ LEFT OUTER JOIN bitten_report_item AS item_lines
+  ON (item_lines.report=report.id AND item_lines.item=item_name.item AND
+      item_lines.name='lines')
+ LEFT OUTER JOIN bitten_report_item AS item_percentage
+  ON (item_percentage.report=report.id AND
+      item_percentage.item=item_name.item AND
+      item_percentage.name='percentage')
+WHERE category='coverage' AND build=%s
+GROUP BY file, unit ORDER BY unit""", (build.id,))
+
+        data = []
+        for unit, file, loc, cov in cursor:
+            data.append({'name': unit, 'href': self.env.href.browser(file),
+                         'loc': loc, 'cov': cov})
+
+        hdf['data'] = data
+        return hdf.render('bitten_summary_coverage.cs')
--- a/bitten/trac_ext/templates/bitten_summary_coverage.cs
+++ b/bitten/trac_ext/templates/bitten_summary_coverage.cs
@@ -1,9 +1,10 @@
 <h3>Code Coverage</h3>
 <table class="listing coverage">
- <thead><tr><th>Unit</th><th>Lines of Code</th><th>Coverage</th></tr></thead>
+ <thead><tr><th class="name">Unit</th><th class="loc">Lines of Code</th>
+ <th clsas="cov">Coverage</th></tr></thead>
  <tbody><?cs
- each:item = data ?><tr><td><a href="<?cs
-  var:item.href ?>"><?cs var:item.name ?></a></td><td><?cs
-  var:item.loc ?></td><td><?cs var:item.cov ?></td></tr><?cs
+ each:item = data ?><tr><td class="name"><a href="<?cs
+  var:item.href ?>"><?cs var:item.name ?></a></td><td class="loc"><?cs
+  var:item.loc ?></td><td class="cov"><?cs var:item.cov ?>%</td></tr><?cs
  /each ?></tbody>
 </table>
--- a/bitten/trac_ext/templates/bitten_summary_tests.cs
+++ b/bitten/trac_ext/templates/bitten_summary_tests.cs
@@ -7,8 +7,8 @@
  <tbody><?cs
  each:item = data ?><tr><td><a href="<?cs
   var:item.href ?>"><?cs var:item.name ?></a></td><td><?cs
-  var:#item.success + #item.failures + #item.errors ?></td><td><?cs
-  var:item.failures ?></td><td><?cs
-  var:item.errors ?></td></tr><?cs
+  var:#item.num_success + #item.num_failure + #item.num_error ?></td><td><?cs
+  var:item.num_failure ?></td><td><?cs
+  var:item.num_error ?></td></tr><?cs
  /each ?></tbody>
 </table>
--- a/bitten/trac_ext/web_ui.py
+++ b/bitten/trac_ext/web_ui.py
@@ -18,8 +18,8 @@
 from trac.web.chrome import INavigationContributor, ITemplateProvider, \
                             add_link, add_stylesheet
 from trac.wiki import wiki_to_html
-from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, BuildLog
-from bitten.store import get_store
+from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \
+                         BuildLog, Report
 from bitten.trac_ext.api import ILogFormatter, IReportSummarizer
 
 _status_label = {Build.IN_PROGRESS: 'in progress',
@@ -182,13 +182,9 @@
         config = BuildConfig.fetch(self.env, config_name, db=db)
         assert config, 'Build configuration "%s" does not exist' % config_name
 
-        store = get_store(self.env)
-        store.delete(config=config)
-
         config.delete(db=db)
 
         db.commit()
-        store.commit()
 
         req.redirect(self.env.href.build())
 
@@ -328,16 +324,15 @@
             {'name': platform.name, 'id': platform.id} for platform in platforms
         ]
 
-        store = get_store(self.env)
         has_reports = False
-        for report in  store.query('', config=config):
+        for report in Report.select(self.env, config=config.name):
             has_reports = True
             break
 
         if has_reports:
             req.hdf['config.charts'] = [
-                {'href': self.env.href.build(config.name, 'chart/unittest')},
-                {'href': self.env.href.build(config.name, 'chart/trace')}
+                {'href': self.env.href.build(config.name, 'chart/test')},
+                {'href': self.env.href.build(config.name, 'chart/coverage')}
             ]
             charts_license = self.config.get('bitten', 'charts_license')
             if charts_license:
@@ -526,9 +521,6 @@
         for step in BuildStep.select(self.env, build=build.id, db=db):
             step.delete(db=db)
 
-        store = get_store(self.env)
-        store.delete(build=build)
-
         build.slave = None
         build.started = build.stopped = 0
         build.status = Build.PENDING
@@ -536,7 +528,6 @@
         build.update()
 
         db.commit()
-        store.commit()
 
         req.redirect(self.env.href.build(build.config))
 
@@ -546,7 +537,7 @@
             formatters = []
             for formatter in self.log_formatters:
                 formatters.append(formatter.get_formatter(req, build, step,
-                                                          log.type))
+                                                          log.generator))
             for level, message in log.messages:
                 for format in formatters:
                     message = format(level, message)
@@ -556,30 +547,25 @@
     def _render_reports(self, req, build, step):
         summarizers = {} # keyed by report type
         for summarizer in self.report_summarizers:
-            types = summarizer.get_supported_report_types()
-            summarizers.update(dict([(type, summarizer) for type in types]))
+            categories = summarizer.get_supported_categories()
+            summarizers.update(dict([(cat, summarizer) for cat in categories]))
 
-        store = get_store(self.env)
         reports = []
-        for report in store.retrieve(build, step):
-            report_type = report.attr['type']
-            summarizer = summarizers.get(report_type)
+        for report in Report.select(self.env, build=build.id, step=step.name):
+            summarizer = summarizers.get(report.category)
             if summarizer:
-                summary = summarizer.render_report_summary(req, build, step,
-                                                           report)
+                summary = summarizer.render_summary(req, build, step,
+                                                    report.category)
             else:
                 summary = None
-            report_href = self.env.href.buildreport(build.id, step.name,
-                                                    report_type)
-            reports.append({'type': report_type, 'href': report_href,
-                            'summary': summary})
+            reports.append({'category': report.category, 'summary': summary})
         return reports
 
 
 class SourceFileLinkFormatter(Component):
     """Finds references to files and directories in the repository in the build
     log and renders them as links to the repository browser."""
-    
+
     implements(ILogFormatter)
 
     def get_formatter(self, req, build, step, type):
@@ -605,76 +591,3 @@
                 message = pattern.sub(_replace, message)
             return message
         return _formatter
-
-
-class BuildReportController(Component):
-    """Temporary web interface that simply displays the XML source of a report
-    using the Trac `Mimeview` component."""
-
-    implements(INavigationContributor, IRequestHandler)
-
-    template_cs = """<?cs include:"header.cs" ?>
- <div id="ctxtnav" class="nav"></div>
- <div id="content" class="build">
-  <h1>Build <a href="<?cs var:build.href ?>"><?cs var:build.id ?></a>: <?cs
-    var:report.type ?></h1>
-  <?cs var:report.preview ?>
- </div>
-<?cs include:"footer.cs" ?>"""
-
-    # INavigationContributor methods
-
-    def get_active_navigation_item(self, req):
-        return 'build'
-
-    def get_navigation_items(self, req):
-        return []
-
-    # IRequestHandler methods
-
-    def match_request(self, req):
-        match = re.match(r'/buildreport/(?P<build>[\d]+)/(?P<step>[\w]+)'
-                         r'/(?P<type>[\w]+)', req.path_info)
-        if match:
-            for name in match.groupdict():
-                req.args[name] = match.group(name)
-            return True
-
-    def process_request(self, req):
-        req.perm.assert_permission('BUILD_VIEW')
-
-        build = Build.fetch(self.env, int(req.args.get('build')))
-        if not build:
-            raise TracError, 'Build %d does not exist' % req.args.get('build')
-        step = BuildStep.fetch(self.env, build.id, req.args.get('step'))
-        if not step:
-            raise TracError, 'Build step %s does not exist' \
-                             % req.args.get('step')
-        report_type = req.args.get('type')
-
-        req.hdf['build'] = {'id': build.id,
-                            'href': self.env.href.build(build.config, build.id)}
-
-        store = get_store(self.env)
-        reports = []
-        for report in store.retrieve(build, step, report_type):
-            req.hdf['title'] = 'Build %d: %s' % (build.id, report_type)
-            xml = report._node.toprettyxml('  ')
-            if req.args.get('format') == 'xml':
-                req.send_response(200)
-                req.send_header('Content-Type', 'text/xml;charset=utf-8')
-                req.end_headers()
-                req.write(xml)
-                return
-            else:
-                from trac.mimeview import Mimeview
-                preview = Mimeview(self.env).render(req, 'application/xml', xml)
-                req.hdf['report'] = {'type': report_type, 'preview': preview}
-            break
-
-        xml_href = self.env.href.buildreport(build.id, step.name, report_type,
-                                             format='xml')
-        add_link(req, 'alternate', xml_href, 'XML', 'text/xml')
-        add_stylesheet(req, 'common/css/code.css')
-        template = req.hdf.parse(self.template_cs)
-        return template, None
--- a/bitten/upgrades.py
+++ b/bitten/upgrades.py
@@ -8,8 +8,10 @@
 # are also available at http://bitten.cmlenz.net/wiki/License.
 
 import os
+import sys
 
 def add_log_table(env, db):
+    """Add a table for storing the builds logs."""
     from bitten.model import BuildLog, BuildStep
     cursor = db.cursor()
 
@@ -34,6 +36,9 @@
                    "started,stopped FROM old_step")
 
 def add_recipe_to_config(env, db):
+    """Add a column for storing the build recipe to the build configuration
+    table."""
+
     from bitten.model import BuildConfig
     cursor = db.cursor()
 
@@ -48,9 +53,12 @@
                    "NULL,label,description FROM old_config")
 
 def add_config_to_reports(env, db):
+    """Add the name of the build configuration as metadata to report documents
+    stored in the BDB XML database."""
+
     from bitten.model import Build
     try:
-        from bsddb3 import db
+        from bsddb3 import db as bdb
         import dbxml
     except ImportError, e:
         return
@@ -59,14 +67,14 @@
     if not os.path.isfile(dbfile):
         return
 
-    dbenv = db.DBEnv()
+    dbenv = bdb.DBEnv()
     dbenv.open(os.path.dirname(dbfile),
-               db.DB_CREATE | db.DB_INIT_LOCK | db.DB_INIT_LOG |
-               db.DB_INIT_MPOOL | db.DB_INIT_TXN, 0)
+               bdb.DB_CREATE | bdb.DB_INIT_LOCK | bdb.DB_INIT_LOG |
+               bdb.DB_INIT_MPOOL | bdb.DB_INIT_TXN, 0)
 
     mgr = dbxml.XmlManager(dbenv, 0)
     xtn = mgr.createTransaction()
-    container = mgr.openContainer(dbfile)
+    container = mgr.openContainer(dbfile, dbxml.DBXML_TRANSACTIONAL)
     uc = mgr.createUpdateContext()
 
     container.addIndex(xtn, '', 'config', 'node-metadata-equality-string', uc)
@@ -89,8 +97,122 @@
     container.close()
     dbenv.close(0)
 
+def add_order_to_log(env, db):
+    """Add order column to log table to make sure that build logs are displayed
+    in the order they were generated."""
+    from bitten.model import BuildLog
+    cursor = db.cursor()
+
+    cursor.execute("CREATE TEMP TABLE old_log AS "
+                   "SELECT * FROM bitten_log")
+    cursor.execute("DROP TABLE bitten_log")
+    for stmt in db.to_sql(BuildLog._schema[0]):
+        cursor.execute(stmt)
+    cursor.execute("INSERT INTO bitten_log (id,build,step,generator,orderno) "
+                   "SELECT id,build,step,type,0 FROM old_log")
+
+def add_report_tables(env, db):
+    """Add database tables for report storage."""
+    from bitten.model import Report
+    cursor = db.cursor()
+
+    for table in Report._schema:
+        for stmt in db.to_sql(table):
+            cursor.execute(stmt)
+
+def xmldb_to_db(env, db):
+    """Migrate report data from Berkeley DB XML to SQL database.
+    
+    Depending on the number of reports stored, this might take rather long.
+    After the upgrade is done, the bitten.dbxml file (and any BDB XML log files)
+    may be deleted. BDB XML is no longer used by Bitten.
+    """
+    from bitten.model import Report
+    from bitten.util import xmlio
+    try:
+        from bsddb3 import db as bdb
+        import dbxml
+    except ImportError, e:
+        return
+
+    dbfile = os.path.join(env.path, 'db', 'bitten.dbxml')
+    if not os.path.isfile(dbfile):
+        return
+
+    dbenv = bdb.DBEnv()
+    dbenv.open(os.path.dirname(dbfile),
+               bdb.DB_CREATE | bdb.DB_INIT_LOCK | bdb.DB_INIT_LOG |
+               bdb.DB_INIT_MPOOL | bdb.DB_INIT_TXN, 0)
+
+    mgr = dbxml.XmlManager(dbenv, 0)
+    xtn = mgr.createTransaction()
+    container = mgr.openContainer(dbfile, dbxml.DBXML_TRANSACTIONAL)
+
+    def get_pylint_items(xml):
+        for problems_elem in xml.children('problems'):
+            for problem_elem in problems_elem.children('problem'):
+                item = {'type': 'problem'}
+                item.update(problem_elem.attr)
+                yield item
+
+    def get_trace_items(xml):
+        for cov_elem in xml.children('coverage'):
+            item = {'type': 'coverage', 'name': cov_elem.attr['module'],
+                    'file': cov_elem.attr['file'],
+                    'percentage': cov_elem.attr['percentage']}
+            lines = 0
+            line_hits = []
+            for line_elem in cov_elem.children('line'):
+                lines += 1
+                line_hits.append(line_elem.attr['hits'])
+            item['lines'] = lines
+            item['line_hits'] = ' '.join(line_hits)
+            yield item
+
+    def get_unittest_items(xml):
+        for test_elem in xml.children('test'):
+            item = {'type': 'test'}
+            item.update(test_elem.attr)
+            for child_elem in test_elem.children():
+                item[child_elem.name] = child_elem.gettext()
+            yield item
+
+    qc = mgr.createQueryContext()
+    for value in mgr.query(xtn, 'collection("%s")/report' % dbfile, qc, 0):
+        doc = value.asDocument()
+        metaval = dbxml.XmlValue()
+        build, step = None, None
+        if doc.getMetaData('', 'build', metaval):
+            build = metaval.asNumber()
+        if doc.getMetaData('', 'step', metaval):
+            step = metaval.asString()
+
+        report_types = {'pylint':   ('lint', get_pylint_items),
+                        'trace':    ('coverage', get_trace_items),
+                        'unittest': ('test', get_unittest_items)}
+        xml = xmlio.parse(value.asString())
+        report_type = xml.attr['type']
+        category, get_items = report_types[report_type]
+        sys.stderr.write('.')
+        sys.stderr.flush()
+        report = Report(env, build, step, category=category,
+                        generator=report_type)
+        report.items = list(get_items(xml))
+        try:
+            report.insert(db=db)
+        except AssertionError:
+            # Duplicate report, skip
+            pass
+    sys.stderr.write('\n')
+    sys.stderr.flush()
+
+    xtn.abort()
+    container.close()
+    dbenv.close(0)
+
 map = {
     2: [add_log_table],
     3: [add_recipe_to_config],
-    4: [add_config_to_reports]
+    4: [add_config_to_reports],
+    5: [add_order_to_log, add_report_tables, xmldb_to_db]
 }
--- a/bitten/util/testrunner.py
+++ b/bitten/util/testrunner.py
@@ -134,16 +134,18 @@
             from trace import Trace
             trace = Trace(ignoredirs=[sys.prefix, sys.exec_prefix],
                           trace=False, count=True)
-            trace.runfunc(self._run_tests)
-            results = trace.results()
-            real_stdout = sys.stdout
-            sys.stdout = open(self.coverage_results, 'w')
             try:
-                results.write_results(show_missing=True, summary=True,
-                                      coverdir=self.coverage_dir)
+                trace.runfunc(self._run_tests)
             finally:
-                sys.stdout.close()
-                sys.stdout = real_stdout
+                results = trace.results()
+                real_stdout = sys.stdout
+                sys.stdout = open(self.coverage_results, 'w')
+                try:
+                    results.write_results(show_missing=True, summary=True,
+                                          coverdir=self.coverage_dir)
+                finally:
+                    sys.stdout.close()
+                    sys.stdout = real_stdout
         else:
             self._run_tests()
 
--- a/scripts/build.py
+++ b/scripts/build.py
@@ -51,7 +51,7 @@
             if not steps_to_run or step.id in steps_to_run:
                 print
                 print '-->', step.id
-                for type, function, output in step.execute(recipe.ctxt):
+                for type, category, generator, output in step.execute(recipe.ctxt):
                     if type == Recipe.ERROR:
                         log.error('Failure in step "%s": %s', step.id, output)
                     elif type == Recipe.REPORT and options.print_reports:
Copyright (C) 2012-2017 Edgewall Software