# HG changeset patch # User cmlenz # Date 1127143334 0 # Node ID e6ddca1e5712da59f9b4e53c76f9f70774bb760f # Parent ae4b03619d9a4ff1107a47520b6b94ef9bd4a330 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. diff --git a/bitten/__init__.py b/bitten/__init__.py --- 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' diff --git a/bitten/build/pythontools.py b/bitten/build/pythontools.py --- a/bitten/build/pythontools.py +++ b/bitten/build/pythontools.py @@ -69,7 +69,7 @@ r'(?P.*)$') 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: diff --git a/bitten/build/tests/pythontools.py b/bitten/build/tests/pythontools.py --- 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 @@ '') 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 @@ '') 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')) diff --git a/bitten/master.py b/bitten/master.py --- 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): diff --git a/bitten/model.py b/bitten/model.py --- 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 diff --git a/bitten/recipe.py b/bitten/recipe.py --- 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 diff --git a/bitten/slave.py b/bitten/slave.py --- 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 diff --git a/bitten/store.py b/bitten/store.py deleted file mode 100644 --- a/bitten/store.py +++ /dev/null @@ -1,238 +0,0 @@ -# -*- coding: iso8859-1 -*- -# -# Copyright (C) 2005 Christopher Lenz -# 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')) diff --git a/bitten/tests/__init__.py b/bitten/tests/__init__.py --- 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()) diff --git a/bitten/tests/model.py b/bitten/tests/model.py --- 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__': diff --git a/bitten/tests/store.py b/bitten/tests/store.py deleted file mode 100644 --- a/bitten/tests/store.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- coding: iso8859-1 -*- -# -# Copyright (C) 2005 Christopher Lenz -# 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') diff --git a/bitten/trac_ext/api.py b/bitten/trac_ext/api.py --- 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 diff --git a/bitten/trac_ext/charts.py b/bitten/trac_ext/charts.py --- 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 - - -""" + 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 - - { - for $coverage in $report/coverage - return - count($coverage/line) * ($coverage/@percentage/text() div 100) - } - -""" + 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' diff --git a/bitten/trac_ext/main.py b/bitten/trac_ext/main.py --- 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', diff --git a/bitten/trac_ext/summarizers.py b/bitten/trac_ext/summarizers.py --- 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 - -""" - + 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 - -""" + 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') diff --git a/bitten/trac_ext/templates/bitten_summary_coverage.cs b/bitten/trac_ext/templates/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 @@

Code Coverage

- + +
UnitLines of CodeCoverage
UnitLines of CodeCoverage
%
diff --git a/bitten/trac_ext/templates/bitten_summary_tests.cs b/bitten/trac_ext/templates/bitten_summary_tests.cs --- a/bitten/trac_ext/templates/bitten_summary_tests.cs +++ b/bitten/trac_ext/templates/bitten_summary_tests.cs @@ -7,8 +7,8 @@ diff --git a/bitten/trac_ext/web_ui.py b/bitten/trac_ext/web_ui.py --- 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 = """ - -
-

Build :

- -
-""" - - # 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[\d]+)/(?P[\w]+)' - r'/(?P[\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 diff --git a/bitten/upgrades.py b/bitten/upgrades.py --- 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] } diff --git a/bitten/util/testrunner.py b/bitten/util/testrunner.py --- 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() diff --git a/scripts/build.py b/scripts/build.py --- 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: