# HG changeset patch # User cmlenz # Date 1119909058 0 # Node ID 055a6c666fa8264ad5359477ba21d64dc791e4b9 # Parent a9f0c31b9a69e2eddf15a19b1a1b4e481b57e866 * Pass a {{{Context}}} object to recipe commands as the first argument. Currently this only has the basedir, but will be extended to also provide output recording etc. * Fixes and cleanup to the recipe functions. * The build master now checks for failed build steps and sets the status of the build to ''failure'' if any step failed. * The test runner distutils command now also records output to stderr and stdout by the tests. * Upped version number to [milestone:0.2 0.2]. diff --git a/bitten/__init__.py b/bitten/__init__.py --- a/bitten/__init__.py +++ b/bitten/__init__.py @@ -18,7 +18,7 @@ # # Author: Christopher Lenz -__version__ = '0.1' +__version__ = '0.2' class BuildError(Exception): pass diff --git a/bitten/build/ctools.py b/bitten/build/ctools.py --- a/bitten/build/ctools.py +++ b/bitten/build/ctools.py @@ -20,9 +20,17 @@ from bitten.util.cmdline import Commandline -def make(basedir, target='all'): +def make(ctxt, target='all', file=None, jobs=None, keep_going=False): """Execute a Makefile target.""" - cmdline = Commandline('make', ['-C', basedir, target]) + args = ['-C', ctxt.basedir] + if file: + args += ['-f', ctxt.resolve(file)] + if jobs: + args += ['-j', int(jobs)] + if keep_going: + args.append('-k') + args.append(target) + cmdline = Commandline('make', args) for out, err in cmdline.execute(timeout=100.0): if out: for line in out.splitlines(): @@ -31,4 +39,4 @@ for line in err.splitlines(): print '[make] %s' % err if cmdline.returncode != 0: - raise BuildError, "Executing make failed (%s)" % retval + raise BuildError, "Executing make failed (%s)" % cmdline.returncode diff --git a/bitten/build/pythontools.py b/bitten/build/pythontools.py --- a/bitten/build/pythontools.py +++ b/bitten/build/pythontools.py @@ -23,49 +23,58 @@ from bitten import BuildError from bitten.util.cmdline import Commandline -def distutils(basedir, command='build'): +def distutils(ctxt, command='build'): """Execute a `distutils` command.""" - cmdline = Commandline('python', ['setup.py', command], cwd=basedir) + cmdline = Commandline('python', ['setup.py', command], cwd=ctxt.basedir) for out, err in cmdline.execute(timeout=100.0): if out: print '[distutils] %s' % out if err: print '[distutils] %s' % err if cmdline.returncode != 0: - raise BuildError, "Executing distutils failed (%s)" % retval + raise BuildError, "Executing distutils failed (%s)" % cmdline.returncode -def pylint(basedir, file=None): +def pylint(ctxt, file=None): """Extract data from a `pylint` run written to a file.""" assert file, 'Missing required attribute "file"' _msg_re = re.compile(r'^(?P.+):(?P\d+): ' r'\[(?P[A-Z])(?:, (?P[\w\.]+))?\] ' r'(?P.*)$') - for line in open(file, 'r'): - match = _msg_re.search(line) - if match: - filename = match.group('file') - if filename.startswith(basedir): - filename = filename[len(basedir) + 1:] - lineno = int(match.group('line')) - # TODO: emit to build master -def trace(basedir, summary=None, coverdir=None, include=None, exclude=None): + fd = open(ctxt.resolve(file), 'r') + try: + for line in fd: + match = _msg_re.search(line) + if match: + filename = match.group('file') + if filename.startswith(ctxt.basedir): + filename = filename[len(ctxt.basedir) + 1:] + lineno = int(match.group('line')) + # TODO: emit to build master + finally: + fd.close() + +def trace(ctxt, summary=None, coverdir=None, include=None, exclude=None): """Extract data from a `trace.py` run.""" assert summary, 'Missing required attribute "summary"' assert coverdir, 'Missing required attribute "coverdir"' -def unittest(basedir, file=None): +def unittest(ctxt, file=None): """Extract data from a unittest results file in XML format.""" assert file, 'Missing required attribute "file"' - from xml.dom import minidom - root = minidom.parse(open(file, 'r')).documentElement - assert root.tagName == 'unittest-results' - for test in root.getElementsByTagName('test'): - filename = test.getAttribute('file') - if filename.startswith(basedir): - filename = filename[len(basedir) + 1:] - duration = float(test.getAttribute('duration')) - name = test.getAttribute('name') - status = test.getAttribute('status') - # TODO: emit to build master + fd = open(ctxt.resolve(file), 'r') + try: + from xml.dom import minidom + root = minidom.parse(fd).documentElement + assert root.tagName == 'unittest-results' + for test in root.getElementsByTagName('test'): + filename = test.getAttribute('file') + if filename.startswith(ctxt.basedir): + filename = filename[len(ctxt.basedir) + 1:] + duration = float(test.getAttribute('duration')) + name = test.getAttribute('name') + status = test.getAttribute('status') + # TODO: emit to build master + finally: + fd.close() diff --git a/bitten/master.py b/bitten/master.py --- a/bitten/master.py +++ b/bitten/master.py @@ -117,15 +117,10 @@ """ URI = 'http://bitten.cmlenz.net/beep/orchestration' - IDLE = 0 - STARTING = 1 - STARTED = 2 - def handle_connect(self): self.master = self.session.listener assert self.master self.name = None - self.state = self.IDLE def handle_disconnect(self): del self.master.slaves[self.name] @@ -209,6 +204,7 @@ return if cmd == 'ANS': if ansno == 0: + self.steps = [] build.slave = self.name build.time = int(time.time()) build.status = Build.IN_PROGRESS @@ -221,11 +217,15 @@ logging.info('Slave completed step "%s"', elem.attr['id']) if elem.attr['result'] == 'failure': logging.warning('Step failed: %s', elem.gettext()) + self.steps.append((elem.attr['id'], elem.attr['result'])) elif cmd == 'NUL': logging.info('Slave %s completed build of "%s" as of [%s]', self.name, build.config, build.rev) build.duration = int(time.time()) - build.time - build.status = Build.SUCCESS # FIXME: or failure? + if [step for step in self.steps if step[1] == 'failure']: + build.status = Build.FAILURE + else: + build.status = Build.SUCCESS build.update() # TODO: should not block while reading the file; rather stream it using diff --git a/bitten/recipe.py b/bitten/recipe.py --- a/bitten/recipe.py +++ b/bitten/recipe.py @@ -26,6 +26,20 @@ __all__ = ['Recipe'] +class InvalidRecipeError(Exception): + """Exception raised when a recipe cannot be processed.""" + + +class Context(object): + """The context in which a recipe command or report is run.""" + + def __init__(self, basedir): + self.basedir = basedir + + def resolve(self, *path): + return os.path.join(self.basedir, *path) + + class Step(object): """Represents a single step of a build recipe. @@ -40,40 +54,48 @@ def __iter__(self): for child in self._elem: - if child.namespace: - # Commands - yield self._translate(child), child.attr - elif child.name == 'reports': - # Reports + if child.namespace: # Commands + yield self._function(child), self._args(child) + elif child.name == 'reports': # Reports for grandchild in child: - yield self._translate(grandchild), grandchild.attr + yield self._function(grandchild), self._args(grandchild) else: - raise BuildError, "Unknown element <%s>" % child.name + raise InvalidRecipeError, "Unknown element <%s>" % child.name - def _translate(self, elem): + def _args(self, elem): + return dict([(name.replace('-', '_'), value) for name, value + in elem.attr.items()]) + + def _function(self, elem): if not elem.namespace.startswith('bitten:'): # Ignore elements in foreign namespaces return None - - module = __import__(elem.namespace[7:], globals(), locals(), elem.name) - func = getattr(module, elem.name) - return func + func_name = elem.name.replace('-', '_') + try: + module = __import__(elem.namespace[7:], globals(), locals(), + func_name) + func = getattr(module, elem.name) + return func + except (ImportError, AttributeError), e: + raise InvalidRecipeError, 'Cannot load "%s" (%s)' % (elem.name, e) class Recipe(object): - """Represents a build recipe. + """A build recipe. Iterate over this object to get the individual build steps in the order they have been defined in the recipe file.""" def __init__(self, filename='recipe.xml', basedir=os.getcwd()): - self.filename = filename - self.basedir = basedir - self.path = os.path.join(basedir, filename) - self.root = xmlio.parse(file(self.path, 'r')) - self.description = self.root.attr.get('description') + self.ctxt = Context(basedir) + fd = file(self.ctxt.resolve(filename), 'r') + try: + self._root = xmlio.parse(fd) + finally: + fd.close() + self.description = self._root.attr.get('description') def __iter__(self): """Provide an iterator over the individual steps of the recipe.""" - for child in self.root.children('step'): + for child in self._root.children('step'): yield Step(child) diff --git a/bitten/slave.py b/bitten/slave.py --- a/bitten/slave.py +++ b/bitten/slave.py @@ -133,7 +133,7 @@ try: for function, args in step: logging.debug('Executing command "%s"', function) - function(recipe.basedir, **args) + function(recipe.ctxt, **args) xml = xmlio.Element('step', id=step.id, result='success', description=step.description) self.channel.send_ans(msgno, beep.MIMEMessage(xml)) 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 @@ -252,8 +252,7 @@ } if not build.slave: continue - status_label = {Build.PENDING: 'pending', - Build.IN_PROGRESS: 'in progress', + status_label = {Build.IN_PROGRESS: 'in progress', Build.SUCCESS: 'success', Build.FAILURE: 'failed'} prefix = 'build.config.builds.%d.slaves.%d' % (idx, slave_idx) req.hdf[prefix] = {'name': build.slave, diff --git a/bitten/util/testrunner.py b/bitten/util/testrunner.py --- a/bitten/util/testrunner.py +++ b/bitten/util/testrunner.py @@ -18,7 +18,12 @@ # # Author: Christopher Lenz +import os import re +try: + from cStringIO import StringIO +except: + from StringIO import StringIO import sys import time from distutils.core import Command @@ -26,22 +31,32 @@ from bitten.util.xmlio import Element, SubElement + class XMLTestResult(_TextTestResult): def __init__(self, stream, descriptions, verbosity): _TextTestResult.__init__(self, stream, descriptions, verbosity) self.tests = [] + self.orig_stdout = self.orig_stderr = None + self.buf_stdout = self.buf_stderr = None def startTest(self, test): _TextTestResult.startTest(self, test) filename = sys.modules[test.__module__].__file__ if filename.endswith('.pyc') or filename.endswith('.pyo'): filename = filename[:-1] - self.tests.append([test, filename, time.time()]) + self.tests.append([test, filename, time.time(), None, None]) + + # Record output by the test to stdout and stderr + self.old_stdout, self.buf_stdout = sys.stdout, StringIO() + self.old_stderr, self.buf_stderr = sys.stderr, StringIO() + sys.stdout, sys.stderr = self.buf_stdout, self.buf_stderr def stopTest(self, test): _TextTestResult.stopTest(self, test) - self.tests[-1][-1] = time.time() - self.tests[-1][-1] + self.tests[-1][2] = time.time() - self.tests[-1][2] + self.tests[-1][3] = self.buf_stdout.getvalue() + self.tests[-1][4] = self.buf_stderr.getvalue() class XMLTestRunner(TextTestRunner): @@ -59,10 +74,10 @@ return result root = Element('unittest-results') - for testcase, filename, timetaken in result.tests: + for testcase, filename, timetaken, stdout, stderr in result.tests: status = 'success' tb = None - + if testcase in [e[0] for e in result.errors]: status = 'error' tb = [e[1] for e in result.errors if e[0] is testcase][0] @@ -74,9 +89,13 @@ status=status, duration=timetaken) description = testcase.shortDescription() if description: - desc_elem = SubElement(test_elem, 'description')[description] + SubElement(test_elem, 'description')[description] + if stdout: + SubElement(test_elem, 'stdout')[stdout] + if stderr: + SubElement(test_elem, 'stdout')[stderr] if tb: - tb_elem = SubElement(test_elem, 'traceback')[tb] + SubElement(test_elem, 'traceback')[tb] root.write(self.xml_stream, newlines=True) return result @@ -102,6 +121,8 @@ def finalize_options(self): assert self.test_suite, 'Missing required attribute "test-suite"' if self.xml_results is not None: + if not os.path.exists(os.path.dirname(self.xml_results)): + os.makedirs(os.path.dirname(self.xml_results)) self.xml_results = open(self.xml_results, 'w') def run(self): diff --git a/scripts/build.py b/scripts/build.py --- a/scripts/build.py +++ b/scripts/build.py @@ -35,7 +35,7 @@ if not step_id or step.id == step_id: print '-->', step.description or step.id for function, kw in step: - function(recipe.basedir, **kw) + function(recipe.ctxt, **kw) print steps_run.append(step.id)