Edgewall Software

source: trunk/bitten/recipe.py @ 1001

Last change on this file since 1001 was 1001, checked in by cmlenz, 13 years ago

Improve layout and customizability of build status summary.

  • Property svn:eol-style set to native
File size: 11.7 KB
CovLine 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2007-2010 Edgewall Software
4# Copyright (C) 2005-2007 Christopher Lenz <cmlenz@gmx.de>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at http://bitten.edgewall.org/wiki/License.
10
11"""Execution of build recipes.
12
13This module provides various classes that can be used to process build recipes,
14most importantly the `Recipe` class.
115"""
16
117import inspect
118import keyword
119import logging
120import os
121import time
122try:
123    set
024except NameError:
025    from sets import Set as set
26
127from pkg_resources import WorkingSet
128from bitten.build import BuildError, TimeoutError
129from bitten.build.config import Configuration
130from bitten.util import xmlio
31
132__all__ = ['Context', 'Recipe', 'Step', 'InvalidRecipeError']
133__docformat__ = 'restructuredtext en'
34
135log = logging.getLogger('bitten.recipe')
36
37
238class InvalidRecipeError(Exception):
139    """Exception raised when a recipe is not valid."""
40
41
242class Context(object):
143    """The context in which a build is executed."""
44
145    step = None # The current step
146    generator = None # The current generator (namespace#name)
47
148    def __init__(self, basedir, config=None, vars=None):
49        """Initialize the context.
50       
51        :param basedir: a string containing the working directory for the build.
52                        (may be a pattern for replacement ex: 'build_${build}'
53        :param config: the build slave configuration
54        :type config: `Configuration`
55        """       
7856        self.config = config or Configuration()
7857        self.vars = vars or {}
7858        self.output = []
7859        self.basedir = os.path.realpath(self.config.interpolate(basedir,
7860                                                                **self.vars))
7861        self.vars['basedir'] = self.basedir.replace('\\', '\\\\')
62
163    def run(self, step, namespace, name, attr):
64        """Run the specified recipe command.
65       
66        :param step: the build step that the command belongs to
67        :param namespace: the namespace URI of the command
68        :param name: the local tag name of the command
69        :param attr: a dictionary containing the attributes defined on the
70                     command element
71        """
372        self.step = step
73
374        try:
375            function = None
376            qname = '#'.join(filter(None, [namespace, name]))
377            if namespace:
378                group = 'bitten.recipe_commands'
379                for entry_point in WorkingSet().iter_entry_points(group, qname):
380                    function = entry_point.load()
381                    break
082            elif name == 'report':
083                function = Context.report_file
084            elif name == 'attach':
085                function = Context.attach
386            if not function:
087                raise InvalidRecipeError('Unknown recipe command %s' % qname)
88
389            def escape(name):
590                name = name.replace('-', '_')
591                if keyword.iskeyword(name) or name in __builtins__:
092                    name = name + '_'
593                return name
394            args = dict([(escape(name),
395                          self.config.interpolate(attr[name], **self.vars))
896                         for name in attr])
397            function_args, has_kwargs = inspect.getargspec(function)[0:3:2]
798            for arg in args:
599                if not (arg in function_args or has_kwargs):
1100                    raise InvalidRecipeError(
1101                            "Unsupported argument '%s' for command %s" % \
1102                            (arg, qname))
103
2104            self.generator = qname
2105            log.debug('Executing %s with arguments: %s', function, args)
2106            function(self, **args)
107
0108        finally:
3109            self.generator = None
3110            self.step = None
111
1112    def error(self, message):
113        """Record an error message.
114       
115        :param message: a string containing the error message.
116        """
4117        self.output.append((Recipe.ERROR, None, self.generator, message))
118
1119    def log(self, xml):
120        """Record log output.
121       
122        :param xml: an XML fragment containing the log messages
123        """
4124        self.output.append((Recipe.LOG, None, self.generator, xml))
125
1126    def report(self, category, xml):
127        """Record report data.
128       
129        :param category: the name of category of the report
130        :param xml: an XML fragment containing the report data
131        """
34132        self.output.append((Recipe.REPORT, category, self.generator, xml))
133
1134    def report_file(self, category=None, file_=None):
135        """Read report data from a file and record it.
136       
137        :param category: the name of the category of the report
138        :param file\_: the path to the file containing the report data, relative
139                       to the base directory
140        """
0141        filename = self.resolve(file_)
0142        try:
0143            fileobj = file(filename, 'r')
0144            try:
0145                xml_elem = xmlio.Fragment()
0146                for child in xmlio.parse(fileobj).children():
0147                    child_elem = xmlio.Element(child.name, **dict([
0148                        (name, value) for name, value in child.attr.items()
0149                        if value is not None
0150                    ]))
0151                    xml_elem.append(child_elem[
0152                        [xmlio.Element(grandchild.name)[grandchild.gettext()]
0153                        for grandchild in child.children()]
0154                    ])
0155                self.output.append((Recipe.REPORT, category, None, xml_elem))
0156            finally:
0157                fileobj.close()
0158        except xmlio.ParseError, e:
0159            self.error('Failed to parse %s report at %s: %s'
0160                       % (category, filename, e))
0161        except IOError, e:
0162            self.error('Failed to read %s report at %s: %s'
0163                       % (category, filename, e))
164
1165    def attach(self, file_=None, description=None, resource=None):
166        """Attach a file to the build or build configuration.
167       
168        :param file\_: the path to the file to attach, relative to
169                       base directory.
170        :param description: description saved with attachment
171        :param resource: which resource to attach the file to,
172                   either 'build' (default) or 'config'
173        """
174        # Attachments are not added as inline xml, so only adding
175        # the details for later processing.
2176        if not file_:
0177            self.error('No attachment file specified.')
0178            return
2179        xml_elem = xmlio.Element('file', filename=file_,
2180                                description=description or '',
2181                                resource=resource or 'build')
2182        self.output.append((Recipe.ATTACH, None, None, xml_elem))
183
1184    def resolve(self, *path):
185        """Return the path of a file relative to the base directory.
186       
187        Accepts any number of positional arguments, which are joined using the
188        system path separator to form the path.
189        """
55190        return os.path.normpath(os.path.join(self.basedir, *path))
191
192
2193class Step(object):
194    """Represents a single step of a build recipe.
195
196    Iterate over an object of this class to get the commands to execute, and
197    their keyword arguments.
1198    """
199
1200    def __init__(self, elem, onerror_default):
201        """Create the step.
202       
203        :param elem: the XML element representing the step
204        :type elem: `ParsedElement`
205        """
22206        self._elem = elem
22207        self.id = elem.attr['id']
22208        self.description = elem.attr.get('description')
22209        self.onerror = elem.attr.get('onerror', onerror_default)
22210        assert self.onerror in ('fail', 'ignore', 'continue')
211
1212    def __repr__(self):
0213        return '<%s %r>' % (type(self).__name__, self.id)
214
1215    def execute(self, ctxt):
216        """Execute this step in the given context.
217       
218        :param ctxt: the build context
219        :type ctxt: `Context`
220        """
2221        last_finish = time.time()
4222        for child in self._elem:
2223            try:
2224                ctxt.run(self, child.namespace, child.name, child.attr)
0225            except (BuildError, InvalidRecipeError, TimeoutError), e:
0226                ctxt.error(e)
2227        if time.time() < last_finish + 1:
228            # Add a delay to make sure steps appear in correct order
2229            time.sleep(1)
230
2231        errors = []
4232        while ctxt.output:
2233            type, category, generator, output = ctxt.output.pop(0)
2234            yield type, category, generator, output
2235            if type == Recipe.ERROR:
0236                errors.append((generator, output))
2237        if errors:
0238            for _t, error in errors:
0239                log.error(error)
0240            if self.onerror != 'ignore':
0241                raise BuildError("Build step '%s' failed" % self.id)
0242            log.warning("Continuing despite errors in step '%s'", self.id)
243
244
2245class Recipe(object):
246    """A build recipe.
247   
248    Iterate over this object to get the individual build steps in the order
249    they have been defined in the recipe file.
1250    """
251
1252    ERROR = 'error'
1253    LOG = 'log'
1254    REPORT = 'report'
1255    ATTACH = 'attach'
256
1257    def __init__(self, xml, basedir=os.getcwd(), config=None):
258        """Create the recipe.
259       
260        :param xml: the XML document representing the recipe
261        :type xml: `ParsedElement`
262        :param basedir: the base directory for the build
263        :param config: the slave configuration (optional)
264        :type config: `Configuration`
265        """
24266        assert isinstance(xml, xmlio.ParsedElement)
24267        vars = dict([(name, value) for name, value in xml.attr.items()
4268                     if not name.startswith('xmlns')])
24269        self.ctxt = Context(basedir, config, vars)
24270        self._root = xml
24271        self.onerror_default = vars.get('onerror', 'fail')
24272        assert self.onerror_default in ('fail', 'ignore', 'continue')
273
1274    def __iter__(self):
275        """Iterate over the individual steps of the recipe."""
40276        for child in self._root.children('step'):
22277            yield Step(child, self.onerror_default)
278
1279    def validate(self):
280        """Validate the recipe.
281       
282        This method checks a number of constraints:
283         - the name of the root element must be "build"
284         - the only permitted child elements or the root element with the name
285           "step"
286         - the recipe must contain at least one step
287         - step elements must have a unique "id" attribute
288         - a step must contain at least one nested command
289         - commands must not have nested content
290
291        :raise InvalidRecipeError: in case any of the above contraints is
292                                   violated
293        """
10294        if self._root.name != 'build':
1295            raise InvalidRecipeError('Root element must be <build>')
9296        steps = list(self._root.children())
9297        if not steps:
1298            raise InvalidRecipeError('Recipe defines no build steps')
299
8300        step_ids = set()
11301        for step in steps:
10302            if step.name != 'step':
1303                raise InvalidRecipeError('Only <step> elements allowed at '
1304                                         'top level of recipe')
9305            if not step.attr.get('id'):
3306                raise InvalidRecipeError('Steps must have an "id" attribute')
307
6308            if step.attr['id'] in step_ids:
1309                raise InvalidRecipeError('Duplicate step ID "%s"' %
1310                                         step.attr['id'])
5311            step_ids.add(step.attr['id'])
312
5313            cmds = list(step.children())
5314            if not cmds:
1315                raise InvalidRecipeError('Step "%s" has no recipe commands' %
1316                                         step.attr['id'])
7317            for cmd in cmds:
4318                if len(list(cmd.children())):
1319                    raise InvalidRecipeError('Recipe command <%s> has nested '
1320                                             'content' % cmd.name)
Note: See TracBrowser for help on using the repository browser.