changeset 174:79c61c26a4e1

* Changed the `IReportStore` interface to allow querying with [http://www.w3.org/XML/Query/ XQuery]. This should make it possible to efficiently query the report store for any existing metrics. * The objects yielded by the `BDBXMLBackend` of the report store no longer re-parse the returned documents/elements using the Python `minidom` API. Instead, the `XmlValue` objects returned by BDB XML are wrapped in an adapter that provides compatibility with `bitten.util.xmlio.ParsedElement`. * The summarizers for code coverage and test results (introduced in r169) are now based on an abstract base class that executes an arbitrary XQuery query, and maps the results to the HDF. * The templates for these two summarizers are now stored in separated files and can thus be overridden by the project admin.
author cmlenz
date Tue, 30 Aug 2005 18:44:55 +0000
parents f0c8d52a9447
children f7c2f112afe6
files bitten/model.py bitten/store.py bitten/tests/store.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
diffstat 8 files changed, 214 insertions(+), 222 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -707,4 +707,4 @@
 
 schema = BuildConfig._schema + TargetPlatform._schema + Build._schema + \
          BuildStep._schema + BuildLog._schema
-schema_version = 3
+schema_version = 4
--- a/bitten/store.py
+++ b/bitten/store.py
@@ -36,6 +36,11 @@
                   backend.__class__.__name__)
         backend.store_report(build, step, xml)
 
+    def query_reports(self, xquery, config=None, build=None, step=None,
+                     type=None):
+        backend = self._get_configured_backend()
+        return backend.query_reports(xquery, config, build, step, type)
+
     def retrieve_reports(self, build, step=None, type=None):
         backend = self._get_configured_backend()
         return backend.retrieve_reports(build, step, type)
@@ -58,6 +63,7 @@
     implements(IReportStoreBackend)
 
     indexes = [
+        ('config', 'node-metadata-equality-string'),
         ('build', 'node-metadata-equality-decimal'),
         ('step',  'node-metadata-equality-string'),
         ('type',  'node-attribute-equality-string'),
@@ -66,25 +72,29 @@
     ]
 
 
-    class XmlValueWrapper(xmlio.ParsedElement):
-
-        _metadata = None
+    class XmlValueAdapter(xmlio.ParsedElement):
 
         def __init__(self, value):
-            self.value = value
-            from xml.dom import minidom
-            dom = minidom.parseString(value.asString())
-            xmlio.ParsedElement.__init__(self, dom.documentElement)
+            self._value = value
+            self.attr = {}
+            for attr in value.getAttributes():
+                self.attr[attr.getLocalName()] = attr.getNodeValue()
 
-        def _get_metadata(self):
-            if self._metadata is None:
-                self._metadata = {}
-                for metadata in self.value.asDocument().getMetaDataIterator():
-                    if not metadata.get_uri():
-                        self._metadata[metadata.get_name()] = \
-                            metadata.get_value().asString()
-            return self._metadata
-        metadata = property(fget=lambda self: self._get_metadata())
+        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)
+                child = child.getNextSibling()
+
+        def gettext(self):
+            raise NotImplementedError
+
+        def write(self, out, newlines=False):
+            return self._value.asString()
 
 
     def __init__(self):
@@ -99,28 +109,46 @@
         ctxt = mgr.createUpdateContext()
         doc = 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))
         container.putDocument(doc, ctxt, dbxml.DBXML_GEN_NAME)
 
+    def query_reports(self, xquery, config=None, build=None, step=None,
+                     type=None):
+        if dbxml is None:
+            log.warning('BDB XML not installed, cannot query reports')
+            return
+        mgr = dbxml.XmlManager()
+        container = self._open_container(mgr)
+        ctxt = 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
+        self.log.debug('Executíng XQuery: %s', query)
+
+        results = mgr.query(query, ctxt)
+        for value in results:
+            yield BDBXMLBackend.XmlValueAdapter(value)
+
     def retrieve_reports(self, build, step=None, type=None):
         if dbxml is None:
             log.warning('BDB XML not installed, cannot retrieve reports')
             return
-        path = os.path.join(self.env.path, 'db', 'bitten.dbxml')
-        mgr = dbxml.XmlManager()
-        container = self._open_container(mgr)
-        ctxt = mgr.createQueryContext()
-        query = "collection('%s')/report[dbxml:metadata('build')=%d " \
-                % (path, build.id)
-        if step is not None:
-            query += " and dbxml:metadata('step')='%s'" % step.name
-        if type is not None:
-            query += " and @type='%s'" % type
-        query += "]"
-        results = mgr.query(query, ctxt)
-        for value in results:
-            yield BDBXMLBackend.XmlValueWrapper(value)
+        return self.query_reports('return $reports', build=build, step=step,
+                                  type=type)
 
     def _open_container(self, mgr, create=False):
         if create and not os.path.exists(self.path):
@@ -130,73 +158,3 @@
                 container.addIndex('', name, index, ctxt)
             return container
         return mgr.openContainer(self.path)
-
-
-class FSBackend(Component):
-    implements(IReportStoreBackend)
-
-
-    class FileWrapper(xmlio.ParsedElement):
-
-        _metadata = None
-
-        def __init__(self, path):
-            self.path = path
-            from xml.dom import minidom
-            fd = file(path, 'r')
-            try:
-                dom = minidom.parse(fd)
-            finally:
-                fd.close()
-            xmlio.ParsedElement.__init__(self, dom.documentElement)
-
-        def _get_metadata(self):
-            if self._metadata is None:
-                step_dir = os.path.dirname(self.path)
-                build_dir = os.path.dirname(step_dir)
-                self._metadata = {
-                    'build': os.path.basename(build_dir),
-                    'step': os.path.basename(step_dir)
-                }
-            return self._metadata
-        metadata = property(fget=lambda self: self._get_metadata())
-
-
-    def __init__(self):
-        self.path = os.path.join(self.env.path, 'reports')
-
-    def _get_path(self, build, step, type=None):
-        if type:
-            return os.path.join(self.path, build.id, step.name, type) + '.xml'
-        else:
-            return os.path.join(self.path, build.id, step.name)
-
-    def store_report(self, build, step, xml):
-        if not os.path.exists(self.path):
-            os.mkdir(self.path)
-        dirname = os.path.join(self.path, str(build.id), step.name)
-        if not os.path.exists(dirname):
-            os.makedirs(dirname)
-        filename = os.path.join(dirname, xml.attr['type'] + '.xml')
-        fd = file(filename, 'w')
-        try:
-            xml.write(fd)
-        finally:
-            fd.close()
-
-    def retrieve_reports(self, build, step=None, type=None):
-        if step is not None:
-            dirname = os.path.join(self.path, str(build.id), step.name)
-            if os.path.exists(dirname):
-                for filename in os.listdir(dirname):
-                    if type is None or filename == type + '.xml':
-                        yield FSBackend.FileWrapper(os.path.join(dirname,
-                                                                 filename))
-        else:
-            dirname = os.path.join(self.path, str(build.id))
-            if os.path.exists(dirname):
-                for dirname in os.listdir(dirname):
-                    if os.path.isdir(dirname):
-                        reports = self.retrieve_reports(build, dirname, type)
-                        for report in reports:
-                            yield report
--- a/bitten/tests/store.py
+++ b/bitten/tests/store.py
@@ -14,7 +14,7 @@
 import unittest
 
 from trac.test import EnvironmentStub, Mock
-from bitten.store import BDBXMLBackend, FSBackend
+from bitten.store import BDBXMLBackend
 from bitten.util import xmlio
 
 
@@ -24,65 +24,60 @@
         self.env = EnvironmentStub()
         self.env.path = tempfile.mkdtemp('bitten-test')
         os.mkdir(os.path.join(self.env.path, 'db'))
+        self.store = BDBXMLBackend(self.env)
 
     def tearDown(self):
         shutil.rmtree(self.env.path)
 
     def test_store_report(self):
-        store = BDBXMLBackend(self.env)
-        build = Mock(id=42)
+        """
+        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')]
-        store.store_report(build, step, xml)
-
-        xml = xmlio.Element('report', type='lint')[xmlio.Element('dummy')]
-        store.store_report(build, step, xml)
-
-        reports = list(store.retrieve_reports(build, step))
-        self.assertEqual(2, len(reports))
-        self.assertEqual('42', reports[0].metadata['build'])
-        self.assertEqual('foo', reports[0].metadata['step'])
-        self.assertEqual('42', reports[1].metadata['build'])
-        self.assertEqual('foo', reports[1].metadata['step'])
-
-        reports = list(store.retrieve_reports(build, step, 'test'))
-        self.assertEqual(1, len(reports))
+        self.store.store_report(build, step, xml)
 
-        reports = list(store.retrieve_reports(build, step, 'lint'))
-        self.assertEqual(1, len(reports))
-
-
-class FSBackendTestCase(unittest.TestCase):
+        self.assertEqual(1, len(list(self.store.retrieve_reports(build, step,
+                                                                 'test'))))
 
-    def setUp(self):
-        self.env = EnvironmentStub()
-        self.env.path = tempfile.mkdtemp('bitten-test')
-
-    def tearDown(self):
-        shutil.rmtree(self.env.path)
-
-    def test_store_report(self):
-        store = FSBackend(self.env)
-        build = Mock(id=42)
+    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')]
-        store.store_report(build, step, xml)
-
+        self.store.store_report(build, step, xml)
         xml = xmlio.Element('report', type='lint')[xmlio.Element('dummy')]
-        store.store_report(build, step, xml)
+        self.store.store_report(build, step, xml)
 
-        reports = list(store.retrieve_reports(build, step))
-        self.assertEqual(2, len(reports))
-        self.assertEqual('42', reports[0].metadata['build'])
-        self.assertEqual('foo', reports[0].metadata['step'])
-        self.assertEqual('42', reports[1].metadata['build'])
-        self.assertEqual('foo', reports[1].metadata['step'])
+        other_step = Mock(name='bar')
+        xml = xmlio.Element('report', type='test')[xmlio.Element('dummy')]
+        self.store.store_report(build, other_step, xml)
 
-        reports = list(store.retrieve_reports(build, step, 'test'))
-        self.assertEqual(1, len(reports))
+        self.assertEqual(2, len(list(self.store.retrieve_reports(build, step))))
 
-        reports = list(store.retrieve_reports(build, step, 'lint'))
-        self.assertEqual(1, len(reports))
+    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_report(build, step_foo, xml)
+        xml = xmlio.Element('report', type='lint')[xmlio.Element('dummy')]
+        self.store.store_report(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_report(other_build, step_baz, xml)
+
+        self.assertEqual(2, len(list(self.store.retrieve_reports(build))))
 
 
 def suite():
@@ -92,7 +87,6 @@
         suite.addTest(unittest.makeSuite(BDBXMLBackendTestCase, 'test'))
     except ImportError:
         print>>sys.stderr, 'Skipping unit tests for BDB XML backend'
-    suite.addTest(unittest.makeSuite(FSBackendTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
--- a/bitten/trac_ext/summarizers.py
+++ b/bitten/trac_ext/summarizers.py
@@ -8,94 +8,77 @@
 # are also available at http://bitten.cmlenz.net/wiki/License.
 
 from trac.core import *
+from trac.util import escape
+from trac.web.chrome import Chrome
 from trac.web.clearsilver import HDFWrapper
 from bitten.model import BuildConfig
+from bitten.store import ReportStore
 from bitten.trac_ext.api import IReportSummarizer
 
 
-class TestResultsSummarizer(Component):
+class XQuerySummarizer(Component):
+    abstract = True
     implements(IReportSummarizer)
 
-    template = """<h3>Test Results</h3>
-<table class="listing tests">
- <thead><tr>
-  <th>Test Fixture</th><th>Total</th>
-  <th>Failures</th><th>Errors</th>
- </tr></thead>
- <tbody><?cs
- each:fixture = fixtures ?><tr><td><a href="<?cs
-  var:fixture.href ?>"><?cs var:fixture.name ?></a></td><td><?cs
-  var:fixture.total ?></td><td><?cs var:fixture.failures ?></td><td><?cs
-  var:fixture.errors ?></td></tr><?cs
- /each ?></tbody>
-</table>
-"""
+    query = None
+    report_type = None
+    template = None
 
     def get_supported_report_types(self):
-        return ['unittest']
+        return [self.report_type]
 
     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)
-        fixtures = {}
-        for test in report.children('test'):
-            filename = test.attr.get('file')
-            name = test.attr.get('fixture') or filename
-            status = test.attr.get('status')
-            if name in fixtures:
-                fixtures[name]['total'] += 1
-                fixtures[name]['errors'] += int(status == 'error')
-                fixtures[name]['failures'] += int(status == 'failure')
-            else:
-                file_href = None
-                if filename:
-                    file_href = self.env.href.browser(config.path, filename,
-                                                      rev=build.rev)
-                fixtures[name] = {'name': name, 'href': file_href, 'total': 1,
-                                  'errors': int(status == 'error'),
-                                  'failures': int(status == 'failure')}
-        hdf = HDFWrapper()
-        names = fixtures.keys()
-        names.sort()
-        for idx, name in enumerate(names):
-            hdf['fixtures.%d' % idx] = fixtures[name]
-        return hdf.parse(self.template).render()
+        store = ReportStore(self.env)
+        results = store.query_reports(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
+
+        return hdf.render(self.template)
 
 
-class CodeCoverageSummarizer(Component):
-    implements(IReportSummarizer)
+class TestResultsSummarizer(XQuerySummarizer):
 
-    template = """<h3>Code Coverage</h3>
-<table class="listing coverage">
- <thead><tr><th>Unit</th><th>Lines of Code</th><th>Coverage</th></tr></thead>
- <tbody><?cs
- each:unit = units ?><tr><td><a href="<?cs
-  var:unit.href ?>"><?cs var:unit.name ?></a></td><td><?cs
-  var:unit.loc ?></td><td><?cs var:unit.cov ?>%</td></tr><?cs
- /each ?></tbody>
-</table>
+    report_type = 'unittest'
+    template = 'bitten_summary_tests.cs'
+
+    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_report_types(self):
-        return ['trace']
 
