changeset 60:055a6c666fa8

* 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].
author cmlenz
date Mon, 27 Jun 2005 21:50:58 +0000
parents a9f0c31b9a69
children 47ab019508dd
files bitten/__init__.py bitten/build/ctools.py bitten/build/pythontools.py bitten/master.py bitten/recipe.py bitten/slave.py bitten/trac_ext/web_ui.py bitten/util/testrunner.py scripts/build.py
diffstat 9 files changed, 123 insertions(+), 64 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/__init__.py
+++ b/bitten/__init__.py
@@ -18,7 +18,7 @@
 #
 # Author: Christopher Lenz <cmlenz@gmx.de>
 
-__version__ = '0.1'
+__version__ = '0.2'
 
 class BuildError(Exception):
     pass
--- 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
--- 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<file>.+):(?P<line>\d+): '
                          r'\[(?P<type>[A-Z])(?:, (?P<tag>[\w\.]+))?\] '
                          r'(?P<msg>.*)$')
-    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()
--- 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
--- 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)
--- 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))
--- 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,
--- a/bitten/util/testrunner.py
+++ b/bitten/util/testrunner.py
@@ -18,7 +18,12 @@
 #
 # Author: Christopher Lenz <cmlenz@gmx.de>
 
+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):
--- 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)
 
Copyright (C) 2012-2017 Edgewall Software