changeset 645:8c824b14e1c5

0.6dev: Switching `master.py` to use new `self._send_response()` and `self._send_error() methods. Simplifies code, but most importantly for errors it allows a consistent method for transmitting plain-text error messages to the slave (that the slave will now output as part of debug logging). Raising Trac HTTP* errors actually causes full rendering of an HTML error page as response, which is both inefficient as well as making it near-impossible to extract the 'hidden' message from the master.
author osimons
date Mon, 24 Aug 2009 12:00:43 +0000
parents 9331ab08000f
children fe344dac8385
files bitten/master.py bitten/slave.py bitten/tests/master.py
diffstat 3 files changed, 308 insertions(+), 257 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -19,9 +19,7 @@
 from trac.config import BoolOption, IntOption, Option
 from trac.core import *
 from trac.resource import ResourceNotFound
-from trac.web import IRequestHandler, HTTPBadRequest, HTTPConflict, \
-                     HTTPForbidden, HTTPMethodNotAllowed, HTTPNotFound, \
-                     RequestDone
+from trac.web import IRequestHandler, RequestDone
 
 from bitten.model import BuildConfig, Build, BuildStep, BuildLog, Report, \
                      TargetPlatform
@@ -35,6 +33,13 @@
 __docformat__ = 'restructuredtext en'
 
 
+HTTP_BAD_REQUEST = 400
+HTTP_FORBIDDEN = 403
+HTTP_NOT_FOUND = 404
+HTTP_METHOD_NOT_ALLOWED = 405
+HTTP_CONFLICT = 409
+
+
 class BuildMaster(Component):
     """Trac request handler implementation for the build master."""
 
@@ -88,12 +93,14 @@
 
         if 'id' not in req.args:
             if req.method != 'POST':
-                raise HTTPMethodNotAllowed('Method not allowed')
+                self._send_error(req, HTTP_METHOD_NOT_ALLOWED,
+                                      'Only POST allowed for build creation')
             return self._process_build_creation(req)
 
         build = Build.fetch(self.env, req.args['id'])
         if not build:
-            raise HTTPNotFound('No such build')
+            self._send_error(req, HTTP_NOT_FOUND,
+                                  'No such build (%s)' % req.args['id'])
         config = BuildConfig.fetch(self.env, build.config)
 
         if not req.args['collection']:
@@ -103,12 +110,31 @@
                 return self._process_build_initiation(req, config, build)
 
         if req.method != 'POST':
-            raise HTTPMethodNotAllowed('Method not allowed')
+            self._send_error(req, HTTP_METHOD_NOT_ALLOWED,
+                                  'Method %s not allowed' % req.method)
 
         if req.args['collection'] == 'steps':
             return self._process_build_step(req, config, build)
         else:
-            raise HTTPNotFound('No such collection')
+            self._send_error(req, HTTP_NOT_FOUND,
+                    "No such collection '%s'" % req.args['collection'])
+
+    # Internal methods
+
+    def _send_response(self, req, code=200, body='', headers=None):
+        """ Formats and sends the response, raising ``RequestDone``. """
+        req.send_response(code)
+        headers = headers or {}
+        for header in headers:
+            req.send_header(header, headers[header])
+        req.write(body)
+        raise RequestDone
+
+    def _send_error(self, req, code=500, message=''):
+        """ Formats and sends the error, raising ``RequestDone``. """
+        headers = {'Content-Type': 'text/plain',
+                   'Content-Length': str(len(message))}
+        self._send_response(req, code, body=message, headers=headers)
 
     def _process_build_creation(self, req):
         queue = BuildQueue(self.env, build_all=self.build_all, 
@@ -121,7 +147,7 @@
         except xmlio.ParseError, e:
             self.log.error('Error parsing build initialization request: %s', e,
                            exc_info=True)
-            raise HTTPBadRequest('XML parser error')
+            self._send_error(req, HTTP_BAD_REQUEST, 'XML parser error')
 
         slavename = elem.attr['name']
         properties = {'name': slavename, Build.IP_ADDRESS: req.remote_addr}
@@ -146,15 +172,11 @@
 
         build = queue.get_build_for_slave(slavename, properties)
         if not build:
-            req.send_response(204)
-            req.write('')
-            raise RequestDone
+            self._send_response(req, 204, '', {})
 
-        req.send_response(201)
-        req.send_header('Content-Type', 'text/plain')
-        req.send_header('Location', req.abs_href.builds(build.id))
-        req.write('Build pending')
-        raise RequestDone
+        self._send_response(req, 201, 'Build pending', headers={
+                            'Content-Type': 'text/plain',
+                            'Location': req.abs_href.builds(build.id)})
 
     def _process_build_cancellation(self, req, config, build):
         self.log.info('Build slave %r cancelled build %d', build.slave,
@@ -172,9 +194,7 @@
         for listener in BuildSystem(self.env).listeners:
             listener.build_aborted(build)
 
-        req.send_response(204)
-        req.write('')
-        raise RequestDone
+        self._send_response(req, 204, '', {})
 
     def _process_build_initiation(self, req, config, build):
         self.log.info('Build slave %r initiated build %d', build.slave,
@@ -198,14 +218,12 @@
         self.log.info('Build slave %r initiated build %d', build.slave,
                       build.id)
 
-        req.send_response(200)
-        req.send_header('Content-Type', 'application/x-bitten+xml')
-        req.send_header('Content-Length', str(len(body)))
-        req.send_header('Content-Disposition',
+        self._send_response(req, 200, body, headers={
+                    'Content-Type': 'application/x-bitten+xml',
+                    'Content-Length': str(len(body)),
+                    'Content-Disposition':
                         'attachment; filename=recipe_%s_r%s.xml' %
-                        (config.name, build.rev))
-        req.write(body)
-        raise RequestDone
+                        (config.name, build.rev)})
 
     def _process_build_step(self, req, config, build):
         try:
@@ -213,18 +231,19 @@
         except xmlio.ParseError, e:
             self.log.error('Error parsing build step result: %s', e,
                            exc_info=True)
-            raise HTTPBadRequest('XML parser error')
+            self._send_error(req, HTTP_BAD_REQUEST, 'XML parser error')
         stepname = elem.attr['step']
 
         # make sure it's the right slave.
         if build.status != Build.IN_PROGRESS or \
                 build.slave_info.get(Build.IP_ADDRESS) != req.remote_addr:
-            raise HTTPForbidden('Build %s has been invalidated for host %s.'
-                                % (build.id, req.remote_addr))
+            self._send_error(req, HTTP_FORBIDDEN,
+                                'Build %s has been invalidated for host %s.'
+                                        % (build.id, req.remote_addr))
 
         step = BuildStep.fetch(self.env, build=build.id, name=stepname)
         if step:
-            raise HTTPConflict('Build step already exists')
+            self._send_error(req, HTTP_CONFLICT, 'Build step already exists')
 
         recipe = Recipe(xmlio.parse(config.recipe))
         index = None
@@ -234,7 +253,8 @@
                 index = num
                 current_step = recipe_step
         if index is None:
-            raise HTTPForbidden('No such build step')
+            self._send_error(req, HTTP_FORBIDDEN,
+                                'No such build step' % stepname)
         last_step = index == num
 
         self.log.debug('Slave %s (build %d) completed step %d (%s) with '
@@ -250,7 +270,7 @@
         except ValueError, e:
             self.log.error('Error parsing build step timestamp: %s', e,
                            exc_info=True)
-            raise HTTPBadRequest(e.args[0])
+            self._send_error(req, HTTP_BAD_REQUEST, e.args[0])
         if elem.attr['status'] == 'failure':
             self.log.warning('Build %s step %s failed', build.id, stepname)
             step.status = BuildStep.FAILURE
@@ -330,13 +350,11 @@
                 listener.build_completed(build)
 
         body = 'Build step processed'
-        req.send_response(201)
-        req.send_header('Content-Type', 'text/plain')
-        req.send_header('Content-Length', str(len(body)))
-        req.send_header('Location', req.abs_href.builds(build.id, 'steps',
-                        stepname))
-        req.write(body)
-        raise RequestDone
+        self._send_response(req, 201, body, {
+                            'Content-Type': 'text/plain',
+                            'Content-Length': str(len(body)),
+                            'Location': req.abs_href.builds(
+                                    build.id, 'steps', stepname)})
 
 
 def _parse_iso_datetime(string):
--- a/bitten/slave.py
+++ b/bitten/slave.py
@@ -159,7 +159,13 @@
             return resp
         except urllib2.HTTPError, e:
             if e.code >= 300:
-                log.warning('Server returned error %d: %s', e.code, e.msg)
+                if e.headers.getheader('Content-Type', ''
+                                                ).startswith('text/plain'):
+                    content = e.read()
+                else:
+                    content = 'no message available'
+                log.debug('Server returned error %d: %s (%s)',
+                                        e.code, e.msg, content)
                 raise
             return e
 
--- a/bitten/tests/master.py
+++ b/bitten/tests/master.py
@@ -19,8 +19,7 @@
 from trac.perm import PermissionCache, PermissionSystem
 from trac.test import EnvironmentStub, Mock
 from trac.util.datefmt import to_datetime, utc
-from trac.web.api import HTTPBadRequest, HTTPMethodNotAllowed, HTTPNotFound, \
-                         HTTPForbidden, RequestDone
+from trac.web.api import RequestDone
 from trac.web.href import Href
 
 from bitten.master import BuildMaster
@@ -92,47 +91,58 @@
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(201, outheaders['Status'])
-            self.assertEqual('text/plain', outheaders['Content-Type'])
-            location = outheaders['Location']
-            mo = re.match('http://example.org/trac/builds/(\d+)', location)
-            assert mo, 'Location was %r' % location
-            self.assertEqual('Build pending', outbody.getvalue())
-            build = Build.fetch(self.env, int(mo.group(1)))
-            self.assertEqual(Build.IN_PROGRESS, build.status)
-            self.assertEqual('hal', build.slave)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEqual(201, outheaders['Status'])
+        self.assertEqual('text/plain', outheaders['Content-Type'])
+        location = outheaders['Location']
+        mo = re.match('http://example.org/trac/builds/(\d+)', location)
+        assert mo, 'Location was %r' % location
+        self.assertEqual('Build pending', outbody.getvalue())
+        build = Build.fetch(self.env, int(mo.group(1)))
+        self.assertEqual(Build.IN_PROGRESS, build.status)
+        self.assertEqual('hal', build.slave)
 
     def test_create_build_invalid_xml(self):
         inheaders = {'Content-Type': 'application/x-bitten+xml'}
         inbody = StringIO('<slave></salve>')
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='POST', base_path='', path_info='/builds',
                    href=Href('/trac'), remote_addr='127.0.0.1', args={},
                    perm=PermissionCache(self.env, 'hal'),
-                   get_header=lambda x: inheaders.get(x), read=inbody.read)
+                   get_header=lambda x: inheaders.get(x), read=inbody.read,
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPBadRequest')
-        except HTTPBadRequest, e:
-            self.assertEqual('XML parser error', e.detail)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEquals(400, outheaders['Status'])
+        self.assertEqual('XML parser error', outbody.getvalue())
 
     def test_create_build_no_post(self):
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='GET', base_path='', path_info='/builds',
                    href=Href('/trac'), remote_addr='127.0.0.1', args={},
-                   perm=PermissionCache(self.env, 'hal'))
+                   perm=PermissionCache(self.env, 'hal'),
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
+
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPMethodNotAllowed')
-        except HTTPMethodNotAllowed, e:
-            self.assertEqual('Method not allowed', e.detail)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEquals(405, outheaders['Status'])
+        self.assertEquals('Only POST allowed for build creation',
+                    outbody.getvalue())
 
     def test_create_build_no_match(self):
         inheaders = {'Content-Type': 'application/x-bitten+xml'}
@@ -152,12 +162,11 @@
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(204, outheaders['Status'])
-            self.assertEqual('', outbody.getvalue())
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEqual(204, outheaders['Status'])
+        self.assertEqual('', outbody.getvalue())
 
     def test_cancel_build(self):
         config = BuildConfig(self.env, 'test', path='somepath', active=True,
@@ -179,17 +188,16 @@
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(204, outheaders['Status'])
-            self.assertEqual('', outbody.getvalue())
 
-            # Make sure the started timestamp has been set
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.PENDING, build.status)
-            assert not build.started
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEqual(204, outheaders['Status'])
+        self.assertEqual('', outbody.getvalue())
+
+        # Make sure the started timestamp has been set
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.PENDING, build.status)
+        assert not build.started
 
     def test_initiate_build(self):
         config = BuildConfig(self.env, 'test', path='somepath', active=True,
@@ -215,38 +223,42 @@
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(200, outheaders['Status'])
-            self.assertEqual('90', outheaders['Content-Length'])
-            self.assertEqual('application/x-bitten+xml',
-                             outheaders['Content-Type'])
-            self.assertEqual('attachment; filename=recipe_test_r123.xml',
-                             outheaders['Content-Disposition'])
-            self.assertEqual('<build build="1" config="test" name="hal"'
-                             ' path="somepath" platform="Unix"'
-                             ' revision="123"/>',
-                             outbody.getvalue())
 
-            # Make sure the started timestamp has been set
-            build = Build.fetch(self.env, build.id)
-            assert build.started
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEqual(200, outheaders['Status'])
+        self.assertEqual('90', outheaders['Content-Length'])
+        self.assertEqual('application/x-bitten+xml',
+                         outheaders['Content-Type'])
+        self.assertEqual('attachment; filename=recipe_test_r123.xml',
+                         outheaders['Content-Disposition'])
+        self.assertEqual('<build build="1" config="test" name="hal"'
+                         ' path="somepath" platform="Unix"'
+                         ' revision="123"/>',
+                         outbody.getvalue())
+
+        # Make sure the started timestamp has been set
+        build = Build.fetch(self.env, build.id)
+        assert build.started
 
     def test_initiate_build_no_such_build(self):
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='GET', base_path='',
                    path_info='/builds/123', href=Href('/trac'),
                    remote_addr='127.0.0.1', args={},
-                   perm=PermissionCache(self.env, 'hal'))
+                   perm=PermissionCache(self.env, 'hal'),
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPNotFound')
-        except HTTPNotFound, e:
-            self.assertEqual('No such build', e.detail)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEquals(404, outheaders['Status'])
+        self.assertEquals('No such build (123)', outbody.getvalue())
 
     def test_process_unknown_collection(self):
         BuildConfig(self.env, 'test', path='somepath', active=True,
@@ -254,18 +266,23 @@
         build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42)
         build.insert()
 
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='POST', base_path='',
                    path_info='/builds/%d/files/' % build.id,
                    href=Href('/trac'), remote_addr='127.0.0.1', args={},
-                   perm=PermissionCache(self.env, 'hal'))
+                   perm=PermissionCache(self.env, 'hal'),
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPNotFound')
-        except HTTPNotFound, e:
-            self.assertEqual('No such collection', e.detail)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEqual(404, outheaders['Status'])
+        self.assertEqual("No such collection 'files'", outbody.getvalue())
 
     def test_process_build_step_success(self):
         recipe = """<build>
@@ -296,24 +313,23 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(201, outheaders['Status'])
-            self.assertEqual('20', outheaders['Content-Length'])
-            self.assertEqual('text/plain', outheaders['Content-Type'])
-            self.assertEqual('Build step processed', outbody.getvalue())
 
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.SUCCESS, build.status)
-            assert build.stopped
-            assert build.stopped > build.started
+        self.assertRaises(RequestDone, module.process_request, req)
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(1, len(steps))
-            self.assertEqual('foo', steps[0].name)
-            self.assertEqual(BuildStep.SUCCESS, steps[0].status)
+        self.assertEqual(201, outheaders['Status'])
+        self.assertEqual('20', outheaders['Content-Length'])
+        self.assertEqual('text/plain', outheaders['Content-Type'])
+        self.assertEqual('Build step processed', outbody.getvalue())
+
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.SUCCESS, build.status)
+        assert build.stopped
+        assert build.stopped > build.started
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(1, len(steps))
+        self.assertEqual('foo', steps[0].name)
+        self.assertEqual(BuildStep.SUCCESS, steps[0].status)
 
     def test_process_build_step_success_with_log(self):
         recipe = """<build>
@@ -348,32 +364,31 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(201, outheaders['Status'])
-            self.assertEqual('20', outheaders['Content-Length'])
-            self.assertEqual('text/plain', outheaders['Content-Type'])
-            self.assertEqual('Build step processed', outbody.getvalue())
 
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.SUCCESS, build.status)
-            assert build.stopped
-            assert build.stopped > build.started
+        self.assertRaises(RequestDone, module.process_request, req)
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(1, len(steps))
-            self.assertEqual('foo', steps[0].name)
-            self.assertEqual(BuildStep.SUCCESS, steps[0].status)
+        self.assertEqual(201, outheaders['Status'])
+        self.assertEqual('20', outheaders['Content-Length'])
+        self.assertEqual('text/plain', outheaders['Content-Type'])
+        self.assertEqual('Build step processed', outbody.getvalue())
 
-            logs = list(BuildLog.select(self.env, build=build.id, step='foo'))
-            self.assertEqual(1, len(logs))
-            self.assertEqual('http://bitten.cmlenz.net/tools/python#unittest',
-                             logs[0].generator)
-            self.assertEqual(2, len(logs[0].messages))
-            self.assertEqual((u'info', u'Doing stuff'), logs[0].messages[0])
-            self.assertEqual((u'error', u'Ouch that hurt'), logs[0].messages[1])
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.SUCCESS, build.status)
+        assert build.stopped
+        assert build.stopped > build.started
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(1, len(steps))
+        self.assertEqual('foo', steps[0].name)
+        self.assertEqual(BuildStep.SUCCESS, steps[0].status)
+
+        logs = list(BuildLog.select(self.env, build=build.id, step='foo'))
+        self.assertEqual(1, len(logs))
+        self.assertEqual('http://bitten.cmlenz.net/tools/python#unittest',
+                         logs[0].generator)
+        self.assertEqual(2, len(logs[0].messages))
+        self.assertEqual((u'info', u'Doing stuff'), logs[0].messages[0])
+        self.assertEqual((u'error', u'Ouch that hurt'), logs[0].messages[1])
 
     def test_process_build_step_success_with_report(self):
         recipe = """<build>
@@ -410,37 +425,36 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(201, outheaders['Status'])
-            self.assertEqual('20', outheaders['Content-Length'])
-            self.assertEqual('text/plain', outheaders['Content-Type'])
-            self.assertEqual('Build step processed', outbody.getvalue())
-
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.SUCCESS, build.status)
-            assert build.stopped
-            assert build.stopped > build.started
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(1, len(steps))
-            self.assertEqual('foo', steps[0].name)
-            self.assertEqual(BuildStep.SUCCESS, steps[0].status)
+        self.assertRaises(RequestDone, module.process_request, req)
 
-            reports = list(Report.select(self.env, build=build.id, step='foo'))
-            self.assertEqual(1, len(reports))
-            self.assertEqual('test', reports[0].category)
-            self.assertEqual('http://bitten.cmlenz.net/tools/python#unittest',
-                             reports[0].generator)
-            self.assertEqual(1, len(reports[0].items))
-            self.assertEqual({
-                'fixture': 'my.Fixture', 
-                'file': 'my/test/file.py', 
-                'stdout': 'Doing my thing',
-                'type': 'test',
-            }, reports[0].items[0])
+        self.assertEqual(201, outheaders['Status'])
+        self.assertEqual('20', outheaders['Content-Length'])
+        self.assertEqual('text/plain', outheaders['Content-Type'])
+        self.assertEqual('Build step processed', outbody.getvalue())
+
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.SUCCESS, build.status)
+        assert build.stopped
+        assert build.stopped > build.started
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(1, len(steps))
+        self.assertEqual('foo', steps[0].name)
+        self.assertEqual(BuildStep.SUCCESS, steps[0].status)
+
+        reports = list(Report.select(self.env, build=build.id, step='foo'))
+        self.assertEqual(1, len(reports))
+        self.assertEqual('test', reports[0].category)
+        self.assertEqual('http://bitten.cmlenz.net/tools/python#unittest',
+                         reports[0].generator)
+        self.assertEqual(1, len(reports[0].items))
+        self.assertEqual({
+            'fixture': 'my.Fixture', 
+            'file': 'my/test/file.py', 
+            'stdout': 'Doing my thing',
+            'type': 'test',
+        }, reports[0].items[0])
 
     def test_process_build_step_success_with_attach(self):
         # Parse input and create attachments for config + build
@@ -552,19 +566,19 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPForbidden')
-        except HTTPForbidden, e:
-            self.assertEqual('Build 1 has been invalidated for host 127.0.0.1.', e.detail)
 
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.IN_PROGRESS, build.status)
-            assert not build.stopped
+        self.assertRaises(RequestDone, module.process_request, req)
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(0, len(steps))
+        self.assertEqual(403, outheaders['Status'])
+        self.assertEqual('Build 1 has been invalidated for host 127.0.0.1.',
+                        outbody.getvalue())
 
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.IN_PROGRESS, build.status)
+        assert not build.stopped
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(0, len(steps))
 
     def test_process_build_step_invalidated_build(self):
         recipe = """<build>
@@ -601,16 +615,15 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:            
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.IN_PROGRESS, build.status)
-            assert not build.stopped
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(1, len(steps))
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.IN_PROGRESS, build.status)
+        assert not build.stopped
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(1, len(steps))
 
         # invalidate the build. 
 
@@ -640,14 +653,15 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Build was invalidated. Should fail.');
-        except HTTPForbidden, e:
-            self.assertEqual('Build 1 has been invalidated for host 127.0.0.1.', e.detail)            
 
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.PENDING, build.status)
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEquals(403, outheaders['Status'])
+        self.assertEquals('Build 1 has been invalidated for host 127.0.0.1.',
+                        outbody.getvalue())            
+
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.PENDING, build.status)
 
     def test_process_build_step_failure(self):
         recipe = """<build>
@@ -678,24 +692,23 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(201, outheaders['Status'])
-            self.assertEqual('20', outheaders['Content-Length'])
-            self.assertEqual('text/plain', outheaders['Content-Type'])
-            self.assertEqual('Build step processed', outbody.getvalue())
 
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.FAILURE, build.status)
-            assert build.stopped
-            assert build.stopped > build.started
+        self.assertRaises(RequestDone, module.process_request, req)
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(1, len(steps))
-            self.assertEqual('foo', steps[0].name)
-            self.assertEqual(BuildStep.FAILURE, steps[0].status)
+        self.assertEqual(201, outheaders['Status'])
+        self.assertEqual('20', outheaders['Content-Length'])
+        self.assertEqual('text/plain', outheaders['Content-Type'])
+        self.assertEqual('Build step processed', outbody.getvalue())
+
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.FAILURE, build.status)
+        assert build.stopped
+        assert build.stopped > build.started
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(1, len(steps))
+        self.assertEqual('foo', steps[0].name)
+        self.assertEqual(BuildStep.FAILURE, steps[0].status)
 
     def test_process_build_step_failure_ignored(self):
         recipe = """<build>
@@ -727,24 +740,23 @@
                    write=outbody.write)
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected RequestDone')
-        except RequestDone:
-            self.assertEqual(201, outheaders['Status'])
-            self.assertEqual('20', outheaders['Content-Length'])
-            self.assertEqual('text/plain', outheaders['Content-Type'])
-            self.assertEqual('Build step processed', outbody.getvalue())
 
-            build = Build.fetch(self.env, build.id)
-            self.assertEqual(Build.SUCCESS, build.status)
-            assert build.stopped
-            assert build.stopped > build.started
+        self.assertRaises(RequestDone, module.process_request, req)
 
-            steps = list(BuildStep.select(self.env, build.id))
-            self.assertEqual(1, len(steps))
-            self.assertEqual('foo', steps[0].name)
-            self.assertEqual(BuildStep.FAILURE, steps[0].status)
+        self.assertEqual(201, outheaders['Status'])
+        self.assertEqual('20', outheaders['Content-Length'])
+        self.assertEqual('text/plain', outheaders['Content-Type'])
+        self.assertEqual('Build step processed', outbody.getvalue())
+
+        build = Build.fetch(self.env, build.id)
+        self.assertEqual(Build.SUCCESS, build.status)
+        assert build.stopped
+        assert build.stopped > build.started
+
+        steps = list(BuildStep.select(self.env, build.id))
+        self.assertEqual(1, len(steps))
+        self.assertEqual('foo', steps[0].name)
+        self.assertEqual(BuildStep.FAILURE, steps[0].status)
 
     def test_process_build_step_invalid_xml(self):
         recipe = """<build>
@@ -758,19 +770,24 @@
         build.insert()
 
         inbody = StringIO("""<result></rsleut>""")
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='POST', base_path='',
                    path_info='/builds/%d/steps/' % build.id,
                    href=Href('/trac'), remote_addr='127.0.0.1', args={},
                    perm=PermissionCache(self.env, 'hal'),
-                   read=inbody.read)
+                   read=inbody.read,
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPBadRequest')
-        except HTTPBadRequest, e:
-            self.assertEqual('XML parser error', e.detail)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEquals(400, outheaders['Status'])
+        self.assertEquals('XML parser error', outbody.getvalue())
 
     def test_process_build_step_invalid_datetime(self):
         recipe = """<build>
@@ -788,20 +805,25 @@
                                      time="sometime tomorrow maybe"
                                      duration="3.45">
 </result>""")
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='POST', base_path='',
                    path_info='/builds/%d/steps/' % build.id,
                    href=Href('/trac'), remote_addr='127.0.0.1', args={},
                    perm=PermissionCache(self.env, 'hal'),
-                   read=inbody.read)
+                   read=inbody.read,
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPBadRequest')
-        except HTTPBadRequest, e:
-            self.assertEqual("Invalid ISO date/time 'sometime tomorrow maybe'",
-                             e.detail)
+
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEquals(400, outheaders['Status'])
+        self.assertEquals("Invalid ISO date/time 'sometime tomorrow maybe'",
+                             outbody.getvalue())
 
     def test_process_build_step_no_post(self):
         BuildConfig(self.env, 'test', path='somepath', active=True,
@@ -810,18 +832,23 @@
                       started=42)
         build.insert()
 
+        outheaders = {}
+        outbody = StringIO()
         req = Mock(method='GET', base_path='',
                    path_info='/builds/%d/steps/' % build.id,
                    href=Href('/trac'), remote_addr='127.0.0.1', args={},
-                   perm=PermissionCache(self.env, 'hal'))
+                   perm=PermissionCache(self.env, 'hal'),
+                   send_response=lambda x: outheaders.setdefault('Status', x),
+                   send_header=lambda x, y: outheaders.setdefault(x, y),
+                   write=outbody.write)
 
         module = BuildMaster(self.env)
         assert module.match_request(req)
-        try:
-            module.process_request(req)
-            self.fail('Expected HTTPMethodNotAllowed')
-        except HTTPMethodNotAllowed, e:
-            self.assertEqual('Method not allowed', e.detail)
+        
+        self.assertRaises(RequestDone, module.process_request, req)
+
+        self.assertEqual(405, outheaders['Status'])
+        self.assertEqual('Method GET not allowed', outbody.getvalue())
 
 
 def suite():
Copyright (C) 2012-2017 Edgewall Software