-    def render_report_summary(self, req, build, step, report):
-        config = BuildConfig.fetch(self.env, name=build.config)
-        units = {}
-        for coverage in report.children('coverage'):
-            filename = coverage.attr.get('file')
-            if filename:
-                file_href = self.env.href.browser(config.path, filename,
-                                                  rev=build.rev)
-            name = coverage.attr.get('module')
-            loc = 0
-            for line in coverage.children('line'):
-                loc += 1
-            units[name] = {'name': name, 'href': file_href, 'loc': loc,
-                           'cov': coverage.attr['percentage']}
 
-        hdf = HDFWrapper()
-        names = units.keys()
-        names.sort()
-        for idx, name in enumerate(names):
-            hdf['units.%d' % idx] = units[name]
-        return hdf.parse(self.template).render()
+class CodeCoverageSummarizer(XQuerySummarizer):
+
+    report_type = 'trace'
+    template = 'bitten_summary_coverage.cs'
+
+    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}%"/>
+"""
new file mode 100644
--- /dev/null
+++ b/bitten/trac_ext/templates/bitten_summary_coverage.cs
@@ -0,0 +1,9 @@
+<h3>Code Coverage</h3>
+<table class="listing coverage">
+ <thead><tr><th>Unit</th><th>Lines of Code</th><th>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 ?></tbody>
+</table>
new file mode 100644
--- /dev/null
+++ b/bitten/trac_ext/templates/bitten_summary_tests.cs
@@ -0,0 +1,14 @@
+<h3>Test Results</h3>
+<table class="listing tests">
+ <thead><tr>
+  <th>Test Fixture</th><th>Total</th>
+  <th>Failures</th><th>Errors</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.success + #item.failures + #item.errors ?></td><td><?cs
+  var:item.failures ?></td><td><?cs
+  var:item.errors ?></td></tr><?cs
+ /each ?></tbody>
+</table>
--- a/bitten/trac_ext/web_ui.py
+++ b/bitten/trac_ext/web_ui.py
@@ -14,9 +14,9 @@
 from trac.core import *
 from trac.Timeline import ITimelineEventProvider
 from trac.util import escape, pretty_timedelta
+from trac.web import IRequestHandler
 from trac.web.chrome import INavigationContributor, ITemplateProvider, \
                             add_link, add_stylesheet
-from trac.web import IRequestHandler
 from trac.wiki import wiki_to_html
 from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, BuildLog
 from bitten.store import ReportStore
@@ -392,7 +392,7 @@
     # IRequestHandler methods
 
     def match_request(self, req):
-        match = re.match(r'/build/([\w.-]+)/([\d]+)', req.path_info)
+        match = re.match(r'/build/([\w.-]+)/(\d+)', req.path_info)
         if match:
             if match.group(1):
                 req.args['config'] = match.group(1)
--- a/bitten/upgrades.py
+++ b/bitten/upgrades.py
@@ -7,6 +7,8 @@
 # you should have received as part of this distribution. The terms
 # are also available at http://bitten.cmlenz.net/wiki/License.
 
+import os
+
 def add_log_table(env, db):
     from bitten.model import BuildLog, BuildStep
     cursor = db.cursor()
@@ -45,7 +47,39 @@
                    "max_rev,label,description) SELECT name,path,0,'',NULL,"
                    "NULL,label,description FROM old_config")
 
+def add_config_to_reports(env, db):
+    backend = env.config.get('bitten', 'report_store', 'BDBXMLBackend')
+    if backend != 'BDBXMLBackend':
+        return
+
+    from bitten.model import Build
+    try:
+        import dbxml
+    except ImportError, e:
+        return
+
+    dbfile = os.path.join(env.path, 'db', 'bitten.dbxml')
+    if not os.path.isfile(dbfile):
+        return
+
+    mgr = dbxml.XmlManager()
+    container = mgr.openContainer(dbfile)
+    uc = mgr.createUpdateContext()
+
+    container.addIndex('', 'config', 'node-metadata-equality-string', uc)
+
+    qc = mgr.createQueryContext()
+    for value in mgr.query('collection("%s")/report' % dbfile, qc):
+        doc = value.asDocument()
+        metaval = dbxml.XmlValue()
+        if doc.getMetaData('', 'build', metaval):
+            build_id = int(metaval.asNumber())
+            build = Build.fetch(env, id=build_id, db=db)
+            doc.setMetaData('', 'config', dbxml.XmlValue(build.config))
+            container.updateDocument(doc, uc)
+
 map = {
     2: [add_log_table],
-    3: [add_recipe_to_config]
+    3: [add_recipe_to_config],
+    4: [add_config_to_reports]
 }
Copyright (C) 2012-2017 Edgewall Software