# HG changeset patch # User wbell # Date 1236559317 0 # Node ID a3bcc4f98187ef745127c15607ed7c69b9713c40 # Parent 6a9268d10d09c876a48de18ef03f61efb74dba1f Bitten trunk is now trac-0.11 compatible. diff --git a/trac-0.11/COPYING b/trac-0.11/COPYING new file mode 100644 --- /dev/null +++ b/trac-0.11/COPYING @@ -0,0 +1,36 @@ +Copyright (C) 2007 Edgewall Software +Copyright (C) 2005-2007 Christopher Lenz +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================== + +This software includes a copy of XML/SWF charts, which is +Copyright (C) 2004-2005 maani.us +See http://www.maani.us/xml_charts/index.php?menu=License for more +information. diff --git a/trac-0.11/ChangeLog b/trac-0.11/ChangeLog new file mode 100644 --- /dev/null +++ b/trac-0.11/ChangeLog @@ -0,0 +1,71 @@ +Version 0.6 +(?, from 0.6.x branch) +http://svn.edgewall.org/repos/bitten/tags/0.6.0 + + * Switch to using HTTP for communication between the build master and build + slaves. This means the `build-master` executable is no longer needed or + installed, the build simply runs in the scope of the Trac site. + * Build recipes now need to include instructions for performing the checkout + from the version control repository. The slave no longer receives a snapshot + archive of the code, but performs the checkout itself based on the + instructions in the build recipe. + * Many fixes for compatibility with more recent versions of Trac. + + +Version 0.5.3 +(18 April 2006, from 0.5.x branch) +http://svn.edgewall.org/repos/bitten/tags/0.5.3 + + * Fix double-escaping of report summaries. + * Fix build master error when build log contains no messages. + + +Version 0.5.2 +(17 January 2006, from 0.5.x branch) +http://svn.edgewall.org/repos/bitten/tags/0.5.2 + + * Fixes the main navigation tab that was broken in 0.5.1. + + +Version 0.5.1 +(10 January 2006, from 0.5.x branch) +http://svn.edgewall.org/repos/bitten/tags/0.5.1 + + * Fixes compatibility with Trac 0.9.3 release, as well as the current trunk. + This also means that Bitten now longer works with versions of Trac earlier + than 0.9.3. + * Improves PostgreSQL compatibility. + * Fixes encoding of non-ASCII characters in command output. + * Fix for missing log output when using on Windows. + + +Version 0.5 +(6 October 2005, from 0.5.x branch) +http://svn.edgewall.org/repos/bitten/tags/0.5 + + * BDB XML is no longer being used for report storage. Instead, + collected metrics data is stored in the Trac database. + * Snapshot archives created by the master are checked for integrity + prior to their transmission to the slaves. + * Improvements to the build status presentation in Trac. + * Changes to the build recipe format. See the documentation on the web + site for details. + * New recipe commands: , , , + , , and . Various improvements to + the existing commands. + * Recipe commands and command attributes in recipes can now reference + slave configuration values. + * The names of the master and slaves scripts have changed: `bittend` + is now `bitten-master`, `bitten` is now `bitten-slave`. + * The build master can now handle multiple Trac environments. + * The build slave now by default removes any working directories when + done. + * Build configurations can now be completely deleted. + * Build configurations can now have a minimum and maximum revision + specified. Any revisions outside that range will not be built. + * The build configuration editor now validates the supplied values. + * Fix management of target platforms when running under mod_python. + * Improved performance of the build log formatter that is responsible + for linking file references in build logs to the repository browser. + * Add paging to the build configuration view. + * Fix compatibility with PySQLite2. diff --git a/trac-0.11/MANIFEST.in b/trac-0.11/MANIFEST.in new file mode 100644 --- /dev/null +++ b/trac-0.11/MANIFEST.in @@ -0,0 +1,2 @@ +include doc/api/*.* +include doc/*.html diff --git a/trac-0.11/README.txt b/trac-0.11/README.txt new file mode 100644 --- /dev/null +++ b/trac-0.11/README.txt @@ -0,0 +1,141 @@ +About Bitten +============ + +Bitten is a simple distributed continuous integration system that not only +coordinates builds across multiple machines, but also collects software +metrics generated by builds, to enable feedback and reporting about +the progress of a software project. + +The Bitten software consists of three separate parts: + * The build slave, which executes builds on behalf of a local or remote + build master + * The build master, which orchestrates builds for a project across all + connected slaves, and stores the build status and results to the + database + * The web interface, which is implemented as an add-on to Trac + (http://trac.edgewall.com/) and provides a build management interface + as well as presentation of build results. + +Both the build master and the web interface depend on Trac 0.10, and need +to be installed on the same machine, together with the Subversion +repository. The build slave only requires Python (>= 2.3), setuptools +(>= 0.6a2), as well as any tools required by the build process itself. A +build slave may be run on any machine that can connect to the server +running the Bitten build master. + + +Installation +------------ + +Bitten is written in Python, so make sure that you have Python installed. +You'll need Python 2.3 or later. Also, make sure that setuptools +(http://peak.telecommunity.com/DevCenter/setuptools), version 0.6a2 or later, +is installed. + +If that's taken care of, you just need to download and unpack the Bitten +distribution, and execute the command: + + $ python setup.py install + +from the top of the directory where you unpacked (or checked out) the Bitten +code. Note that you may need administrator/root privileges for this step, as +it will by default attempt to install Bitten to the Python site-packages +directory on your system. + +It's also a good idea to run the unit tests at this point, to make sure that +the code works as expected on your platform: + + $ python setup.py test + + +What's left to do now depends on whether you want to use the build master and +web interface, or just the build slave. In the latter case, you're already +done. You might need to install software that the build of your project +requires, but the Bitten build slave itself doesn't require anything extra. + +For the build master and web interface, you'll need to install Trac 0.10 or +later. Please refer to the Trac documentation for information on how it is +installed. + + +Build Master Configuration +-------------------------- + +Once both Bitten and Trac are installed and working, you'll have to introduce +Bitten to your Trac project environment. If you don't have a Trac project +set up yet, you'll need to do so in order to use Bitten. + +If you already have a Trac project environment, the Bitten plugin needs to be +explicitly enabled in the Trac configuration. This is done by adding it to the +[components] section in /path/to/projenv/conf/trac.ini: + + [components] + bitten.* = enabled + +The Trac web interface should now inform you with an error message that the +environment needs to be upgraded. To do this, run: + + $ trac-admin /path/to/projenv upgrade + +This will create the database tables and directories that Bitten requires. +You probably also want to grant permissions to someone (such as yourself) +to manage build configurations, and allow anonymous users to view the +status and results of builds: + + $ trac-admin /path/to/projenv permission add anonymous BUILD_EXEC + $ trac-admin /path/to/projenv permission add anonymous BUILD_VIEW + $ trac-admin /path/to/projenv permission add [yourname] BUILD_ADMIN + +You should now see an additional tab labeled "Build Status" in the Trac +navigation bar. This link will take you to the list of build configurations, +which at this point is of course empty. If you've set up permissions +correctly as described previously, you should see a button for adding new +build configurations. Click that button and fill out the form. Also, add +at least one target platform after saving the configuration. Last but not +least, you'll have to "activate" the build configuration. + + +Running the Build Master +------------------------ + +At this point, you're ready to start the Bitten build master. The +installation of Bitten should have put a `bitten-master` executable on your +path. If the script is not on your path, look for it in the `bin` or +`scripts` subdirectory of your Python installation. + +To find out about the options and arguments of the master, execute it with +the `--help` option as follows: + + $ bitten-master --help + +Most commonly, you'll want to specify the log level and log file, as well as +the path to the Trac environment: + + $ bitten-master --verbose --log=/var/log/bittend /var/trac/myproject + + +Running the Build Slave +----------------------- + +The build slave can be run on any machine that can connect to the machine +on which the build master is running. The installation of Bitten should have put +a `bitten-slave` executable on your path. If the script is not on your path, +look for it in the `bin` or `scripts` subdirectory of your Python installation. + +To get a list of options for the build slave, execute it with the `--help` +option: + + $ bitten-slave --help + +To run the build slave against a Bitten-enabled Trac site installed at +http://myproject.example.org/trac, you'd run: + + $ bitten-slave http://myproject.example.org/trac/builds + + +More Information +---------------- + +For further documentation, please see the Bitten website at: + + diff --git a/trac-0.11/bitten/__init__.py b/trac-0.11/bitten/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +__docformat__ = 'restructuredtext en' +try: + __version__ = __import__('pkg_resources').get_distribution('Bitten').version +except ImportError: + pass diff --git a/trac-0.11/bitten/admin.py b/trac-0.11/bitten/admin.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/admin.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Implementation of the web administration interface.""" + +from pkg_resources import require, DistributionNotFound +import re + +from trac.core import * +from trac.admin import IAdminPanelProvider +from trac.web.chrome import add_stylesheet + +from bitten.model import BuildConfig, TargetPlatform +from bitten.recipe import Recipe, InvalidRecipeError +from bitten.util import xmlio + + +class BuildMasterAdminPageProvider(Component): + """Web administration panel for configuring the build master.""" + + implements(IAdminPanelProvider) + + # IAdminPanelProvider methods + + def get_admin_panels(self, req): + if req.perm.has_permission('BUILD_ADMIN'): + yield ('bitten', 'Builds', 'master', 'Master Settings') + + def render_admin_panel(self, req, cat, page, path_info): + from bitten.master import BuildMaster + master = BuildMaster(self.env) + + if req.method == 'POST': + self._save_config_changes(req, master) + req.redirect(req.abs_href.admin(cat, page)) + + data = {'master': master} + add_stylesheet(req, 'bitten/admin.css') + return 'bitten_admin_master.html', data + + # Internal methods + + def _save_config_changes(self, req, master): + changed = False + + build_all = 'build_all' in req.args + if build_all != master.build_all: + self.config['bitten'].set('build_all', + build_all and 'yes' or 'no') + changed = True + + adjust_timestamps = 'adjust_timestamps' in req.args + if adjust_timestamps != master.adjust_timestamps: + self.config['bitten'].set('adjust_timestamps', + adjust_timestamps and 'yes' or 'no') + changed = True + + stabilize_wait = int(req.args.get('stabilize_wait', 0)) + if stabilize_wait != master.stabilize_wait: + self.config['bitten'].set('stabilize_wait', str(stabilize_wait)) + changed = True + + slave_timeout = int(req.args.get('slave_timeout', 0)) + if slave_timeout != master.slave_timeout: + self.config['bitten'].set('slave_timeout', str(slave_timeout)) + changed = True + + if changed: + self.config.save() + + return master + + +class BuildConfigurationsAdminPageProvider(Component): + """Web administration panel for configuring the build master.""" + + implements(IAdminPanelProvider) + + # IAdminPanelProvider methods + + def get_admin_panels(self, req): + if req.perm.has_permission('BUILD_MODIFY'): + yield ('bitten', 'Builds', 'configs', 'Configurations') + + def render_admin_panel(self, req, cat, page, path_info): + data = {} + + # Analyze url + try: + config_name, platform_id = path_info.split('/', 1) + except: + config_name = path_info + platform_id = None + + if config_name: # Existing build config + if platform_id or ( + # Editing or creating one of the config's target platforms + req.method == 'POST' and 'new' in req.args): + + if platform_id: # Editing target platform + platform_id = int(platform_id) + platform = TargetPlatform.fetch(self.env, platform_id) + + if req.method == 'POST': + if 'cancel' in req.args or \ + self._update_platform(req, platform): + req.redirect(req.abs_href.admin(cat, page, + config_name)) + else: # creating target platform + if req.method == 'POST': + if 'add' in req.args: + self._create_platform(req, config_name) + req.redirect(req.abs_href.admin(cat, page, + config_name)) + elif 'cancel' in req.args: + req.redirect(req.abs_href.admin(cat, page, + config_name)) + + platform = TargetPlatform(self.env, config=config_name) + + # Set up template variables + data['platform'] = { + 'id': platform.id, 'name': platform.name, + 'exists': platform.exists, + 'rules': [ + {'property': propname, 'pattern': pattern} + for propname, pattern in platform.rules + ] or [('', '')] + } + + else: # Editing existing build config itself + config = BuildConfig.fetch(self.env, config_name) + platforms = list(TargetPlatform.select(self.env, + config=config.name)) + + if req.method == 'POST': + if 'add' in req.args: # Add target platform + platform = self._create_platform(req, config) + req.redirect(req.abs_href.admin(cat, page, config.name)) + + elif 'remove' in req.args: # Remove selected platforms + self._remove_platforms(req) + req.redirect(req.abs_href.admin(cat, page, config.name)) + + elif 'save' in req.args: # Save this build config + self._update_config(req, config) + + req.redirect(req.abs_href.admin(cat, page)) + + # Prepare template variables + data['config'] = { + 'name': config.name, 'label': config.label or config.name, + 'active': config.active, 'path': config.path, + 'min_rev': config.min_rev, 'max_rev': config.max_rev, + 'description': config.description, + 'recipe': config.recipe, + 'platforms': [{ + 'name': platform.name, + 'id': platform.id, + 'href': req.href.admin('bitten', 'configs', config.name, + platform.id), + 'rules': [{'property': propname, 'pattern': pattern} + for propname, pattern in platform.rules] + } for platform in platforms] + } + + else: # At the top level build config list + if req.method == 'POST': + if 'add' in req.args: # Add build config + config = self._create_config(req) + req.redirect(req.abs_href.admin(cat, page, config.name)) + + elif 'remove' in req.args: # Remove selected build configs + self._remove_configs(req) + + elif 'apply' in req.args: # Update active state of configs + self._activate_configs(req) + req.redirect(req.abs_href.admin(cat, page)) + + # Prepare template variables + configs = [] + for config in BuildConfig.select(self.env, include_inactive=True): + configs.append({ + 'name': config.name, 'label': config.label or config.name, + 'active': config.active, 'path': config.path, + 'min_rev': config.min_rev, 'max_rev': config.max_rev, + 'href': req.href.admin('bitten', 'configs', config.name), + }) + data['configs'] = configs + + add_stylesheet(req, 'bitten/admin.css') + return 'bitten_admin_configs.html', data + + # Internal methods + + def _activate_configs(self, req): + req.perm.assert_permission('BUILD_MODIFY') + + active = req.args.get('active') or [] + active = isinstance(active, list) and active or [active] + + db = self.env.get_db_cnx() + for config in list(BuildConfig.select(self.env, db=db, + include_inactive=True)): + config.active = config.name in active + config.update(db=db) + db.commit() + + def _create_config(self, req): + req.perm.assert_permission('BUILD_CREATE') + + config = BuildConfig(self.env) + self._update_config(req, config) + return config + + def _remove_configs(self, req): + req.perm.assert_permission('BUILD_DELETE') + + sel = req.args.get('sel') + if not sel: + raise TracError('No configuration selected') + sel = isinstance(sel, list) and sel or [sel] + + db = self.env.get_db_cnx() + for name in sel: + config = BuildConfig.fetch(self.env, name, db=db) + if not config: + raise TracError('Configuration %r not found' % name) + config.delete(db=db) + db.commit() + + def _update_config(self, req, config): + req.perm.assert_permission('BUILD_MODIFY') + + name = req.args.get('name') + if not name: + raise TracError('Missing required field "name"', 'Missing Field') + if not re.match(r'^[\w.-]+$', name): + raise TracError('The field "name" may only contain letters, ' + 'digits, periods, or dashes.', 'Invalid Field') + + path = req.args.get('path', '') + repos = self.env.get_repository(req.authname) + max_rev = req.args.get('max_rev') or None + try: + node = repos.get_node(path, max_rev) + assert node.isdir, '%s is not a directory' % node.path + except (AssertionError, TracError), e: + raise TracError(unicode(e), 'Invalid Repository Path') + if req.args.get('min_rev'): + try: + repos.get_node(path, req.args.get('min_rev')) + except TracError, e: + raise TracError(unicode(e), 'Invalid Oldest Revision') + + recipe_xml = req.args.get('recipe', '') + if recipe_xml: + try: + Recipe(xmlio.parse(recipe_xml)).validate() + except xmlio.ParseError, e: + raise TracError('Failure parsing recipe: %s' % e, + 'Invalid Recipe') + except InvalidRecipeError, e: + raise TracError(unicode(e), 'Invalid Recipe') + + config.name = name + config.path = repos.normalize_path(path) + config.recipe = recipe_xml + config.min_rev = req.args.get('min_rev') + config.max_rev = req.args.get('max_rev') + config.label = req.args.get('label', config.name) + config.description = req.args.get('description', '') + + if config.exists: + config.update() + else: + config.insert() + + def _create_platform(self, req, config_name): + req.perm.assert_permission('BUILD_MODIFY') + + name = req.args.get('name') + if not name: + raise TracError('Missing required field "name"', 'Missing field') + + platform = TargetPlatform(self.env, config=config_name, name=name) + self._update_platform(req, platform) + return platform + + def _remove_platforms(self, req): + req.perm.assert_permission('BUILD_MODIFY') + + sel = req.args.get('sel') + if not sel: + raise TracError('No platform selected') + sel = isinstance(sel, list) and sel or [sel] + + db = self.env.get_db_cnx() + for platform_id in sel: + platform = TargetPlatform.fetch(self.env, platform_id, db=db) + if not platform: + raise TracError('Target platform %r not found' % platform_id) + platform.delete(db=db) + db.commit() + + def _update_platform(self, req, platform): + platform.name = req.args.get('name') + + properties = [int(key[9:]) for key in req.args.keys() + if key.startswith('property_')] + properties.sort() + patterns = [int(key[8:]) for key in req.args.keys() + if key.startswith('pattern_')] + patterns.sort() + platform.rules = [(req.args.get('property_%d' % property), + req.args.get('pattern_%d' % pattern)) + for property, pattern in zip(properties, patterns) + if req.args.get('property_%d' % property)] + + if platform.exists: + platform.update() + else: + platform.insert() + + add_rules = [int(key[9:]) for key in req.args.keys() + if key.startswith('add_rule_')] + if add_rules: + platform.rules.insert(add_rules[0] + 1, ('', '')) + return False + rm_rules = [int(key[8:]) for key in req.args.keys() + if key.startswith('rm_rule_')] + if rm_rules: + if rm_rules[0] < len(platform.rules): + del platform.rules[rm_rules[0]] + return False + + return True diff --git a/trac-0.11/bitten/api.py b/trac-0.11/bitten/api.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/api.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Interfaces of extension points provided by the Bitten Trac plugin.""" + +from trac.core import * + +__all__ = ['IBuildListener', 'ILogFormatter', 'IReportChartGenerator', + 'IReportSummarizer'] +__docformat__ = 'restructuredtext en' + + +class IBuildListener(Interface): + """Extension point interface for components that need to be notified of + build events. + + Note that these will be notified in the process running the build master, + not the web interface. + """ + + def build_started(build): + """Called when a build slave has accepted a build initiation. + + :param build: the build that was started + :type build: `Build` + """ + + def build_aborted(build): + """Called when a build slave cancels a build or disconnects. + + :param build: the build that was aborted + :type build: `Build` + """ + + def build_completed(build): + """Called when a build slave has completed a build, regardless of the + outcome. + + :param build: the build that was aborted + :type build: `Build` + """ + + +class ILogFormatter(Interface): + """Extension point interface for components that format build log + messages.""" + + def get_formatter(req, build): + """Return a function that gets called for every log message. + + The function must take four positional arguments, ``step``, + ``generator``, ``level`` and ``message``, and return the formatted + message as a string. + + :param req: the request object + :param build: the build to which the logs belong that should be + formatted + :type build: `Build` + :return: the formatted log message + :rtype: `basestring` + """ + + +class IReportSummarizer(Interface): + """Extension point interface for components that render a summary of reports + of some kind.""" + + def get_supported_categories(): + """Return a list of strings identifying the types of reports this + component supports. + """ + + def render_summary(req, config, build, step, category): + """Render a summary for the given report. + + This function should return a tuple of the form `(template, data)`, + where `template` is the name of the template to use and `data` is the + data to be passed to the template. + + :param req: the request object + :param config: the build configuration + :type config: `BuildConfig` + :param build: the build + :type build: `Build` + :param step: the build step + :type step: `BuildStep` + :param category: the category of the report that should be summarized + :type category: `basestring` + """ + + +class IReportChartGenerator(Interface): + """Extension point interface for components that generate a chart for a + set of reports.""" + + def get_supported_categories(): + """Return a list of strings identifying the types of reports this + component supports. + """ + + def generate_chart_data(req, config, category): + """Generate the data for a report chart. + + This function should return a tuple of the form `(template, data)`, + where `template` is the name of the template to use and `data` is the + data to be passed to the template. + + :param req: the request object + :param config: the build configuration + :type config: `BuildConfig` + :param category: the category of reports to include in the chart + :type category: `basestring` + """ diff --git a/trac-0.11/bitten/build/__init__.py b/trac-0.11/bitten/build/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +from bitten.build.api import * + +__docformat__ = 'restructuredtext en' diff --git a/trac-0.11/bitten/build/api.py b/trac-0.11/bitten/build/api.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/api.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Functions and classes used to simplify the implementation recipe commands.""" + +import logging +import fnmatch +import os +import shlex +import time + +log = logging.getLogger('bitten.build.api') + +__docformat__ = 'restructuredtext en' + + +class BuildError(Exception): + """Exception raised when a build fails.""" + + +class TimeoutError(Exception): + """Exception raised when the execution of a command times out.""" + + +def _combine(*iterables): + iterables = [iter(iterable) for iterable in iterables] + size = len(iterables) + while True: + to_yield = [None] * size + for idx, iterable in enumerate(iterables): + if iterable is None: + continue + try: + to_yield[idx] = iterable.next() + except StopIteration: + iterables[idx] = None + if not [iterable for iterable in iterables if iterable is not None]: + break + yield tuple(to_yield) + + +class CommandLine(object): + """Simple helper for executing subprocesses.""" + + def __init__(self, executable, args, input=None, cwd=None): + """Initialize the CommandLine object. + + :param executable: the name of the program to execute + :param args: a list of arguments to pass to the executable + :param input: string or file-like object containing any input data for + the program + :param cwd: the working directory to change to before executing the + command + """ + self.executable = executable + self.arguments = [str(arg) for arg in args] + self.input = input + self.cwd = cwd + if self.cwd: + assert os.path.isdir(self.cwd) + self.returncode = None + + if os.name == 'nt': # windows + + def execute(self, timeout=None): + """Execute the command, and return a generator for iterating over + the output written to the standard output and error streams. + + :param timeout: number of seconds before the external process + should be aborted (not supported on Windows) + """ + args = [self.executable] + self.arguments + for idx, arg in enumerate(args): + if arg.find(' ') >= 0: + args[idx] = '"%s"' % arg + log.debug('Executing %s', args) + + if self.cwd: + old_cwd = os.getcwd() + os.chdir(self.cwd) + + import tempfile + in_name = None + if self.input: + if isinstance(self.input, basestring): + in_file, in_name = tempfile.mkstemp(prefix='bitten_', + suffix='.pipe') + os.write(in_file, self.input) + os.close(in_file) + in_redirect = '< "%s" ' % in_name + else: + in_redirect = '< "%s" ' % self.input.name + else: + in_redirect = '' + + out_file, out_name = tempfile.mkstemp(prefix='bitten_', + suffix='.pipe') + os.close(out_file) + err_file, err_name = tempfile.mkstemp(prefix='bitten_', + suffix='.pipe') + os.close(err_file) + + try: + cmd = '( %s ) > "%s" %s 2> "%s"' % (' '.join(args), out_name, + in_redirect, err_name) + self.returncode = os.system(cmd) + log.debug('Exited with code %s', self.returncode) + + out_file = file(out_name, 'r') + err_file = file(err_name, 'r') + out_lines = out_file.readlines() + err_lines = err_file.readlines() + out_file.close() + err_file.close() + finally: + if in_name: + os.unlink(in_name) + if out_name: + os.unlink(out_name) + if err_name: + os.unlink(err_name) + if self.cwd: + os.chdir(old_cwd) + + for out_line, err_line in _combine(out_lines, err_lines): + yield out_line and out_line.rstrip().replace('\x00', ''), \ + err_line and err_line.rstrip().replace('\x00', '') + + else: # posix + + def execute(self, timeout=None): + """Execute the command, and return a generator for iterating over + the output written to the standard output and error streams. + + :param timeout: number of seconds before the external process + should be aborted (not supported on Windows) + """ + import popen2, select + if self.cwd: + old_cwd = os.getcwd() + os.chdir(self.cwd) + + log.debug('Executing %s', [self.executable] + self.arguments) + pipe = popen2.Popen3([self.executable] + self.arguments, + capturestderr=True) + if self.input: + if isinstance(self.input, basestring): + in_data = self.input + else: + in_data = self.input.read() + else: + pipe.tochild.close() + in_data = '' + + out_data, err_data = [], [] + in_eof = out_eof = err_eof = False + if not in_data: + in_eof = True + while not out_eof or not err_eof: + readable = [pipe.fromchild] * (not out_eof) + \ + [pipe.childerr] * (not err_eof) + writable = [pipe.tochild] * (not in_eof) + ready = select.select(readable, writable, [], timeout) + if not (ready[0] or ready[1]): + raise TimeoutError('Command %s timed out' % self.executable) + if pipe.tochild in ready[1]: + sent = os.write(pipe.tochild.fileno(), in_data) + in_data = in_data[sent:] + if not in_data: + pipe.tochild.close() + in_eof = True + if pipe.fromchild in ready[0]: + data = os.read(pipe.fromchild.fileno(), 1024) + if data: + out_data.append(data) + else: + out_eof = True + if pipe.childerr in ready[0]: + data = os.read(pipe.childerr.fileno(), 1024) + if data: + err_data.append(data) + else: + err_eof = True + out_lines = self._extract_lines(out_data) + err_lines = self._extract_lines(err_data) + for out_line, err_line in _combine(out_lines, err_lines): + yield out_line, err_line + time.sleep(.1) + self.returncode = pipe.wait() + log.debug('%s exited with code %s', self.executable, + self.returncode) + + if self.cwd: + os.chdir(old_cwd) + + def _extract_lines(self, data): + extracted = [] + def _endswith_linesep(string): + for linesep in ('\n', '\r\n', '\r'): + if string.endswith(linesep): + return True + buf = ''.join(data) + lines = buf.splitlines(True) + if len(lines) > 1: + extracted += lines[:-1] + if _endswith_linesep(lines[-1]): + extracted.append(lines[-1]) + buf = '' + else: + buf = lines[-1] + elif _endswith_linesep(buf): + extracted.append(buf) + buf = '' + data[:] = [buf] * bool(buf) + + return [line.rstrip() for line in extracted] + + +class FileSet(object): + """Utility class for collecting a list of files in a directory that match + given name/path patterns.""" + + DEFAULT_EXCLUDES = ['CVS/*', '*/CVS/*', '.svn/*', '*/.svn/*', + '.DS_Store', 'Thumbs.db'] + + def __init__(self, basedir, include=None, exclude=None): + """Create a file set. + + :param basedir: the base directory for all files in the set + :param include: a list of patterns that define which files should be + included in the set + :param exclude: a list of patterns that define which files should be + excluded from the set + """ + self.files = [] + self.basedir = basedir + + self.include = [] + if include is not None: + self.include = shlex.split(include) + + self.exclude = self.DEFAULT_EXCLUDES[:] + if exclude is not None: + self.exclude += shlex.split(exclude) + + for dirpath, dirnames, filenames in os.walk(self.basedir): + dirpath = dirpath[len(self.basedir) + 1:] + + for filename in filenames: + filepath = nfilepath = os.path.join(dirpath, filename) + if os.sep != '/': + nfilepath = nfilepath.replace(os.sep, '/') + + if self.include: + included = False + for pattern in self.include: + if fnmatch.fnmatchcase(nfilepath, pattern) or \ + fnmatch.fnmatchcase(filename, pattern): + included = True + break + if not included: + continue + + excluded = False + for pattern in self.exclude: + if fnmatch.fnmatchcase(nfilepath, pattern) or \ + fnmatch.fnmatchcase(filename, pattern): + excluded = True + break + if not excluded: + self.files.append(filepath) + + def __iter__(self): + """Iterate over the names of all files in the set.""" + for filename in self.files: + yield filename + + def __contains__(self, filename): + """Return whether the given file name is in the set. + + :param filename: the name of the file to check + """ + return filename in self.files diff --git a/trac-0.11/bitten/build/config.py b/trac-0.11/bitten/build/config.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/config.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Support for build slave configuration.""" + +from ConfigParser import SafeConfigParser +import logging +import os +import platform +import re + +log = logging.getLogger('bitten.config') + +__docformat__ = 'restructuredtext en' + + +class Configuration(object): + """Encapsulates the configuration of a build machine. + + Configuration values can be provided through a configuration file (in INI + format) or through command-line parameters (properties). In addition to + explicitly defined properties, this class automatically collects platform + information and stores them as properties. These defaults can be + overridden (useful for cross-compilation). + """ + # TODO: document mapping from config file to property names + + def __init__(self, filename=None, properties=None): + """Create the configuration object. + + :param filename: the path to the configuration file, if any + :param properties: a dictionary of the configuration properties + provided on the command-line + """ + self.properties = {} + self.packages = {} + parser = SafeConfigParser() + if filename: + parser.read(filename) + self._merge_sysinfo(parser, properties) + self._merge_packages(parser, properties) + + def _merge_sysinfo(self, parser, properties): + """Merge the platform information properties into the configuration.""" + system, _, release, version, machine, processor = platform.uname() + system, release, version = platform.system_alias(system, release, + version) + self.properties['machine'] = machine + self.properties['processor'] = processor + self.properties['os'] = system + self.properties['family'] = os.name + self.properties['version'] = release + + mapping = {'machine': ('machine', 'name'), + 'processor': ('machine', 'processor'), + 'os': ('os', 'name'), + 'family': ('os', 'family'), + 'version': ('os', 'version')} + for key, (section, option) in mapping.items(): + if parser.has_section(section): + value = parser.get(section, option) + if value is not None: + self.properties[key] = value + + if properties: + for key, value in properties.items(): + if key in mapping: + self.properties[key] = value + + def _merge_packages(self, parser, properties): + """Merge package information into the configuration.""" + for section in parser.sections(): + if section in ('os', 'machine', 'maintainer'): + continue + package = {} + for option in parser.options(section): + package[option] = parser.get(section, option) + self.packages[section] = package + + if properties: + for key, value in properties.items(): + if '.' in key: + package, propname = key.split('.', 1) + if package not in self.packages: + self.packages[package] = {} + self.packages[package][propname] = value + + def __contains__(self, key): + """Return whether the configuration contains a value for the specified + key. + + :param key: name of the configuration option using dotted notation + (for example, "python.path") + """ + if '.' in key: + package, propname = key.split('.', 1) + return propname in self.packages.get(package, {}) + return key in self.properties + + def __getitem__(self, key): + """Return the value for the specified configuration key. + + :param key: name of the configuration option using dotted notation + (for example, "python.path") + """ + if '.' in key: + package, propname = key.split('.', 1) + return self.packages.get(package, {}).get(propname) + return self.properties.get(key) + + def __str__(self): + return str({'properties': self.properties, 'packages': self.packages}) + + def get_dirpath(self, key): + """Return the value of the specified configuration key, but verify that + the value refers to the path of an existing directory. + + If the value does not exist, or is not a directory path, return `None`. + + :param key: name of the configuration option using dotted notation + (for example, "ant.home") + """ + dirpath = self[key] + if dirpath: + if os.path.isdir(dirpath): + return dirpath + log.warning('Invalid %s: %s is not a directory', key, dirpath) + return None + + def get_filepath(self, key): + """Return the value of the specified configuration key, but verify that + the value refers to the path of an existing file. + + If the value does not exist, or is not a file path, return `None`. + + :param key: name of the configuration option using dotted notation + (for example, "python.path") + """ + filepath = self[key] + if filepath: + if os.path.isfile(filepath): + return filepath + log.warning('Invalid %s: %s is not a file', key, filepath) + return None + + _VAR_RE = re.compile(r'\$\{(?P\w[\w.]*?\w)(?:\:(?P.+))?\}') + + def interpolate(self, text, **vars): + """Interpolate configuration properties into a string. + + Properties can be referenced in the text using the notation + ``${property.name}``. A default value can be provided by appending it to + the property name separated by a colon, for example + ``${property.name:defaultvalue}``. This value will be used when there's + no such property in the configuration. Otherwise, if no default is + provided, the reference is not replaced at all. + + :param text: the string containing variable references + :param vars: extra variables to use for the interpolation + """ + def _replace(m): + refname = m.group('ref') + if refname in self: + return self[refname] + elif refname in vars: + return vars[refname] + elif m.group('def'): + return m.group('def') + else: + return m.group(0) + return self._VAR_RE.sub(_replace, text) diff --git a/trac-0.11/bitten/build/ctools.py b/trac-0.11/bitten/build/ctools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/ctools.py @@ -0,0 +1,365 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Recipe commands for build tasks commonly used for C/C++ projects.""" + +import logging +import re +import os +import posixpath +import shlex + +from bitten.build import CommandLine, FileSet +from bitten.util import xmlio + +log = logging.getLogger('bitten.build.ctools') + +__docformat__ = 'restructuredtext en' + +def configure(ctxt, file_='configure', enable=None, disable=None, with=None, + without=None, cflags=None, cxxflags=None): + """Run a ``configure`` script. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the configure script + :param enable: names of the features to enable, seperated by spaces + :param disable: names of the features to disable, separated by spaces + :param with: names of external packages to include + :param without: names of external packages to exclude + :param cflags: ``CFLAGS`` to pass to the configure script + :param cxxflags: ``CXXFLAGS`` to pass to the configure script + """ + args = [] + if enable: + args += ['--enable-%s' % feature for feature in enable.split()] + if disable: + args += ['--disable-%s' % feature for feature in disable.split()] + if with: + for pkg in with.split(): + pkg_path = pkg + '.path' + if pkg_path in ctxt.config: + args.append('--with-%s=%s' % (pkg, ctxt.config[pkg_path])) + else: + args.append('--with-%s' % pkg) + if without: + args += ['--without-%s' % pkg for pkg in without.split()] + if cflags: + args.append('CFLAGS=%s' % cflags) + if cxxflags: + args.append('CXXFLAGS=%s' % cxxflags) + + from bitten.build import shtools + returncode = shtools.execute(ctxt, file_=file_, args=args) + if returncode != 0: + ctxt.error('configure failed (%s)' % returncode) + +def autoreconf(ctxt, file_='configure', force=None, install=None, symlink=None, + warnings=None, prepend_include=None, include =None): + """Run the autotoll ``autoreconf``. + + :param ctxt: the build context + :type ctxt: `Context` + :param force: consider all files obsolete + :param install: copy missing auxiliary files + :param symlink: install symbolic links instead of copies + :param warnings: report the warnings falling in CATEGORY + :prepend_include: prepend directories to search path + :include: append directories to search path + + """ + args = [] + if install: + args.append('--install') + if symlink: + args.append('--symlink') + if force: + args.append('--force') + if warnings: + args.append('--warnings=%s' % warnings) + + if include: + args += ['--include=%s' % inc for inc in include.split()] + if prepend_include: + args += ['--prepend-include=%s' % pinc for pinc in prepend_include.split()] + + from bitten.build import shtools + returncode = shtools.execute(ctxt, 'autoreconf', args=args) + if returncode != 0: + ctxt.error('autoreconf failed (%s)' % returncode) + +def make(ctxt, target=None, file_=None, keep_going=False, directory=None, jobs=None, args=None): + """Execute a Makefile target. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the Makefile + :param keep_going: whether make should keep going when errors are + encountered + :param directory: directory in which to build; defaults to project source directory + :param jobs: number of concurrent jobs to run + :param args: command-line arguments to pass to the script + """ + executable = ctxt.config.get_filepath('make.path') or 'make' + + if directory is None: + directory = ctxt.basedir + + margs = ['--directory', directory] + + if file_: + margs += ['--file', ctxt.resolve(file_)] + if keep_going: + margs.append('--keep-going') + if target: + margs.append(target) + if jobs: + margs += ['--jobs', jobs] + + if args: + if isinstance(args, basestring): + margs += shlex.split(args) + + from bitten.build import shtools + returncode = shtools.execute(ctxt, executable=executable, args=margs) + if returncode != 0: + ctxt.error('make failed (%s)' % returncode) + +def cppunit(ctxt, file_=None, srcdir=None): + """Collect CppUnit XML data. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: path of the file containing the CppUnit results; may contain + globbing wildcards to match multiple files + :param srcdir: name of the directory containing the source files, used to + link the test results to the corresponding files + """ + assert file_, 'Missing required attribute "file"' + + try: + fileobj = file(ctxt.resolve(file_), 'r') + try: + total, failed = 0, 0 + results = xmlio.Fragment() + for group in xmlio.parse(fileobj): + if group.name not in ('FailedTests', 'SuccessfulTests'): + continue + for child in group.children(): + test = xmlio.Element('test') + name = child.children('Name').next().gettext() + if '::' in name: + parts = name.split('::') + test.attr['fixture'] = '::'.join(parts[:-1]) + name = parts[-1] + test.attr['name'] = name + + for location in child.children('Location'): + for file_elem in location.children('File'): + filepath = file_elem.gettext() + if srcdir is not None: + filepath = posixpath.join(srcdir, filepath) + test.attr['file'] = filepath + break + for line_elem in location.children('Line'): + test.attr['line'] = line_elem.gettext() + break + break + + if child.name == 'FailedTest': + for message in child.children('Message'): + test.append(xmlio.Element('traceback')[ + message.gettext() + ]) + test.attr['status'] = 'failure' + failed += 1 + else: + test.attr['status'] = 'success' + + results.append(test) + total += 1 + + if failed: + ctxt.error('%d of %d test%s failed' % (failed, total, + total != 1 and 's' or '')) + + ctxt.report('test', results) + + finally: + fileobj.close() + + except IOError, e: + log.warning('Error opening CppUnit results file (%s)', e) + except xmlio.ParseError, e: + print e + log.warning('Error parsing CppUnit results file (%s)', e) + +def cunit (ctxt, file_=None, srcdir=None): + """Collect CUnit XML data. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: path of the file containing the CUnit results; may contain + globbing wildcards to match multiple files + :param srcdir: name of the directory containing the source files, used to + link the test results to the corresponding files + """ + assert file_, 'Missing required attribute "file"' + + try: + fileobj = file(ctxt.resolve(file_), 'r') + try: + total, failed = 0, 0 + results = xmlio.Fragment() + log_elem = xmlio.Fragment() + def info (msg): + log.info (msg) + log_elem.append (xmlio.Element ('message', level='info')[msg]) + def warning (msg): + log.warning (msg) + log_elem.append (xmlio.Element ('message', level='warning')[msg]) + def error (msg): + log.error (msg) + log_elem.append (xmlio.Element ('message', level='error')[msg]) + for node in xmlio.parse(fileobj): + if node.name != 'CUNIT_RESULT_LISTING': + continue + for suiteRun in node.children ('CUNIT_RUN_SUITE'): + for suite in suiteRun.children(): + if suite.name not in ('CUNIT_RUN_SUITE_SUCCESS', 'CUNIT_RUN_SUITE_FAILURE'): + warning ("Unknown node: %s" % suite.name) + continue + suiteName = suite.children ('SUITE_NAME').next().gettext() + info ("%s [%s]" % ("*" * (57 - len (suiteName)), suiteName)) + for record in suite.children ('CUNIT_RUN_TEST_RECORD'): + for result in record.children(): + if result.name not in ('CUNIT_RUN_TEST_SUCCESS', 'CUNIT_RUN_TEST_FAILURE'): + continue + testName = result.children ('TEST_NAME').next().gettext() + info ("Running %s..." % testName); + test = xmlio.Element('test') + test.attr['fixture'] = suiteName + test.attr['name'] = testName + if result.name == 'CUNIT_RUN_TEST_FAILURE': + error ("%s(%d): %s" + % (result.children ('FILE_NAME').next().gettext(), + int (result.children ('LINE_NUMBER').next().gettext()), + result.children ('CONDITION').next().gettext())) + test.attr['status'] = 'failure' + failed += 1 + else: + test.attr['status'] = 'success' + + results.append(test) + total += 1 + + if failed: + ctxt.error('%d of %d test%s failed' % (failed, total, + total != 1 and 's' or '')) + + ctxt.report('test', results) + ctxt.log (log_elem) + + finally: + fileobj.close() + + except IOError, e: + log.warning('Error opening CUnit results file (%s)', e) + except xmlio.ParseError, e: + print e + log.warning('Error parsing CUnit results file (%s)', e) + +def gcov(ctxt, include=None, exclude=None, prefix=None, root=""): + """Run ``gcov`` to extract coverage data where available. + + :param ctxt: the build context + :type ctxt: `Context` + :param include: patterns of files and directories to include + :param exclude: patterns of files and directories that should be excluded + :param prefix: optional prefix name that is added to object files by the + build system + :param root: optional root path in which the build system puts the object + files + """ + file_re = re.compile(r'^File (?:\'|\`)(?P[^\']+)\'\s*$') + lines_re = re.compile(r'^Lines executed:(?P\d+\.\d+)\% of (?P\d+)\s*$') + + files = [] + for filename in FileSet(ctxt.basedir, include, exclude): + if os.path.splitext(filename)[1] in ('.c', '.cpp', '.cc', '.cxx'): + files.append(filename) + + coverage = xmlio.Fragment() + log_elem = xmlio.Fragment() + def info (msg): + log.info (msg) + log_elem.append (xmlio.Element ('message', level='info')[msg]) + def warning (msg): + log.warning (msg) + log_elem.append (xmlio.Element ('message', level='warning')[msg]) + def error (msg): + log.error (msg) + log_elem.append (xmlio.Element ('message', level='error')[msg]) + + for srcfile in files: + # Determine the coverage for each source file by looking for a .gcno + # and .gcda pair + info ("Getting coverage info for %s" % srcfile) + filepath, filename = os.path.split(srcfile) + stem = os.path.splitext(filename)[0] + if prefix is not None: + stem = prefix + '-' + stem + + objfile = os.path.join (root, filepath, stem + '.o') + if not os.path.isfile(ctxt.resolve(objfile)): + warning ('No object file found for %s at %s' % (srcfile, objfile)) + continue + if not os.path.isfile (ctxt.resolve (os.path.join (root, filepath, stem + '.gcno'))): + warning ('No .gcno file found for %s at %s' % (srcfile, os.path.join (root, filepath, stem + '.gcno'))) + continue + if not os.path.isfile (ctxt.resolve (os.path.join (root, filepath, stem + '.gcda'))): + warning ('No .gcda file found for %s at %s' % (srcfile, os.path.join (root, filepath, stem + '.gcda'))) + continue + + num_lines, num_covered = 0, 0 + skip_block = False + cmd = CommandLine('gcov', ['-b', '-n', '-o', objfile, srcfile], + cwd=ctxt.basedir) + for out, err in cmd.execute(): + if out == '': # catch blank lines, reset the block state... + skip_block = False + elif out and not skip_block: + # Check for a file name + match = file_re.match(out) + if match: + if os.path.isabs(match.group('file')): + skip_block = True + continue + else: + # check for a "Lines executed" message + match = lines_re.match(out) + if match: + lines = float(match.group('num')) + cov = float(match.group('cov')) + num_covered += int(lines * cov / 100) + num_lines += int(lines) + if cmd.returncode != 0: + continue + + module = xmlio.Element('coverage', name=os.path.basename(srcfile), + file=srcfile.replace(os.sep, '/'), + lines=num_lines, percentage=0) + if num_lines: + percent = int(round(num_covered * 100 / num_lines)) + module.attr['percentage'] = percent + coverage.append(module) + + ctxt.report('coverage', coverage) + ctxt.log (log_elem) diff --git a/trac-0.11/bitten/build/javatools.py b/trac-0.11/bitten/build/javatools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/javatools.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2006 Matthew Good +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Recipe commands for tools commonly used in Java projects.""" + +from glob import glob +import logging +import os +import posixpath +import shlex +import tempfile + +from bitten.build import CommandLine +from bitten.util import xmlio + +log = logging.getLogger('bitten.build.javatools') + +__docformat__ = 'restructuredtext en' + +def ant(ctxt, file_=None, target=None, keep_going=False, args=None): + """Run an Ant build. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the Ant build file + :param target: name of the target that should be executed (optional) + :param keep_going: whether Ant should keep going when errors are encountered + :param args: additional arguments to pass to Ant + """ + executable = 'ant' + ant_home = ctxt.config.get_dirpath('ant.home') + if ant_home: + executable = os.path.join(ant_home, 'bin', 'ant') + + java_home = ctxt.config.get_dirpath('java.home') + if java_home: + os.environ['JAVA_HOME'] = java_home + + logfile = tempfile.NamedTemporaryFile(prefix='ant_log', suffix='.xml') + logfile.close() + if args: + args = shlex.split(args) + else: + args = [] + args += ['-noinput', '-listener', 'org.apache.tools.ant.XmlLogger', + '-Dant.XmlLogger.stylesheet.uri', '""', + '-DXmlLogger.file', logfile.name] + if file_: + args += ['-buildfile', ctxt.resolve(file_)] + if keep_going: + args.append('-keep-going') + if target: + args.append(target) + + cmdline = CommandLine(executable, args, cwd=ctxt.basedir) + for out, err in cmdline.execute(): + if out is not None: + log.info(out) + if err is not None: + log.error(err) + + error_logged = False + log_elem = xmlio.Fragment() + try: + xml_log = xmlio.parse(file(logfile.name, 'r')) + def collect_log_messages(node): + for child in node.children(): + if child.name == 'message': + if child.attr['priority'] == 'debug': + continue + log_elem.append(xmlio.Element('message', + level=child.attr['priority'])[ + child.gettext().replace(ctxt.basedir + os.sep, '') + .replace(ctxt.basedir, '') + ]) + else: + collect_log_messages(child) + collect_log_messages(xml_log) + + if 'error' in xml_log.attr: + ctxt.error(xml_log.attr['error']) + error_logged = True + + except xmlio.ParseError, e: + log.warning('Error parsing Ant XML log file (%s)', e) + ctxt.log(log_elem) + + if not error_logged and cmdline.returncode != 0: + ctxt.error('Ant failed (%s)' % cmdline.returncode) + +def junit(ctxt, file_=None, srcdir=None): + """Extract test results from a JUnit XML report. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: path to the JUnit XML test results; may contain globbing + wildcards for matching multiple results files + :param srcdir: name of the directory containing the test sources, used to + link test results to the corresponding source files + """ + assert file_, 'Missing required attribute "file"' + try: + total, failed = 0, 0 + results = xmlio.Fragment() + for path in glob(ctxt.resolve(file_)): + fileobj = file(path, 'r') + try: + for testcase in xmlio.parse(fileobj).children('testcase'): + test = xmlio.Element('test') + test.attr['fixture'] = testcase.attr['classname'] + if 'time' in testcase.attr: + test.attr['duration'] = testcase.attr['time'] + if srcdir is not None: + cls = testcase.attr['classname'].split('.') + test.attr['file'] = posixpath.join(srcdir, *cls) + \ + '.java' + + result = list(testcase.children()) + if result: + test.attr['status'] = result[0].name + test.append(xmlio.Element('traceback')[ + result[0].gettext() + ]) + failed += 1 + else: + test.attr['status'] = 'success' + + results.append(test) + total += 1 + finally: + fileobj.close() + if failed: + ctxt.error('%d of %d test%s failed' % (failed, total, + total != 1 and 's' or '')) + ctxt.report('test', results) + except IOError, e: + log.warning('Error opening JUnit results file (%s)', e) + except xmlio.ParseError, e: + log.warning('Error parsing JUnit results file (%s)', e) + + +class _LineCounter(object): + def __init__(self): + self.lines = [] + self.covered = 0 + self.num_lines = 0 + + def __getitem__(self, idx): + if idx >= len(self.lines): + return 0 + return self.lines[idx] + + def __setitem__(self, idx, val): + idx = int(idx) - 1 # 1-indexed to 0-indexed + from itertools import repeat + if idx >= len(self.lines): + self.lines.extend(repeat('-', idx - len(self.lines) + 1)) + self.lines[idx] = val + self.num_lines += 1 + if val != '0': + self.covered += 1 + + def line_hits(self): + return ' '.join(self.lines) + line_hits = property(line_hits) + + def percentage(self): + if self.num_lines == 0: + return 0 + return int(round(self.covered * 100. / self.num_lines)) + percentage = property(percentage) + + +def cobertura(ctxt, file_=None): + """Extract test coverage information from a Cobertura XML report. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: path to the Cobertura XML output + """ + assert file_, 'Missing required attribute "file"' + + coverage = xmlio.Fragment() + doc = xmlio.parse(open(ctxt.resolve(file_))) + srcdir = [s.gettext().strip() for ss in doc.children('sources') + for s in ss.children('source')][0] + + classes = [cls for pkgs in doc.children('packages') + for pkg in pkgs.children('package') + for clss in pkg.children('classes') + for cls in clss.children('class')] + + counters = {} + class_names = {} + + for cls in classes: + filename = cls.attr['filename'].replace(os.sep, '/') + name = cls.attr['name'] + if not '$' in name: # ignore internal classes + class_names[filename] = name + counter = counters.get(filename) + if counter is None: + counter = counters[filename] = _LineCounter() + lines = [l for ls in cls.children('lines') + for l in ls.children('line')] + for line in lines: + counter[line.attr['number']] = line.attr['hits'] + + for filename, name in class_names.iteritems(): + counter = counters[filename] + module = xmlio.Element('coverage', name=name, + file=posixpath.join(srcdir, filename), + lines=counter.num_lines, + percentage=counter.percentage) + module.append(xmlio.Element('line_hits')[counter.line_hits]) + coverage.append(module) + ctxt.report('coverage', coverage) diff --git a/trac-0.11/bitten/build/phptools.py b/trac-0.11/bitten/build/phptools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/phptools.py @@ -0,0 +1,113 @@ +# -*- coding: UTF-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2007 Wei Zhuo +# 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 +import shlex + +from bitten.util import xmlio +from bitten.build import shtools + +log = logging.getLogger('bitten.build.phptools') + +def phing(ctxt, file_=None, target=None, executable=None, args=None): + """Run a phing build""" + if args: + args = shlex.split(args) + else: + args = [] + args += ['-logger', 'phing.listener.DefaultLogger', + '-buildfile', ctxt.resolve(file_ or 'build.xml')] + if target: + args.append(target) + + returncode = shtools.execute(ctxt, file_=executable or 'phing', args=args) + if returncode != 0: + ctxt.error('Phing failed (%s)' % returncode) + +def phpunit(ctxt, file_=None): + """Extract test results from a PHPUnit XML report.""" + assert file_, 'Missing required attribute "file"' + try: + total, failed = 0, 0 + results = xmlio.Fragment() + fileobj = file(ctxt.resolve(file_), 'r') + try: + for testsuit in xmlio.parse(fileobj).children('testsuite'): + total += int(testsuit.attr['tests']) + failed += int(testsuit.attr['failures']) + \ + int(testsuit.attr['errors']) + + for testcase in testsuit.children(): + test = xmlio.Element('test') + test.attr['fixture'] = testcase.attr['class'] + test.attr['name'] = testcase.attr['name'] + test.attr['duration'] = testcase.attr['time'] + result = list(testcase.children()) + if result: + test.append(xmlio.Element('traceback')[ + result[0].gettext() + ]) + test.attr['status'] = result[0].name + else: + test.attr['status'] = 'success' + if 'file' in testsuit.attr: + testfile = os.path.realpath(testsuit.attr['file']) + if testfile.startswith(ctxt.basedir): + testfile = testfile[len(ctxt.basedir) + 1:] + testfile = testfile.replace(os.sep, '/') + test.attr['file'] = testfile + results.append(test) + finally: + fileobj.close() + if failed: + ctxt.error('%d of %d test%s failed' % (failed, total, + total != 1 and 's' or '')) + ctxt.report('test', results) + except IOError, e: + ctxt.log('Error opening PHPUnit results file (%s)' % e) + except xmlio.ParseError, e: + ctxt.log('Error parsing PHPUnit results file (%s)' % e) + +def coverage(ctxt, file_=None): + """Extract data from a Phing code coverage report.""" + assert file_, 'Missing required attribute "file"' + try: + summary_file = file(ctxt.resolve(file_), 'r') + try: + coverage = xmlio.Fragment() + for package in xmlio.parse(summary_file).children('package'): + for cls in package.children('class'): + statements = float(cls.attr['statementcount']) + covered = float(cls.attr['statementscovered']) + if statements: + percentage = covered / statements * 100 + else: + percentage = 100 + class_coverage = xmlio.Element('coverage', + name=cls.attr['name'], + lines=int(statements), + percentage=percentage + ) + source = list(cls.children())[0] + if 'sourcefile' in source.attr: + sourcefile = os.path.realpath(source.attr['sourcefile']) + if sourcefile.startswith(ctxt.basedir): + sourcefile = sourcefile[len(ctxt.basedir) + 1:] + sourcefile = sourcefile.replace(os.sep, '/') + class_coverage.attr['file'] = sourcefile + coverage.append(class_coverage) + finally: + summary_file.close() + ctxt.report('coverage', coverage) + except IOError, e: + ctxt.log('Error opening coverage summary file (%s)' % e) + except xmlio.ParseError, e: + ctxt.log('Error parsing coverage summary file (%s)' % e) diff --git a/trac-0.11/bitten/build/pythontools.py b/trac-0.11/bitten/build/pythontools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/pythontools.py @@ -0,0 +1,466 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2008 Matt Good +# Copyright (C) 2008 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Recipe commands for tools commonly used by Python projects.""" + +from __future__ import division + +import logging +import os +import cPickle as pickle +import re +try: + set +except NameError: + from sets import Set as set +import shlex +import sys + +from bitten.build import CommandLine, FileSet +from bitten.util import loc, xmlio + +log = logging.getLogger('bitten.build.pythontools') + +__docformat__ = 'restructuredtext en' + +def _python_path(ctxt): + """Return the path to the Python interpreter. + + If the configuration has a ``python.path`` property, the value of that + option is returned; otherwise the path to the current Python interpreter is + returned. + """ + python_path = ctxt.config.get_filepath('python.path') + if python_path: + return python_path + return sys.executable + +def distutils(ctxt, file_='setup.py', command='build', options=None): + """Execute a ``distutils`` command. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the file defining the distutils setup + :param command: the setup command to execute + :param options: additional options to pass to the command + """ + if options: + if isinstance(options, basestring): + options = shlex.split(options) + else: + options = [] + + cmdline = CommandLine(_python_path(ctxt), + [ctxt.resolve(file_), command] + options, + cwd=ctxt.basedir) + log_elem = xmlio.Fragment() + error_logged = False + for out, err in cmdline.execute(): + if out is not None: + log.info(out) + log_elem.append(xmlio.Element('message', level='info')[out]) + if err is not None: + level = 'error' + if err.startswith('warning: '): + err = err[9:] + level = 'warning' + log.warning(err) + elif err.startswith('error: '): + ctxt.error(err[7:]) + error_logged = True + else: + log.error(err) + log_elem.append(xmlio.Element('message', level=level)[err]) + ctxt.log(log_elem) + + if not error_logged and cmdline.returncode != 0: + ctxt.error('distutils failed (%s)' % cmdline.returncode) + +def exec_(ctxt, file_=None, module=None, function=None, output=None, args=None): + """Execute a Python script. + + Either the `file_` or the `module` parameter must be provided. If + specified using the `file_` parameter, the file must be inside the project + directory. If specified as a module, the module must either be resolvable + to a file, or the `function` parameter must be provided + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the script file to execute + :param module: name of the Python module to execute + :param function: name of the Python function to run + :param output: name of the file to which output should be written + :param args: extra arguments to pass to the script + """ + assert file_ or module, 'Either "file" or "module" attribute required' + if function: + assert module and not file_, '"module" attribute required for use of ' \ + '"function" attribute' + + if module: + # Script specified as module name, need to resolve that to a file, + # or use the function name if provided + if function: + args = '-c "import sys; from %s import %s; %s(sys.argv)" %s' % ( + module, function, function, args) + else: + try: + mod = __import__(module, globals(), locals(), []) + components = module.split('.') + for comp in components[1:]: + mod = getattr(mod, comp) + file_ = mod.__file__.replace('\\', '/') + except ImportError, e: + ctxt.error('Cannot execute Python module %s: %s' % (module, e)) + return + + from bitten.build import shtools + returncode = shtools.execute(ctxt, executable=_python_path(ctxt), + file_=file_, output=output, args=args) + if returncode != 0: + ctxt.error('Executing %s failed (error code %s)' % (file_, returncode)) + +def pylint(ctxt, file_=None): + """Extract data from a ``pylint`` run written to a file. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the file containing the Pylint output + """ + assert file_, 'Missing required attribute "file"' + msg_re = re.compile(r'^(?P.+):(?P\d+): ' + r'\[(?P[A-Z]\d*)(?:, (?P[\w\.]+))?\] ' + r'(?P.*)$') + msg_categories = dict(W='warning', E='error', C='convention', R='refactor') + + problems = xmlio.Fragment() + try: + fd = open(ctxt.resolve(file_), 'r') + try: + for line in fd: + match = msg_re.search(line) + if match: + msg_type = match.group('type') + category = msg_categories.get(msg_type[0]) + if len(msg_type) == 1: + msg_type = None + filename = os.path.realpath(match.group('file')) + if filename.startswith(ctxt.basedir): + filename = filename[len(ctxt.basedir) + 1:] + filename = filename.replace(os.sep, '/') + lineno = int(match.group('line')) + tag = match.group('tag') + problems.append(xmlio.Element('problem', category=category, + type=msg_type, tag=tag, + line=lineno, file=filename)[ + match.group('msg') or '' + ]) + ctxt.report('lint', problems) + finally: + fd.close() + except IOError, e: + log.warning('Error opening pylint results file (%s)', e) + +def coverage(ctxt, summary=None, coverdir=None, include=None, exclude=None): + """Extract data from a ``coverage.py`` run. + + :param ctxt: the build context + :type ctxt: `Context` + :param summary: path to the file containing the coverage summary + :param coverdir: name of the directory containing the per-module coverage + details + :param include: patterns of files or directories to include in the report + :param exclude: patterns of files or directories to exclude from the report + """ + assert summary, 'Missing required attribute "summary"' + + summary_line_re = re.compile(r'^(?P.*?)\s+(?P\d+)\s+' + r'(?P\d+)\s+(?P\d+)%\s+' + r'(?:(?P(?:\d+(?:-\d+)?(?:, )?)*)\s+)?' + r'(?P.+)$') + + fileset = FileSet(ctxt.basedir, include, exclude) + missing_files = [] + for filename in fileset: + if os.path.splitext(filename)[1] != '.py': + continue + missing_files.append(filename) + covered_modules = set() + + try: + summary_file = open(ctxt.resolve(summary), 'r') + try: + coverage = xmlio.Fragment() + for summary_line in summary_file: + match = summary_line_re.search(summary_line) + if match: + modname = match.group(1) + filename = match.group(6) + if not os.path.isabs(filename): + filename = os.path.normpath(os.path.join(ctxt.basedir, + filename)) + else: + filename = os.path.realpath(filename) + if not filename.startswith(ctxt.basedir): + continue + filename = filename[len(ctxt.basedir) + 1:] + if not filename in fileset: + continue + + percentage = int(match.group(4).rstrip('%')) + num_lines = int(match.group(2)) + + missing_files.remove(filename) + covered_modules.add(modname) + module = xmlio.Element('coverage', name=modname, + file=filename.replace(os.sep, '/'), + percentage=percentage, + lines=num_lines) + coverage.append(module) + + for filename in missing_files: + modname = os.path.splitext(filename.replace(os.sep, '.'))[0] + if modname in covered_modules: + continue + covered_modules.add(modname) + module = xmlio.Element('coverage', name=modname, + file=filename.replace(os.sep, '/'), + percentage=0) + coverage.append(module) + + ctxt.report('coverage', coverage) + finally: + summary_file.close() + except IOError, e: + log.warning('Error opening coverage summary file (%s)', e) + +def trace(ctxt, summary=None, coverdir=None, include=None, exclude=None): + """Extract data from a ``trace.py`` run. + + :param ctxt: the build context + :type ctxt: `Context` + :param summary: path to the file containing the coverage summary + :param coverdir: name of the directory containing the per-module coverage + details + :param include: patterns of files or directories to include in the report + :param exclude: patterns of files or directories to exclude from the report + """ + assert summary, 'Missing required attribute "summary"' + assert coverdir, 'Missing required attribute "coverdir"' + + summary_line_re = re.compile(r'^\s*(?P\d+)\s+(?P\d+)%\s+' + r'(?P.*?)\s+\((?P.*?)\)') + coverage_line_re = re.compile(r'\s*(?:(?P\d+): )?(?P.*)') + + fileset = FileSet(ctxt.basedir, include, exclude) + missing_files = [] + for filename in fileset: + if os.path.splitext(filename)[1] != '.py': + continue + missing_files.append(filename) + covered_modules = set() + + def handle_file(elem, sourcefile, coverfile=None): + code_lines = set() + for lineno, linetype, line in loc.count(sourcefile): + if linetype == loc.CODE: + code_lines.add(lineno) + num_covered = 0 + lines = [] + + if coverfile: + prev_hits = '0' + for idx, coverline in enumerate(coverfile): + match = coverage_line_re.search(coverline) + if match: + hits = match.group(1) + if hits: # Line covered + if hits != '0': + num_covered += 1 + lines.append(hits) + prev_hits = hits + elif coverline.startswith('>'): # Line not covered + lines.append('0') + prev_hits = '0' + elif idx not in code_lines: # Not a code line + lines.append('-') + prev_hits = '0' + else: # A code line not flagged by trace.py + if prev_hits != '0': + num_covered += 1 + lines.append(prev_hits) + + elem.append(xmlio.Element('line_hits')[' '.join(lines)]) + + num_lines = len(code_lines) + if num_lines: + percentage = int(round(num_covered * 100 / num_lines)) + else: + percentage = 0 + elem.attr['percentage'] = percentage + elem.attr['lines'] = num_lines + + try: + summary_file = open(ctxt.resolve(summary), 'r') + try: + coverage = xmlio.Fragment() + for summary_line in summary_file: + match = summary_line_re.search(summary_line) + if match: + modname = match.group(3) + filename = match.group(4) + if not os.path.isabs(filename): + filename = os.path.normpath(os.path.join(ctxt.basedir, + filename)) + else: + filename = os.path.realpath(filename) + if not filename.startswith(ctxt.basedir): + continue + filename = filename[len(ctxt.basedir) + 1:] + if not filename in fileset: + continue + + missing_files.remove(filename) + covered_modules.add(modname) + module = xmlio.Element('coverage', name=modname, + file=filename.replace(os.sep, '/')) + sourcefile = file(ctxt.resolve(filename)) + try: + coverpath = ctxt.resolve(coverdir, modname + '.cover') + if os.path.isfile(coverpath): + coverfile = file(coverpath, 'r') + else: + log.warning('No coverage file for module %s at %s', + modname, coverpath) + coverfile = None + try: + handle_file(module, sourcefile, coverfile) + finally: + if coverfile: + coverfile.close() + finally: + sourcefile.close() + coverage.append(module) + + for filename in missing_files: + modname = os.path.splitext(filename.replace(os.sep, '.'))[0] + if modname in covered_modules: + continue + covered_modules.add(modname) + module = xmlio.Element('coverage', name=modname, + file=filename.replace(os.sep, '/'), + percentage=0) + filepath = ctxt.resolve(filename) + fileobj = file(filepath, 'r') + try: + handle_file(module, fileobj) + finally: + fileobj.close() + coverage.append(module) + + ctxt.report('coverage', coverage) + finally: + summary_file.close() + except IOError, e: + log.warning('Error opening coverage summary file (%s)', e) + +def figleaf(ctxt, summary=None, include=None, exclude=None): + from figleaf import get_lines + coverage = xmlio.Fragment() + try: + fileobj = open(ctxt.resolve(summary)) + except IOError, e: + log.warning('Error opening coverage summary file (%s)', e) + return + coverage_data = pickle.load(fileobj) + fileset = FileSet(ctxt.basedir, include, exclude) + for filename in fileset: + base, ext = os.path.splitext(filename) + if ext != '.py': + continue + modname = base.replace(os.path.sep, '.') + realfilename = ctxt.resolve(filename) + interesting_lines = get_lines(open(realfilename)) + covered_lines = coverage_data.get(realfilename, set()) + percentage = int(round(len(covered_lines) * 100 / len(interesting_lines))) + line_hits = [] + for lineno in xrange(1, max(interesting_lines)+1): + if lineno not in interesting_lines: + line_hits.append('-') + elif lineno in covered_lines: + line_hits.append('1') + else: + line_hits.append('0') + module = xmlio.Element('coverage', name=modname, + file=filename, + percentage=percentage, + lines=len(interesting_lines), + line_hits=' '.join(line_hits)) + coverage.append(module) + ctxt.report('coverage', coverage) + +def _normalize_filenames(ctxt, filenames, fileset): + for filename in filenames: + if not os.path.isabs(filename): + filename = os.path.normpath(os.path.join(ctxt.basedir, + filename)) + else: + filename = os.path.realpath(filename) + if not filename.startswith(ctxt.basedir): + continue + filename = filename[len(ctxt.basedir) + 1:] + if filename not in fileset: + continue + yield filename.replace(os.sep, '/') + +def unittest(ctxt, file_=None): + """Extract data from a unittest results file in XML format. + + :param ctxt: the build context + :type ctxt: `Context` + :param file\_: name of the file containing the test results + """ + assert file_, 'Missing required attribute "file"' + + try: + fileobj = file(ctxt.resolve(file_), 'r') + try: + total, failed = 0, 0 + results = xmlio.Fragment() + for child in xmlio.parse(fileobj).children(): + test = xmlio.Element('test') + for name, value in child.attr.items(): + if name == 'file': + value = os.path.realpath(value) + if value.startswith(ctxt.basedir): + value = value[len(ctxt.basedir) + 1:] + value = value.replace(os.sep, '/') + else: + continue + test.attr[name] = value + if name == 'status' and value in ('error', 'failure'): + failed += 1 + for grandchild in child.children(): + test.append(xmlio.Element(grandchild.name)[ + grandchild.gettext() + ]) + results.append(test) + total += 1 + if failed: + ctxt.error('%d of %d test%s failed' % (failed, total, + total != 1 and 's' or '')) + ctxt.report('test', results) + finally: + fileobj.close() + except IOError, e: + log.warning('Error opening unittest results file (%s)', e) + except xmlio.ParseError, e: + log.warning('Error parsing unittest results file (%s)', e) diff --git a/trac-0.11/bitten/build/shtools.py b/trac-0.11/bitten/build/shtools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/shtools.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Generic recipe commands for executing external processes.""" + +import logging +import os +import shlex + +from bitten.build import CommandLine +from bitten.util import xmlio + +log = logging.getLogger('bitten.build.shtools') + +__docformat__ = 'restructuredtext en' + +def exec_(ctxt, executable=None, file_=None, output=None, args=None, dir_=None): + """Execute a program or shell script. + + :param ctxt: the build context + :type ctxt: `Context` + :param executable: name of the executable to run + :param file\_: name of the script file, relative to the project directory, + that should be run + :param output: name of the file to which the output of the script should be + written + :param args: command-line arguments to pass to the script + """ + assert executable or file_, \ + 'Either "executable" or "file" attribute required' + + returncode = execute(ctxt, executable=executable, file_=file_, + output=output, args=args, dir_=dir_) + if returncode != 0: + ctxt.error('Executing %s failed (error code %s)' % (executable or file_, + returncode)) + +def pipe(ctxt, executable=None, file_=None, input_=None, output=None, + args=None, dir_=None): + """Pipe the contents of a file through a program or shell script. + + :param ctxt: the build context + :type ctxt: `Context` + :param executable: name of the executable to run + :param file\_: name of the script file, relative to the project directory, + that should be run + :param input\_: name of the file containing the data that should be passed + to the shell script on its standard input stream + :param output: name of the file to which the output of the script should be + written + :param args: command-line arguments to pass to the script + """ + assert executable or file_, \ + 'Either "executable" or "file" attribute required' + assert input_, 'Missing required attribute "input"' + + returncode = execute(ctxt, executable=executable, file_=file_, + input_=input_, output=output, args=args, dir_=dir_) + if returncode != 0: + ctxt.error('Piping through %s failed (error code %s)' + % (executable or file_, returncode)) + +def execute(ctxt, executable=None, file_=None, input_=None, output=None, + args=None, dir_=None, filter_=None): + """Generic external program execution. + + This function is not itself bound to a recipe command, but rather used from + other commands. + + :param ctxt: the build context + :type ctxt: `Context` + :param executable: name of the executable to run + :param file\_: name of the script file, relative to the project directory, + that should be run + :param input\_: name of the file containing the data that should be passed + to the shell script on its standard input stream + :param output: name of the file to which the output of the script should be + written + :param args: command-line arguments to pass to the script + :param dirs: + :param filter\_: function to filter out messages from the executable stdout + """ + if args: + if isinstance(args, basestring): + args = shlex.split(args) + else: + args = [] + + if dir_: + def resolve(*args): + return ctxt.resolve(dir_, *args) + else: + resolve = ctxt.resolve + + if file_ and os.path.isfile(resolve(file_)): + file_ = resolve(file_) + + if executable is None: + executable = file_ + elif file_: + args[:0] = [file_] + + if input_: + input_file = file(resolve(input_), 'r') + else: + input_file = None + + if output: + output_file = file(resolve(output), 'w') + else: + output_file = None + + if dir_ and os.path.isdir(ctxt.resolve(dir_)): + dir_ = ctxt.resolve(dir_) + else: + dir_ = ctxt.basedir + + if not filter_: + filter_=lambda s: s + + try: + cmdline = CommandLine(executable, args, input=input_file, + cwd=dir_) + log_elem = xmlio.Fragment() + for out, err in cmdline.execute(): + if out is not None: + log.info(out) + info = filter_(out) + if info: + log_elem.append(xmlio.Element('message', level='info')[ + info.replace(ctxt.basedir + os.sep, '') + .replace(ctxt.basedir, '') + ]) + if output: + output_file.write(out + os.linesep) + if err is not None: + log.error(err) + log_elem.append(xmlio.Element('message', level='error')[ + err.replace(ctxt.basedir + os.sep, '') + .replace(ctxt.basedir, '') + ]) + if output: + output_file.write(err + os.linesep) + ctxt.log(log_elem) + finally: + if input_: + input_file.close() + if output: + output_file.close() + + return cmdline.returncode diff --git a/trac-0.11/bitten/build/svntools.py b/trac-0.11/bitten/build/svntools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/svntools.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Recipe commands for Subversion.""" + +import logging +import posixpath +import re + +log = logging.getLogger('bitten.build.svntools') + +__docformat__ = 'restructuredtext en' + +def checkout(ctxt, url, path=None, revision=None, dir_='.', verbose=False): + """Perform a checkout from a Subversion repository. + + :param ctxt: the build context + :type ctxt: `Context` + :param url: the URL of the repository + :param path: the path inside the repository + :param revision: the revision to check out + :param dir_: the name of a local subdirectory to check out into + :param verbose: whether to log the list of checked out files + """ + args = ['checkout'] + if revision: + args += ['-r', revision] + if path: + url = posixpath.join(url, path.lstrip('/')) + args += [url, dir_] + + cofilter = None + if not verbose: + cre = re.compile(r'^[AU]\s.*$') + cofilter = lambda s: cre.sub('', s) + from bitten.build import shtools + returncode = shtools.execute(ctxt, file_='svn', args=args, + filter_=cofilter) + if returncode != 0: + ctxt.error('svn checkout failed (%s)' % returncode) + +def export(ctxt, url, path=None, revision=None, dir_='.'): + """Perform an export from a Subversion repository. + + :param ctxt: the build context + :type ctxt: `Context` + :param url: the URL of the repository + :param path: the path inside the repository + :param revision: the revision to check out + :param dir_: the name of a local subdirectory to export out into + """ + args = ['export', '--force'] + if revision: + args += ['-r', revision] + if path: + url = posixpath.join(url, path) + args += [url, dir_] + + from bitten.build import shtools + returncode = shtools.execute(ctxt, file_='svn', args=args) + if returncode != 0: + ctxt.error('svn export failed (%s)' % returncode) + +def update(ctxt, revision=None, dir_='.'): + """Update the local working copy from the Subversion repository. + + :param ctxt: the build context + :type ctxt: `Context` + :param revision: the revision to check out + :param dir_: the name of a local subdirectory containing the working copy + """ + args = ['update'] + if revision: + args += ['-r', revision] + args += [dir_] + + from bitten.build import shtools + returncode = shtools.execute(ctxt, file_='svn', args=args) + if returncode != 0: + ctxt.error('svn update failed (%s)' % returncode) diff --git a/trac-0.11/bitten/build/tests/__init__.py b/trac-0.11/bitten/build/tests/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import unittest + +from bitten.build.tests import api, config, ctools, phptools, pythontools, \ + xmltools + +def suite(): + suite = unittest.TestSuite() + suite.addTest(api.suite()) + suite.addTest(config.suite()) + suite.addTest(ctools.suite()) + suite.addTest(phptools.suite()) + suite.addTest(pythontools.suite()) + suite.addTest(xmltools.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/api.py b/trac-0.11/bitten/build/tests/api.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/api.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import shutil +import sys +import tempfile +import unittest + +from bitten.build import CommandLine, FileSet, TimeoutError +from bitten.build.api import _combine + + +class CommandLineTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp(suffix='bitten_test')) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, name, content=None): + filename = os.path.join(self.basedir, name) + fd = file(filename, 'w') + if content: + fd.write(content) + fd.close() + return filename + + def test_extract_lines(self): + cmdline = CommandLine('test', []) + data = ['foo\n', 'bar\n'] + lines = cmdline._extract_lines(data) + self.assertEqual(['foo', 'bar'], lines) + self.assertEqual([], data) + + def test_extract_lines_spanned(self): + cmdline = CommandLine('test', []) + data = ['foo ', 'bar\n'] + lines = cmdline._extract_lines(data) + self.assertEqual(['foo bar'], lines) + self.assertEqual([], data) + + def test_extract_lines_trailing(self): + cmdline = CommandLine('test', []) + data = ['foo\n', 'bar'] + lines = cmdline._extract_lines(data) + self.assertEqual(['foo'], lines) + self.assertEqual(['bar'], data) + + def test_combine(self): + list1 = ['foo', 'bar'] + list2 = ['baz'] + combined = list(_combine(list1, list2)) + self.assertEqual([('foo', 'baz'), ('bar', None)], combined) + + def test_single_argument(self): + cmdline = CommandLine(sys.executable, ['-V']) + stdout = [] + stderr = [] + for out, err in cmdline.execute(timeout=5.0): + if out is not None: + stdout.append(out) + if err is not None: + stderr.append(err) + py_version = '.'.join([str(v) for (v) in sys.version_info[:3]]) + self.assertEqual(['Python %s' % py_version], stderr) + self.assertEqual([], stdout) + self.assertEqual(0, cmdline.returncode) + + def test_multiple_arguments(self): + script_file = self._create_file('test.py', content=""" +import sys +for arg in sys.argv[1:]: + print arg +""") + cmdline = CommandLine('python', [script_file, 'foo', 'bar', 'baz']) + stdout = [] + stderr = [] + for out, err in cmdline.execute(timeout=5.0): + stdout.append(out) + stderr.append(err) + py_version = '.'.join([str(v) for (v) in sys.version_info[:3]]) + self.assertEqual(['foo', 'bar', 'baz'], stdout) + self.assertEqual([None, None, None], stderr) + self.assertEqual(0, cmdline.returncode) + + def test_output_error_streams(self): + script_file = self._create_file('test.py', content=""" +import sys, time +print>>sys.stdout, 'Hello' +print>>sys.stdout, 'world!' +sys.stdout.flush() +time.sleep(.1) +print>>sys.stderr, 'Oops' +sys.stderr.flush() +""") + cmdline = CommandLine('python', [script_file]) + stdout = [] + stderr = [] + for out, err in cmdline.execute(timeout=5.0): + stdout.append(out) + stderr.append(err) + py_version = '.'.join([str(v) for (v) in sys.version_info[:3]]) + # nt doesn't properly split stderr and stdout. See ticket #256. + if os.name != "nt": + self.assertEqual(['Hello', 'world!', None], stdout) + self.assertEqual(0, cmdline.returncode) + + def test_input_stream_as_fileobj(self): + script_file = self._create_file('test.py', content=""" +import sys +data = sys.stdin.read() +if data == 'abcd': + print>>sys.stdout, 'Thanks' +""") + input_file = self._create_file('input.txt', content='abcd') + input_fileobj = file(input_file, 'r') + try: + cmdline = CommandLine('python', [script_file], input=input_fileobj) + stdout = [] + stderr = [] + for out, err in cmdline.execute(timeout=5.0): + stdout.append(out) + stderr.append(err) + py_version = '.'.join([str(v) for (v) in sys.version_info[:3]]) + self.assertEqual(['Thanks'], stdout) + self.assertEqual([None], stderr) + self.assertEqual(0, cmdline.returncode) + finally: + input_fileobj.close() + + def test_input_stream_as_string(self): + script_file = self._create_file('test.py', content=""" +import sys +data = sys.stdin.read() +if data == 'abcd': + print>>sys.stdout, 'Thanks' +""") + cmdline = CommandLine('python', [script_file], input='abcd') + stdout = [] + stderr = [] + for out, err in cmdline.execute(timeout=5.0): + stdout.append(out) + stderr.append(err) + py_version = '.'.join([str(v) for (v) in sys.version_info[:3]]) + self.assertEqual(['Thanks'], stdout) + self.assertEqual([None], stderr) + self.assertEqual(0, cmdline.returncode) + + def test_timeout(self): + script_file = self._create_file('test.py', content=""" +import time +time.sleep(2.0) +print 'Done' +""") + cmdline = CommandLine('python', [script_file]) + iterable = iter(cmdline.execute(timeout=.5)) + if os.name != "nt": + # commandline timeout not implemented on windows. See #257 + self.assertRaises(TimeoutError, iterable.next) + +class FileSetTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp(suffix='bitten_test')) + + def tearDown(self): + shutil.rmtree(self.basedir) + + # Convenience methods + + def _create_dir(self, *path): + cur = self.basedir + for part in path: + cur = os.path.join(cur, part) + os.mkdir(cur) + return cur[len(self.basedir) + 1:] + + def _create_file(self, *path): + filename = os.path.join(self.basedir, *path) + fd = file(filename, 'w') + fd.close() + return filename[len(self.basedir) + 1:] + + # Test methods + + def test_empty(self): + fileset = FileSet(self.basedir) + self.assertRaises(StopIteration, iter(fileset).next) + + def test_top_level_files(self): + foo_txt = self._create_file('foo.txt') + bar_txt = self._create_file('bar.txt') + fileset = FileSet(self.basedir) + assert foo_txt in fileset and bar_txt in fileset + + def test_files_in_subdir(self): + self._create_dir('tests') + foo_txt = self._create_file('tests', 'foo.txt') + bar_txt = self._create_file('tests', 'bar.txt') + fileset = FileSet(self.basedir) + assert foo_txt in fileset and bar_txt in fileset + + def test_files_in_subdir_with_include(self): + self._create_dir('tests') + foo_txt = self._create_file('tests', 'foo.txt') + bar_txt = self._create_file('tests', 'bar.txt') + fileset = FileSet(self.basedir, include='tests/*.txt') + assert foo_txt in fileset and bar_txt in fileset + + def test_files_in_subdir_with_exclude(self): + self._create_dir('tests') + foo_txt = self._create_file('tests', 'foo.txt') + bar_txt = self._create_file('tests', 'bar.txt') + fileset = FileSet(self.basedir, include='tests/*.txt', exclude='bar.*') + assert foo_txt in fileset and bar_txt not in fileset + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(CommandLineTestCase, 'test')) + suite.addTest(unittest.makeSuite(FileSetTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/config.py b/trac-0.11/bitten/build/tests/config.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/config.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import platform +import os +import tempfile +import unittest + +from bitten.build.config import Configuration + + +class ConfigurationTestCase(unittest.TestCase): + + def test_sysinfo_defaults(self): + config = Configuration() + + self.assertEqual(platform.machine(), config['machine']) + self.assertEqual(platform.processor(), config['processor']) + system, release, version = platform.system_alias(platform.system(), + platform.release(), + platform.version()) + self.assertEqual(system, config['os']) + self.assertEqual(os.name, config['family']) + self.assertEqual(release, config['version']) + + def test_sysinfo_properties_override(self): + config = Configuration(properties={ + 'machine': 'MACHINE', + 'processor': 'PROCESSOR', + 'os': 'OS', + 'family': 'FAMILY', + 'version': 'VERSION' + }) + self.assertEqual('MACHINE', config['machine']) + self.assertEqual('PROCESSOR', config['processor']) + self.assertEqual('OS', config['os']) + self.assertEqual('FAMILY', config['family']) + self.assertEqual('VERSION', config['version']) + + def test_sysinfo_configfile_override(self): + inifd, ininame = tempfile.mkstemp(prefix='bitten_test') + try: + os.write(inifd, """ +[machine] +name = MACHINE +processor = PROCESSOR + +[os] +name = OS +family = FAMILY +version = VERSION +""") + os.close(inifd) + config = Configuration(ininame) + + self.assertEqual('MACHINE', config['machine']) + self.assertEqual('PROCESSOR', config['processor']) + self.assertEqual('OS', config['os']) + self.assertEqual('FAMILY', config['family']) + self.assertEqual('VERSION', config['version']) + finally: + os.remove(ininame) + + def test_package_properties(self): + config = Configuration(properties={ + 'python.version': '2.3.5', + 'python.path': '/usr/local/bin/python2.3' + }) + self.assertEqual(True, 'python' in config.packages) + self.assertEqual('/usr/local/bin/python2.3', config['python.path']) + self.assertEqual('2.3.5', config['python.version']) + + def test_package_configfile(self): + inifd, ininame = tempfile.mkstemp(prefix='bitten_test') + try: + os.write(inifd, """ +[python] +path = /usr/local/bin/python2.3 +version = 2.3.5 +""") + os.close(inifd) + config = Configuration(ininame) + + self.assertEqual(True, 'python' in config.packages) + self.assertEqual('/usr/local/bin/python2.3', config['python.path']) + self.assertEqual('2.3.5', config['python.version']) + finally: + os.remove(ininame) + + def test_get_dirpath_non_existant(self): + tempdir = tempfile.mkdtemp() + os.rmdir(tempdir) + config = Configuration(properties={'somepkg.home': tempdir}) + self.assertEqual(None, config.get_dirpath('somepkg.home')) + + def test_get_dirpath(self): + tempdir = tempfile.mkdtemp() + try: + config = Configuration(properties={'somepkg.home': tempdir}) + self.assertEqual(tempdir, config.get_dirpath('somepkg.home')) + finally: + os.rmdir(tempdir) + + def test_get_filepath_non_existant(self): + testfile, testname = tempfile.mkstemp(prefix='bitten_test') + os.close(testfile) + os.remove(testname) + config = Configuration(properties={'somepkg.path': testname}) + self.assertEqual(None, config.get_filepath('somepkg.path')) + + def test_get_filepath(self): + testfile = tempfile.NamedTemporaryFile(prefix='bitten_test') + config = Configuration(properties={'somepkg.path': testfile.name}) + self.assertEqual(testfile.name, config.get_filepath('somepkg.path')) + + def test_interpolate(self): + config = Configuration(properties={ + 'python.version': '2.3.5', + 'python.path': '/usr/local/bin/python2.3' + }) + self.assertEqual('/usr/local/bin/python2.3', + config.interpolate('${python.path}')) + self.assertEqual('foo /usr/local/bin/python2.3 bar', + config.interpolate('foo ${python.path} bar')) + + def test_interpolate_default(self): + config = Configuration() + self.assertEqual('python2.3', + config.interpolate('${python.path:python2.3}')) + self.assertEqual('foo python2.3 bar', + config.interpolate('foo ${python.path:python2.3} bar')) + + def test_interpolate_missing(self): + config = Configuration() + self.assertEqual('${python.path}', + config.interpolate('${python.path}')) + self.assertEqual('foo ${python.path} bar', + config.interpolate('foo ${python.path} bar')) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(ConfigurationTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/ctools.py b/trac-0.11/bitten/build/tests/ctools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/ctools.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import shutil +import tempfile +import unittest + +from bitten.build import ctools +from bitten.build.tests import dummy +from bitten.recipe import Context, Recipe + + +class CppUnitTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def test_missing_param_file(self): + self.assertRaises(AssertionError, ctools.cppunit, self.ctxt) + + def test_empty_summary(self): + cppunit_xml = file(self.ctxt.resolve('cppunit.xml'), 'w') + cppunit_xml.write(""" + + + + HelloTest::secondTest + Assertion + + HelloTest.cxx + 95 + + assertion failed +- Expression: 2 == 3 + + + + + + HelloTest::firstTest + + + HelloTest::thirdTest + + + + 3 + 1 + 0 + 1 + +""") + cppunit_xml.close() + ctools.cppunit(self.ctxt, file_='cppunit.xml') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('test', category) + + tests = list(xml.children) + self.assertEqual(3, len(tests)) + self.assertEqual('HelloTest', tests[0].attr['fixture']) + self.assertEqual('secondTest', tests[0].attr['name']) + self.assertEqual('failure', tests[0].attr['status']) + self.assertEqual('HelloTest.cxx', tests[0].attr['file']) + self.assertEqual('95', tests[0].attr['line']) + + self.assertEqual('HelloTest', tests[1].attr['fixture']) + self.assertEqual('firstTest', tests[1].attr['name']) + self.assertEqual('success', tests[1].attr['status']) + + self.assertEqual('HelloTest', tests[2].attr['fixture']) + self.assertEqual('thirdTest', tests[2].attr['name']) + self.assertEqual('success', tests[2].attr['status']) + + +class GCovTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, *path): + filename = os.path.join(self.basedir, *path) + dirname = os.path.dirname(filename) + if not os.path.isdir(dirname): + os.makedirs(dirname) + fd = file(filename, 'w') + fd.close() + return filename[len(self.basedir) + 1:] + + def test_no_file(self): + ctools.CommandLine = dummy.CommandLine() + ctools.gcov(self.ctxt) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('log', type) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('report', type) + self.assertEqual('coverage', category) + self.assertEqual(0, len(xml.children)) + + def test_single_file(self): + self._create_file('foo.c') + self._create_file('foo.o') + self._create_file('foo.gcno') + self._create_file('foo.gcda') + + ctools.CommandLine = dummy.CommandLine(stdout=""" +File `foo.c' +Lines executed:45.81% of 884 +Branches executed:54.27% of 398 +Taken at least once:36.68% of 398 +Calls executed:48.19% of 249 + +File `foo.h' +Lines executed:50.00% of 4 +No branches +Calls executed:100.00% of 1 +""") + ctools.gcov(self.ctxt) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('log', type) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('report', type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + elem = xml.children[0] + self.assertEqual('coverage', elem.name) + self.assertEqual('foo.c', elem.attr['file']) + self.assertEqual('foo.c', elem.attr['name']) + self.assertEqual(888, elem.attr['lines']) + self.assertEqual(45, elem.attr['percentage']) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(CppUnitTestCase, 'test')) + suite.addTest(unittest.makeSuite(GCovTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/dummy.py b/trac-0.11/bitten/build/tests/dummy.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/dummy.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +from StringIO import StringIO + +from bitten.build import api + + +class CommandLine(api.CommandLine): + + def __init__(self, returncode=0, stdout='', stderr=''): + self.returncode = returncode + self.stdout = StringIO(stdout) + self.stderr = StringIO(stderr) + + def __call__(self, executable, args, input=None, cwd=None): + return self + + def execute(self): + return api._combine(self.stdout.readlines(), self.stderr.readlines()) diff --git a/trac-0.11/bitten/build/tests/javatools.py b/trac-0.11/bitten/build/tests/javatools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/javatools.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2006 Matthew Good +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os.path +import shutil +import tempfile +import unittest + +from bitten.build import javatools +from bitten.recipe import Context + +class CoberturaTestCase(unittest.TestCase): + xml_template=""" + + + + + src + + + + %s + + + +""" + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, *path, **kw): + filename = os.path.join(self.basedir, *path) + dirname = os.path.dirname(filename) + if not os.path.exists(dirname): + os.makedirs(dirname) + fd = file(filename, 'w') + content = kw.get('content') + if content is not None: + fd.write(content) + fd.close() + return filename[len(self.basedir) + 1:] + + def test_basic(self): + filename = self._create_file('coverage.xml', content=self.xml_template % """ + + + + + + + + """) + javatools.cobertura(self.ctxt, file_=filename) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('report', type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + + elem = xml.children[0] + self.assertEqual('coverage', elem.name) + self.assertEqual('src/test/TestClass.java', elem.attr['file']) + self.assertEqual('test.TestClass', elem.attr['name']) + self.assertEqual(4, elem.attr['lines']) + self.assertEqual(50, elem.attr['percentage']) + + def test_skipped_lines(self): + filename = self._create_file('coverage.xml', content=self.xml_template % """ + + + + + + """) + javatools.cobertura(self.ctxt, file_=filename) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('report', type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + + elem = xml.children[0] + self.assertEqual('coverage', elem.name) + self.assertEqual('src/test/TestClass.java', elem.attr['file']) + self.assertEqual('test.TestClass', elem.attr['name']) + self.assertEqual(2, elem.attr['lines']) + self.assertEqual(50, elem.attr['percentage']) + + line_hits = elem.children[0] + self.assertEqual('line_hits', line_hits.name) + self.assertEqual('0 - 1', line_hits.children[0]) + + def test_interface(self): + filename = self._create_file('coverage.xml', content=self.xml_template % """ + + + + """) + javatools.cobertura(self.ctxt, file_=filename) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual('report', type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + + elem = xml.children[0] + self.assertEqual('coverage', elem.name) + self.assertEqual('src/test/TestInterface.java', elem.attr['file']) + self.assertEqual('test.TestInterface', elem.attr['name']) + self.assertEqual(0, elem.attr['lines']) + self.assertEqual(0, elem.attr['percentage']) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(CoberturaTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/phptools.py b/trac-0.11/bitten/build/tests/phptools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/phptools.py @@ -0,0 +1,131 @@ +# -*- coding: UTF-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2007 Wei Zhuo +# 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 tempfile +import unittest + +from bitten.build import phptools +from bitten.recipe import Context, Recipe + +class PhpUnitTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def test_missing_param_file(self): + self.assertRaises(AssertionError, phptools.phpunit, self.ctxt) + + def test_sample_unit_test_result(self): + phpunit_xml = file(self.ctxt.resolve('phpunit.xml'), 'w') + phpunit_xml.write(""" + + + + + ... + + + + + + + +""") + phpunit_xml.close() + phptools.phpunit(self.ctxt, file_='phpunit.xml') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('test', category) + + tests = list(xml.children) + self.assertEqual(3, len(tests)) + self.assertEqual('FooTest', tests[0].attr['fixture']) + self.assertEqual('testBar', tests[0].attr['name']) + self.assertEqual('failure', tests[0].attr['status']) + self.assert_('FooTest.php' in tests[0].attr['file']) + + self.assertEqual('FooTest', tests[1].attr['fixture']) + self.assertEqual('testBar2', tests[1].attr['name']) + self.assertEqual('success', tests[1].attr['status']) + + self.assertEqual('BarTest', tests[2].attr['fixture']) + self.assertEqual('testFoo', tests[2].attr['name']) + self.assertEqual('success', tests[2].attr['status']) + +class PhpCodeCoverageTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def test_missing_param_file(self): + self.assertRaises(AssertionError, phptools.coverage, self.ctxt) + + def test_sample_code_coverage(self): + coverage_xml = file(self.ctxt.resolve('phpcoverage.xml'), 'w') + coverage_xml.write(""" + + + + + ... + + + + + ... + + + + + ... + + + +""") + coverage_xml.close() + phptools.coverage(self.ctxt, file_='phpcoverage.xml') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + + coverage = list(xml.children) + self.assertEqual(3, len(coverage)) + self.assertEqual(7, coverage[0].attr['lines']) + self.assertEqual('Foo', coverage[0].attr['name']) + self.assert_('xxxx/Foo.php' in coverage[0].attr['file']) + + self.assertEqual(4, coverage[1].attr['lines']) + self.assertEqual(50.0, coverage[1].attr['percentage']) + self.assertEqual('Foo2', coverage[1].attr['name']) + self.assert_('xxxx/Foo.php' in coverage[1].attr['file']) + + self.assertEqual(0, coverage[2].attr['lines']) + self.assertEqual(100.0, coverage[2].attr['percentage']) + self.assertEqual('Bar', coverage[2].attr['name']) + self.assert_('xxxx/Bar.php' in coverage[2].attr['file']) + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(PhpUnitTestCase, 'test')) + suite.addTest(unittest.makeSuite(PhpCodeCoverageTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/pythontools.py b/trac-0.11/bitten/build/tests/pythontools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/pythontools.py @@ -0,0 +1,413 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2008 Matt Good +# Copyright (C) 2008 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import cPickle as pickle +import shutil +import tempfile +import unittest + +from bitten.build import pythontools +from bitten.build import FileSet +from bitten.recipe import Context, Recipe + + +class CoverageTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + self.summary = open(os.path.join(self.basedir, 'test-coverage.txt'), + 'w') + self.coverdir = os.path.join(self.basedir, 'coverage') + os.mkdir(self.coverdir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, *path): + filename = os.path.join(self.basedir, *path) + dirname = os.path.dirname(filename) + os.makedirs(dirname) + fd = file(filename, 'w') + fd.close() + return filename[len(self.basedir) + 1:] + + def test_missing_param_summary(self): + self.summary.close() + self.assertRaises(AssertionError, pythontools.coverage, self.ctxt, + coverdir=self.coverdir) + + def test_empty_summary(self): + self.summary.write(""" +Name Stmts Exec Cover Missing +------------------------------------------- +""") + self.summary.close() + pythontools.coverage(self.ctxt, summary=self.summary.name, include='*.py') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(0, len(xml.children)) + + def test_summary_with_absolute_path(self): + self.summary.write(""" +Name Stmts Exec Cover Missing +------------------------------------------- +test.module 60 60 100%% %s/test/module.py +""" % self.ctxt.basedir) + self.summary.close() + self._create_file('test', 'module.py') + pythontools.coverage(self.ctxt, summary=self.summary.name, + include='test/*') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual('test/module.py', child.attr['file']) + self.assertEqual(100, child.attr['percentage']) + self.assertEqual(60, child.attr['lines']) + + def test_summary_with_relative_path(self): + self.summary.write(""" +Name Stmts Exec Cover Missing +------------------------------------------- +test.module 60 60 100% ./test/module.py +""") + self.summary.close() + self._create_file('test', 'module.py') + pythontools.coverage(self.ctxt, summary=self.summary.name, + include='test/*') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual('test/module.py', child.attr['file']) + self.assertEqual(100, child.attr['percentage']) + self.assertEqual(60, child.attr['lines']) + + def test_summary_with_missing_lines(self): + self.summary.write(""" +Name Stmts Exec Cover Missing +------------------------------------------- +test.module 28 26 92% 13-14 ./test/module.py +""") + self.summary.close() + self._create_file('test', 'module.py') + pythontools.coverage(self.ctxt, summary=self.summary.name, + include='test/*') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual('test/module.py', child.attr['file']) + self.assertEqual(92, child.attr['percentage']) + self.assertEqual(28, child.attr['lines']) + + +class TraceTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + self.summary = open(os.path.join(self.basedir, 'test-coverage.txt'), + 'w') + self.coverdir = os.path.join(self.basedir, 'coverage') + os.mkdir(self.coverdir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, *path): + filename = os.path.join(self.basedir, *path) + dirname = os.path.dirname(filename) + os.makedirs(dirname) + fd = file(filename, 'w') + fd.close() + return filename[len(self.basedir) + 1:] + + def test_missing_param_summary(self): + self.summary.close() + self.assertRaises(AssertionError, pythontools.trace, self.ctxt, + coverdir='coverage') + + def test_missing_param_coverdir(self): + self.summary.close() + self.assertRaises(AssertionError, pythontools.trace, self.ctxt, + summary='test-coverage.txt') + + def test_empty_summary(self): + self.summary.write('line cov% module (path)') + self.summary.close() + pythontools.trace(self.ctxt, summary=self.summary.name, include='*.py', + coverdir=self.coverdir) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(0, len(xml.children)) + + def test_summary_with_absolute_path(self): + self.summary.write(""" +lines cov%% module (path) + 60 100%% test.module (%s/test/module.py) +""" % self.ctxt.basedir) + self.summary.close() + self._create_file('test', 'module.py') + pythontools.trace(self.ctxt, summary=self.summary.name, + include='test/*', coverdir=self.coverdir) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual('test/module.py', child.attr['file']) + + def test_summary_with_relative_path(self): + self.summary.write(""" +lines cov% module (path) + 60 100% test.module (./test/module.py) +""") + self.summary.close() + self._create_file('test', 'module.py') + pythontools.trace(self.ctxt, summary=self.summary.name, + include='test/*', coverdir=self.coverdir) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual('test/module.py', child.attr['file']) + + +class FigleafTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + self.summary = open(os.path.join(self.basedir, '.figleaf'), 'w') + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, *path): + filename = os.path.join(self.basedir, *path) + dirname = os.path.dirname(filename) + os.makedirs(dirname) + fd = file(filename, 'w') + fd.close() + return filename[len(self.basedir) + 1:] + + def test_missing_param_summary(self): + self.summary.close() + self.assertRaises(AssertionError, pythontools.coverage, self.ctxt) + + def test_empty_summary(self): + pickle.dump({}, self.summary) + self.summary.close() + pythontools.figleaf(self.ctxt, summary=self.summary.name, include='*.py') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(0, len(xml.children)) + + def test_missing_coverage_file(self): + self.summary.close() + pythontools.figleaf(self.ctxt, summary='non-existant-file', include='*.py') + self.assertEqual([], self.ctxt.output) + + def test_summary_with_absolute_path(self): + filename = os.sep.join([self.ctxt.basedir, 'test', 'module.py']) + pickle.dump({ + filename: set([1, 4, 5]), + }, self.summary) + self.summary.close() + sourcefile = self.ctxt.resolve(self._create_file('test', 'module.py')) + open(sourcefile, 'w').write( + "if foo: # line 1\n" + " print 'uncovered' # line 2\n" + "else: # line 3 (uninteresting)\n" + " print 'covered' # line 4\n" + "print 'covered' # line 6\n" + ) + pythontools.figleaf(self.ctxt, summary=self.summary.name, + include='test/*') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual(os.path.join('test', 'module.py'), child.attr['file']) + self.assertEqual(75, child.attr['percentage']) + self.assertEqual(4, child.attr['lines']) + self.assertEqual('1 0 - 1 1', child.attr['line_hits']) + + def test_summary_with_non_covered_file(self): + pickle.dump({}, self.summary) + self.summary.close() + sourcefile = self.ctxt.resolve(self._create_file('test', 'module.py')) + open(sourcefile, 'w').write( + "print 'line 1'\n" + "print 'line 2'\n" + "print 'line 3'\n" + "print 'line 4'\n" + "print 'line 5'\n" + ) + pythontools.figleaf(self.ctxt, summary=self.summary.name, + include='test/*') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(1, len(xml.children)) + child = xml.children[0] + self.assertEqual('coverage', child.name) + self.assertEqual('test.module', child.attr['name']) + self.assertEqual(os.path.join('test', 'module.py'), child.attr['file']) + self.assertEqual(0, child.attr['percentage']) + self.assertEqual(5, child.attr['lines']) + + def test_summary_with_non_python_files(self): + "Figleaf coverage reports should not include files that do not end in .py" + pickle.dump({}, self.summary) + self.summary.close() + sourcefile = self.ctxt.resolve(self._create_file('test', 'document.txt')) + open(sourcefile, 'w').write("\n") + pythontools.figleaf(self.ctxt, summary=self.summary.name, + include='test/*') + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(Recipe.REPORT, type) + self.assertEqual('coverage', category) + self.assertEqual(0, len(xml.children)) + + +class FilenameNormalizationTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def _create_file(self, *path): + filename = os.path.join(self.basedir, *path) + dirname = os.path.dirname(filename) + os.makedirs(dirname) + fd = file(filename, 'w') + fd.close() + return filename[len(self.basedir) + 1:] + + def test_absolute_path(self): + filename = os.sep.join([self.ctxt.basedir, 'test', 'module.py']) + self._create_file('test', 'module.py') + filenames = pythontools._normalize_filenames( + self.ctxt, [filename], + FileSet(self.ctxt.basedir, '**/*.py', None)) + self.assertEqual(['test/module.py'], list(filenames)) + + +class UnittestTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + self.results_xml = open(os.path.join(self.basedir, 'test-results.xml'), + 'w') + + def tearDown(self): + shutil.rmtree(self.basedir) + + def test_missing_file_param(self): + self.results_xml.close() + self.assertRaises(AssertionError, pythontools.unittest, self.ctxt) + + def test_empty_results(self): + self.results_xml.write('' + '' + '') + self.results_xml.close() + pythontools.unittest(self.ctxt, self.results_xml.name) + 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): + self.results_xml.write('' + '' + '' + '' + % os.path.join(self.ctxt.basedir, 'bar_test.py')) + self.results_xml.close() + pythontools.unittest(self.ctxt, self.results_xml.name) + 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) + self.assertEqual('0.12', test_elem.attr['duration']) + self.assertEqual('success', test_elem.attr['status']) + self.assertEqual('bar_test.py', test_elem.attr['file']) + self.assertEqual('test_foo (pkg.BarTestCase)', test_elem.attr['name']) + + def test_file_path_normalization(self): + self.results_xml.write('' + '' + '' + '' + % os.path.join(self.ctxt.basedir, 'bar_test.py')) + self.results_xml.close() + pythontools.unittest(self.ctxt, self.results_xml.name) + 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): + self.results_xml.write('' + '' + '' + '') + self.results_xml.close() + pythontools.unittest(self.ctxt, self.results_xml.name) + type, category, generator, xml = self.ctxt.output.pop() + self.assertEqual(1, len(xml.children)) + self.assertEqual(None, xml.children[0].attr.get('file')) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(CoverageTestCase, 'test')) + suite.addTest(unittest.makeSuite(TraceTestCase, 'test')) + suite.addTest(unittest.makeSuite(FigleafTestCase, 'test')) + suite.addTest(unittest.makeSuite(FilenameNormalizationTestCase, 'test')) + suite.addTest(unittest.makeSuite(UnittestTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/tests/xmltools.py b/trac-0.11/bitten/build/tests/xmltools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/tests/xmltools.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import shutil +import tempfile +import unittest + +from bitten.build import xmltools +from bitten.recipe import Context +from bitten.util import xmlio + + +class TransformTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + self.ctxt = Context(self.basedir) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def test_transform_no_src(self): + self.assertRaises(AssertionError, xmltools.transform, self.ctxt) + + def test_transform_no_dest(self): + self.assertRaises(AssertionError, xmltools.transform, self.ctxt, + src='src.xml') + + def test_transform_no_stylesheet(self): + self.assertRaises(AssertionError, xmltools.transform, self.ctxt, + src='src.xml', dest='dest.xml') + + def test_transform(self): + src_file = file(self.ctxt.resolve('src.xml'), 'w') + try: + src_file.write(""" +Document Title +
+Section Title +This is a test. +This is a note. +
+
+""") + finally: + src_file.close() + + style_file = file(self.ctxt.resolve('style.xsl'), 'w') + try: + style_file.write(""" + + + + <xsl:value-of select="title"/> + + + + + + + +

+
+ +

+
+ +

+
+ +

NOTE:

+
+
+""") + finally: + style_file.close() + + xmltools.transform(self.ctxt, src='src.xml', dest='dest.xml', + stylesheet='style.xsl') + + dest_file = file(self.ctxt.resolve('dest.xml')) + try: + dest = xmlio.parse(dest_file) + finally: + dest_file.close() + + self.assertEqual('html', dest.name) + self.assertEqual('http://www.w3.org/TR/xhtml1/strict', dest.namespace) + children = list(dest.children()) + self.assertEqual(2, len(children)) + self.assertEqual('head', children[0].name) + head_children = list(children[0].children()) + self.assertEqual(1, len(head_children)) + self.assertEqual('title', head_children[0].name) + self.assertEqual('Document Title', head_children[0].gettext()) + self.assertEqual('body', children[1].name) + body_children = list(children[1].children()) + self.assertEqual(4, len(body_children)) + self.assertEqual('h1', body_children[0].name) + self.assertEqual('Document Title', body_children[0].gettext()) + self.assertEqual('h2', body_children[1].name) + self.assertEqual('Section Title', body_children[1].gettext()) + self.assertEqual('p', body_children[2].name) + self.assertEqual('This is a test.', body_children[2].gettext()) + self.assertEqual('p', body_children[3].name) + self.assertEqual('note', body_children[3].attr['class']) + self.assertEqual('This is a note.', body_children[3].gettext()) + + +def suite(): + suite = unittest.TestSuite() + if xmltools.have_libxslt or xmltools.have_msxml: + suite.addTest(unittest.makeSuite(TransformTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/build/xmltools.py b/trac-0.11/bitten/build/xmltools.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/build/xmltools.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Recipe commands for XML processing.""" + +import logging +import os + +try: + import libxml2 + import libxslt + have_libxslt = True +except ImportError: + have_libxslt = False + +if not have_libxslt and os.name == 'nt': + try: + import win32com.client + have_msxml = True + except ImportError: + have_msxml = False +else: + have_msxml = False + +log = logging.getLogger('bitten.build.xmltools') + +__docformat__ = 'restructuredtext en' + +def transform(ctxt, src=None, dest=None, stylesheet=None): + """Apply an XSLT stylesheet to a source XML document. + + This command requires either libxslt (with Python bindings), or MSXML to + be installed. + + :param ctxt: the build context + :type ctxt: `Context` + :param src: name of the XML input file + :param dest: name of the XML output file + :param stylesheet: name of the file containing the XSLT stylesheet + """ + assert src, 'Missing required attribute "src"' + assert dest, 'Missing required attribute "dest"' + assert stylesheet, 'Missing required attribute "stylesheet"' + + if have_libxslt: + log.debug('Using libxslt for XSLT transformation') + srcdoc, styledoc, result = None, None, None + try: + srcdoc = libxml2.parseFile(ctxt.resolve(src)) + styledoc = libxslt.parseStylesheetFile(ctxt.resolve(stylesheet)) + result = styledoc.applyStylesheet(srcdoc, None) + styledoc.saveResultToFilename(ctxt.resolve(dest), result, 0) + finally: + if styledoc: + styledoc.freeStylesheet() + if srcdoc: + srcdoc.freeDoc() + if result: + result.freeDoc() + + elif have_msxml: + log.debug('Using MSXML for XSLT transformation') + srcdoc = win32com.client.Dispatch('MSXML2.DOMDocument.3.0') + if not srcdoc.load(ctxt.resolve(src)): + err = styledoc.parseError + ctxt.error('Failed to parse XML source %s: %s', src, err.reason) + return + styledoc = win32com.client.Dispatch('MSXML2.DOMDocument.3.0') + if not styledoc.load(ctxt.resolve(stylesheet)): + err = styledoc.parseError + ctxt.error('Failed to parse XSLT stylesheet %s: %s', stylesheet, + err.reason) + return + result = srcdoc.transformNode(styledoc) + + # MSXML seems to always write produce the resulting XML document using + # UTF-16 encoding, regardless of the encoding specified in the + # stylesheet. For better interoperability, recode to UTF-8 here. + result = result.encode('utf-8').replace(' encoding="UTF-16"?>', '?>') + + dest_file = file(ctxt.resolve(dest), 'w') + try: + dest_file.write(result) + finally: + dest_file.close() + + else: + ctxt.error('No usable XSLT implementation found') + + # TODO: as a last resort, try to invoke 'xsltproc' to do the + # transformation? diff --git a/trac-0.11/bitten/htdocs/admin.css b/trac-0.11/bitten/htdocs/admin.css new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/htdocs/admin.css @@ -0,0 +1,6 @@ +table.form th { text-align: right; } +div.platforms h3 { margin-top: 3em; } +table#platformlist td ul { list-style: none; margin: 0; padding: 0; } + +dl.help { color: #666; font-size: 90%; margin: 1em .5em; } +dl.help dt { font-weight: bold; } diff --git a/trac-0.11/bitten/htdocs/bitten.css b/trac-0.11/bitten/htdocs/bitten.css new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/htdocs/bitten.css @@ -0,0 +1,183 @@ +/* Timeline styles */ +#content.timeline dt.successbuild, #content.timeline dt.successbuild a { + background-image: url(bitten_build.png) !important; +} +#content.timeline dt.failedbuild, #content.timeline dt.failedbuild a { + background-image: url(bitten_buildf.png) !important; +} + +#content.build h2.config, #content.build h2.step { background: #f7f7f7; + border-bottom: 1px solid #d7d7d7; margin: 2em 0 0; +} +#content.build h2.config :link, #content.build h2.config :visited { + color: #b00; display: block; border-bottom: none; +} +#content.build h2.deactivated { text-decoration: line-through; } +#content.build #prefs { line-height: 1.4em; } + +#content.build h3.builds { font-weight: bold; text-align: left; + margin: 2em 0 0 2em; +} +#content.build table.builds { border-collapse: separate; + border-top: 1px solid #666; margin-left: 2em; table-layout: fixed; +} +#content.build table.builds th { padding: 0 1em 0 .25em; text-align: left; + vertical-align: top; +} +#content.build table.builds th p { color: #666; font-size: smaller; + margin-top: 0; +} +#content.build table.builds th p.message { font-style: italic; } +#content.build table.builds td { color: #999; border: 1px solid; + padding: .25em .5em; vertical-align: top; +} +#content.build table.builds td :link, #content.build table.builds td :visited { + font-weight: bold; +} +#content.build table.builds td.completed { background: #9d9; border-color: #696; + color: #393; +} +#content.build table.builds td.failed { background: #d99; border-color: #966; + color: #933; +} +#content.build table.builds td.in-progress { background: #dd9; + border-color: #996; color: #993; +} +#content.build table.builds td p { font-size: smaller; margin-top: 0; } +#content.build table.builds .status { color: #000; } +#content.build table.builds .system { font-size: smaller; line-height: 1.2em; + margin: .5em 0; +} + +#content.build form.config { margin-top: 1em; } +#content.build form.config th { text-align: left; } +#content.build form.config fieldset { margin-bottom: 1em; } +#content.build div.platforms { margin-top: 2em; } +#content.build form.platforms ul { list-style-type: none; padding-left: 1em; } + +#content.build p.path { color: #999; font-size: smaller; margin-top: 0; } + +#content.build #charts { clear: right; float: right; width: 44%; } + +#content.build #builds { clear: none; margin-top: 2em; table-layout: fixed; + width: 54%; +} +#content.build #builds tbody th, #content.build #builds tbody td { + background: #fff; +} +#content.build #builds th.chgset { width: 6em; } +#content.build #builds td :link, #content.build #builds td :visited { + font-weight: bold; +} +#content.build #builds tbody td { background-position: 2px .5em; + background-repeat: no-repeat; +} +#content.build #builds td.completed { + background-color: #e8f6e8; background-image: url(bitten_build.png); +} +#content.build #builds td.failed { + background-color: #fbe8e7; background-image: url(bitten_buildf.png); +} +#content.build #builds td.in-progress { + background-color: #f6fae0; background-image: url(bitten_build.png); +} +#content.build #builds .info { margin-left: 16px; } +#content.build #builds :link, #content.build #builds :visited { + text-decoration: none; +} +#content.build #builds .info .status { color: #000; } +#content.build #builds .info .system { color: #999; font-size: smaller; + line-height: 1.2em; margin-top: .5em; +} +#content.build #builds ul.steps { + list-style-type: none; margin: .5em 0 0; padding: 0; +} +#content.build #builds ul.steps li.success, +#content.build #builds ul.steps li.failed { + border: 1px solid; margin: 1px 0; padding: 0 2px 0 12px; +} +#content.build #builds ul.steps li.success { + background: #9d9; border-color: #696; color: #393; +} +#content.build #builds ul.steps li.failed { + background: #d99 url(failure.png) 2px .3em no-repeat; border-color: #966; + color: #933; +} +#content.build #builds ul.steps li :link, +#content.build #builds ul.steps li :visited { border: none; color: inherit; + font-weight: bold; text-decoration: none; +} +#content.build #builds ul.steps li .duration { float: right; + font-size: smaller; +} +#content.build #builds ul.steps li.success .duration { color: #696; } +#content.build #builds ul.steps li.failed .duration { color: #966; } +#content.build #builds ul.steps li.failed ul { font-size: smaller; + line-height: 1.2em; list-style-type: square; margin: 0; + padding: 0 0 .5em 1.5em; +} + +#content.build #overview { line-height: 130%; margin-top: 1em; padding: .5em; } +#content.build #overview dt { font-weight: bold; padding-right: .25em; + position: absolute; left: 0; text-align: right; width: 11.5em; +} +#content.build #overview dd { margin-left: 12em; } +#content.build #overview .slave { margin-top: 1em; } +#content.build #overview .time { margin-top: 1em; } + +#content.build div.errors { background: #d99; border: 1px solid #966; + color: #933; float: right; margin: 1em; +} +#content.build div.errors h3 { background: #966; color: #fff; margin: 0; + padding: 0 .3em; +} +#content.build div.errors ul { list-style-image: url(failure.png); margin: 0; + padding: .5em 1.75em; +} + +#content.build .tabs { clear: right; list-style: none; float: left; width: 100%; + margin: 0 1em; padding: 0; +} +#content.build .tabs li { cursor: pointer; float: left; } +#content.build .tabs li a { background: #b9b9b9; color: #666; display: block; + margin: 2px 2px 0; padding: 3px 2em 0; +} +#content.build .tabs li a:hover { color: #333; text-decoration: none; } +#content.build .tabs li.active a { background: #d7d7d7; border: 1px outset; + border-bottom: none; color: #333; font-weight: bold; margin-top: 0; + padding-bottom: 1px; +} +#content.build .tab-content { background: #f4f4f4; border: 1px outset; + clear: both; margin: 0 2em 0 1em; padding: 5px; +} +#content.build .tab-content table { margin: 0; } + +#content.build tbody.totals td, #content.build tbody.totals th { + font-weight: bold; +} +#content.build table.tests tr.failed { background: #d99; } +#content.build table.tests tr.failed td { font-weight: bold; } +#content.build table.tests tr.failed:hover th, +#content.build table.tests tr.failed:hover td, +#content.build table.tests tr.failed:hover tr { background: #966; } +#content.build table.tests tr.failed :link, +#content.build table.tests tr.failed :visited { color: #933 } +#content.build table.tests tr.failed :link:hover, +#content.build table.tests tr.failed :visited:hover { color: #fff; } + +#content.build .log { background: #fff; border: 1px inset; font-size: 90%; + overflow: auto; max-height: 20em; width: 100%; white-space: pre; +} +#content.build .log code { padding: 0 5px; } +#content.build .log .warning { color: #660; font-weight: bold; } +#content.build .log .error { color: #900; font-weight: bold; } + +#content.build table.listing th, #content.build table.listing td { + font-size: 95%; +} +#content.build table.listing tbody th, #content.build table.listing tbody td { + background: #fff; padding: .1em .3em; +} +#content.build table.listing :link, #content.build table.listing :visited { + border: none; +} diff --git a/trac-0.11/bitten/htdocs/bitten_build.png b/trac-0.11/bitten/htdocs/bitten_build.png new file mode 100755 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..1e2f5669121b121d8119ca1ca05ff66663a41075 GIT binary patch literal 300 zc%17D@N?(olHy`uVBq!ia0vp^JRr=%3?!FCJ6-`&%*9TgAsieWw;%dH0CH6Vd_r9R z|NsB&*|Vd^PoG~B(Kl^QOH0e_8mF|hwBjhusHi9_D=SMa-YEH<-$7=T1o;Is{6_%J zBLQkaan1sd$YKTtZXpn6ymYtj4^U9C#5JNMI6tkVJh3R1!8b9vC_gtfB{NaMEwd=K zJijQrSiwZk;FX$sDNwN(NU?KKYGO%dex5=|W^O8jfw{hcp}v8s@ER``pb8657srr_ zImrnLAwek&Oo7(}7}?m4P0(;<6-c-A$O#MC$vB}aC1N7aBcp=tk_|6qZ|sy!=t^W} ZIP#C{{ok5>vOqH!JYD@<);T3K0RZWzW`O_z diff --git a/trac-0.11/bitten/htdocs/bitten_buildf.png b/trac-0.11/bitten/htdocs/bitten_buildf.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a5b902f0caf9aa53a97b38b3cc0436f483f24d92 GIT binary patch literal 289 zc%17D@N?(olHy`uVBq!ia0vp^JRr=$3?vg*uel1OSkfJR9T^y|-MHc(VFct$mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-$J4)6(ajf#pYj?%Q$;+XkvO0SF^s{Hr?q_Cp^Ygc~wA^4|`2YWZT3Q;AJlGg74b(1J666>B9}O_5 zuAP|#lnnQDaSW-rWpXB3=zxHLOJK{4^e_LyXB!GGJ!LDumLWmuQAOaQw`RtEZ{{7@ zGI8&OYE#Cm5hi<>co>u_*nX^- SuDb?k1%s!npUXO@geCxb9(I)g diff --git a/trac-0.11/bitten/htdocs/bitten_coverage.css b/trac-0.11/bitten/htdocs/bitten_coverage.css new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/htdocs/bitten_coverage.css @@ -0,0 +1,4 @@ +/* Code coverage file annotations */ +table.code th.coverage { width: 4em; } +table.code th.covered { background-color: #0f0; } +table.code th.uncovered { background-color: #f00; } diff --git a/trac-0.11/bitten/htdocs/charts.swf b/trac-0.11/bitten/htdocs/charts.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e2821facdb1d55065baf6a435dafe72de5d8e777 GIT binary patch literal 29521 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 Mv=aaRKU-6&zehYvYXATM diff --git a/trac-0.11/bitten/htdocs/charts_library/arst.swf b/trac-0.11/bitten/htdocs/charts_library/arst.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..268f8e3042f54d4fccbc9161c338c9f465412609 GIT binary patch literal 6619 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 diff --git a/trac-0.11/bitten/htdocs/charts_library/brfl.swf b/trac-0.11/bitten/htdocs/charts_library/brfl.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7b61a52c4cb18bafc176d2fc35def4b617037cbc GIT binary patch literal 6472 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 diff --git a/trac-0.11/bitten/htdocs/charts_library/brno.swf b/trac-0.11/bitten/htdocs/charts_library/brno.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..30d4aa2dea3c059fdc08b7900e756018f8fe1c01 GIT binary patch literal 6508 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 Y1GVJS;YYokTkDbtT8e-F7h~FU^*S0y&j0`b diff --git a/trac-0.11/bitten/htdocs/charts_library/cl3d.swf b/trac-0.11/bitten/htdocs/charts_library/cl3d.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..1c2f7ffbc7eef1402abcc7a381ad11e42c292cca GIT binary patch literal 9346 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 diff --git a/trac-0.11/bitten/htdocs/charts_library/clfl.swf b/trac-0.11/bitten/htdocs/charts_library/clfl.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3d3da8e80ce449c0d0c9b2853073942e598dc209 GIT binary patch literal 6404 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 STm0(0pcyU4fBzTIz#+=UR%}rK diff --git a/trac-0.11/bitten/htdocs/charts_library/clno.swf b/trac-0.11/bitten/htdocs/charts_library/clno.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..edc00972484cc0591683f7f0d9d55b423d81c92c GIT binary patch literal 6487 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 diff --git a/trac-0.11/bitten/htdocs/charts_library/clp3.swf b/trac-0.11/bitten/htdocs/charts_library/clp3.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a5e6b7973a525c8738b733036c3dda2330ea4b02 GIT binary patch literal 9304 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 GZAkrbjtgx7 diff --git a/trac-0.11/bitten/htdocs/charts_library/cls3.swf b/trac-0.11/bitten/htdocs/charts_library/cls3.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..866116d4de72f211e1ff7a0bece0bd0f70222934 GIT binary patch literal 9338 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 diff --git a/trac-0.11/bitten/htdocs/charts_library/clst.swf b/trac-0.11/bitten/htdocs/charts_library/clst.swf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..811e3d26d9e3056b2145b0bed6eabb16b5651ada GIT binary patch literal 6487 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 diff --git a/trac-0.11/bitten/htdocs/failure.png b/trac-0.11/bitten/htdocs/failure.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..702258c3d72fb128148646894daed3c9facc0513 GIT binary patch literal 206 zc%17D@N?(olHy`uVBq!ia0vp^93afW3?x5a^xFxfSkfJR9T^y|-MHc(VFct$mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-#;3h)VW{XA{j{mjg6e*XXe|KDI> z0Fnm{m_SCdl?3?({|5nv&HI<^2Z|VYx;TbZ+;TZ($ala%fXPwe&Hw#xK5I_OEaN(2 t-}tumYMQ~{bx-Un99R8yFnY>iz@R9@@I-illP6FwgQu&X%Q~loCIE_oN?HH_ diff --git a/trac-0.11/bitten/htdocs/tabset.js b/trac-0.11/bitten/htdocs/tabset.js new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/htdocs/tabset.js @@ -0,0 +1,54 @@ +function makeTabSet(parentElement) { + var tabList = document.createElement("ul"); + tabList.className = "tabs"; + var contentDivs = document.createElement("div"); + + function makeTab(div) { + var title = div.firstChild; + while (title.nodeType != 1) title = title.nextSibling; + var tabItem = document.createElement("li"); + if (!tabList.childNodes.length) tabItem.className = "active"; + var link = document.createElement("a"); + link.href = "#"; + link.appendChild(title.firstChild); + tabItem.appendChild(link); + + var contentDiv = document.createElement("div"); + contentDiv.className = "tab-content"; + while (div.childNodes.length) contentDiv.appendChild(div.firstChild); + if (tabList.childNodes.length) contentDiv.style.display = "none"; + + link.onclick = function() { + var child = contentDivs.firstChild; + while (child) { + if (child != contentDiv && child.nodeType == 1) { + child.style.display = "none"; + } + child = child.nextSibling; + } + var item = tabList.firstChild; + while (item) { + if (item.nodeType == 1) { + item.className = item != tabItem ? "" : "active"; + } + item = item.nextSibling; + } + contentDiv.style.display = "block"; + return false; + } + contentDivs.appendChild(contentDiv); + tabList.appendChild(tabItem); + } + + var divs = parentElement.getElementsByTagName("div"); + for (var i = 0; i < divs.length; i++) { + var div = divs[i]; + if (!/\btab\b/.test(div.className)) { + continue; + } + makeTab(div); + } + + parentElement.appendChild(tabList); + parentElement.appendChild(contentDivs); +} diff --git a/trac-0.11/bitten/main.py b/trac-0.11/bitten/main.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/main.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import inspect +import os +import textwrap + +from trac.core import * +from trac.db import DatabaseManager +from trac.env import IEnvironmentSetupParticipant +from trac.perm import IPermissionRequestor +from trac.wiki import IWikiSyntaxProvider +from bitten.api import IBuildListener +from bitten.model import schema, schema_version, Build, BuildConfig + +__all__ = ['BuildSystem'] +__docformat__ = 'restructuredtext en' + + +class BuildSystem(Component): + + implements(IEnvironmentSetupParticipant, IPermissionRequestor, + IWikiSyntaxProvider) + + listeners = ExtensionPoint(IBuildListener) + + # IEnvironmentSetupParticipant methods + + def environment_created(self): + # Create the required tables + db = self.env.get_db_cnx() + connector, _ = DatabaseManager(self.env)._get_connector() + cursor = db.cursor() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + # Insert a global version flag + cursor.execute("INSERT INTO system (name,value) " + "VALUES ('bitten_version',%s)", (schema_version,)) + + # Create the directory for storing snapshot archives + snapshots_dir = os.path.join(self.env.path, 'snapshots') + os.mkdir(snapshots_dir) + + db.commit() + + def environment_needs_upgrade(self, db): + cursor = db.cursor() + cursor.execute("SELECT value FROM system WHERE name='bitten_version'") + row = cursor.fetchone() + if not row or int(row[0]) < schema_version: + return True + + def upgrade_environment(self, db): + cursor = db.cursor() + cursor.execute("SELECT value FROM system WHERE name='bitten_version'") + row = cursor.fetchone() + if not row: + self.environment_created() + else: + current_version = int(row[0]) + 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', + current_version, schema_version) + + # IPermissionRequestor methods + + def get_permission_actions(self): + actions = ['BUILD_VIEW', 'BUILD_CREATE', 'BUILD_MODIFY', 'BUILD_DELETE', + 'BUILD_EXEC'] + return actions + [('BUILD_ADMIN', actions)] + + # IWikiSyntaxProvider methods + + def get_wiki_syntax(self): + return [] + + def get_link_resolvers(self): + def _format_link(formatter, ns, name, label): + build = Build.fetch(self.env, int(name)) + if build: + config = BuildConfig.fetch(self.env, build.config) + title = 'Build %d ([%s] of %s) by %s' % (build.id, build.rev, + config.label, build.slave) + return '%s' \ + % (formatter.href.build(build.config, build.id), title, + label) + return label + yield 'build', _format_link diff --git a/trac-0.11/bitten/master.py b/trac-0.11/bitten/master.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/master.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +"""Build master implementation.""" + +import calendar +import re +import time + +from trac.config import BoolOption, IntOption +from trac.core import * +from trac.web import IRequestHandler, HTTPBadRequest, HTTPConflict, \ + HTTPForbidden, HTTPMethodNotAllowed, HTTPNotFound, \ + RequestDone + +from bitten.model import BuildConfig, Build, BuildStep, BuildLog, Report +from bitten.main import BuildSystem +from bitten.queue import BuildQueue +from bitten.recipe import Recipe +from bitten.util import xmlio + +__all__ = ['BuildMaster'] +__docformat__ = 'restructuredtext en' + + +class BuildMaster(Component): + """BEEP listener implementation for the build master.""" + + implements(IRequestHandler) + + # Configuration options + + adjust_timestamps = BoolOption('bitten', 'adjust_timestamps', False, doc= + """Whether the timestamps of builds should be adjusted to be close + to the timestamps of the corresponding changesets.""") + + build_all = BoolOption('bitten', 'build_all', False, doc= + """Whether to request builds of older revisions even if a younger + revision has already been built.""") + + stabilize_wait = IntOption('bitten', 'stabilize_wait', 0, doc= + """The time in seconds to wait for the repository to stabilize before + queuing up a new build. This allows time for developers to check in + a group of related changes back to back without spawning multiple + builds.""") + + slave_timeout = IntOption('bitten', 'slave_timeout', 3600, doc= + """The time in seconds after which a build is cancelled if the slave + does not report progress.""") + + # IRequestHandler methods + + def match_request(self, req): + match = re.match(r'/builds(?:/(\d+)(?:/(\w+)/([^/]+)?)?)?$', + req.path_info) + if match: + if match.group(1): + req.args['id'] = match.group(1) + req.args['collection'] = match.group(2) + req.args['member'] = match.group(3) + return True + + def process_request(self, req): + req.perm.assert_permission('BUILD_EXEC') + + if 'id' not in req.args: + if req.method != 'POST': + raise HTTPMethodNotAllowed('Method not allowed') + return self._process_build_creation(req) + + build = Build.fetch(self.env, req.args['id']) + if not build: + raise HTTPNotFound('No such build') + config = BuildConfig.fetch(self.env, build.config) + + if not req.args['collection']: + if req.method == 'DELETE': + return self._process_build_cancellation(req, config, build) + else: + return self._process_build_initiation(req, config, build) + + if req.method != 'POST': + raise HTTPMethodNotAllowed('Method not allowed') + + if req.args['collection'] == 'steps': + return self._process_build_step(req, config, build) + else: + raise HTTPNotFound('No such collection') + + def _process_build_creation(self, req): + queue = BuildQueue(self.env, build_all=self.build_all, + stabilize_wait=self.stabilize_wait, + timeout=self.slave_timeout) + queue.populate() + + try: + elem = xmlio.parse(req.read()) + except xmlio.ParseError, e: + self.log.error('Error parsing build initialization request: %s', e, + exc_info=True) + raise HTTPBadRequest('XML parser error') + + slavename = elem.attr['name'] + properties = {'name': slavename, Build.IP_ADDRESS: req.remote_addr} + self.log.info('Build slave %r connected from %s', slavename, + req.remote_addr) + + for child in elem.children(): + if child.name == 'platform': + properties[Build.MACHINE] = child.gettext() + properties[Build.PROCESSOR] = child.attr.get('processor') + elif child.name == 'os': + properties[Build.OS_NAME] = child.gettext() + properties[Build.OS_FAMILY] = child.attr.get('family') + properties[Build.OS_VERSION] = child.attr.get('version') + elif child.name == 'package': + for name, value in child.attr.items(): + if name == 'name': + continue + properties[child.attr['name'] + '.' + name] = value + + self.log.debug('Build slave configuration: %r', properties) + + build = queue.get_build_for_slave(slavename, properties) + if not build: + req.send_response(204) + req.write('') + raise RequestDone + + 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 + + def _process_build_cancellation(self, req, config, build): + self.log.info('Build slave %r cancelled build %d', build.slave, + build.id) + build.status = Build.PENDING + build.slave = None + build.slave_info = {} + build.started = 0 + db = self.env.get_db_cnx() + for step in list(BuildStep.select(self.env, build=build.id, db=db)): + step.delete(db=db) + build.update(db=db) + db.commit() + + for listener in BuildSystem(self.env).listeners: + listener.build_aborted(build) + + req.send_response(204) + req.write('') + raise RequestDone + + def _process_build_initiation(self, req, config, build): + self.log.info('Build slave %r initiated build %d', build.slave, + build.id) + build.started = int(time.time()) + build.update() + + for listener in BuildSystem(self.env).listeners: + listener.build_started(build) + + xml = xmlio.parse(config.recipe) + xml.attr['path'] = config.path + xml.attr['revision'] = build.rev + xml.attr['config'] = config.name + xml.attr['build'] = str(build.id) + body = str(xml) + + 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', + 'attachment; filename=recipe_%s_r%s.xml' % + (config.name, build.rev)) + req.write(body) + raise RequestDone + + def _process_build_step(self, req, config, build): + try: + elem = xmlio.parse(req.read()) + except xmlio.ParseError, e: + self.log.error('Error parsing build step result: %s', e, + exc_info=True) + raise HTTPBadRequest('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)) + + step = BuildStep.fetch(self.env, build=build.id, name=stepname) + if step: + raise HTTPConflict('Build step already exists') + + recipe = Recipe(xmlio.parse(config.recipe)) + index = None + current_step = None + for num, recipe_step in enumerate(recipe): + if recipe_step.id == stepname: + index = num + current_step = recipe_step + if index is None: + raise HTTPForbidden('No such build step') + last_step = index == num + + self.log.debug('Slave %s (build %d) completed step %d (%s) with ' + 'status %s', build.slave, build.id, index, stepname, + elem.attr['status']) + + db = self.env.get_db_cnx() + + step = BuildStep(self.env, build=build.id, name=stepname) + try: + step.started = int(_parse_iso_datetime(elem.attr['time'])) + step.stopped = step.started + float(elem.attr['duration']) + except ValueError, e: + self.log.error('Error parsing build step timestamp: %s', e, + exc_info=True) + raise HTTPBadRequest(e.args[0]) + if elem.attr['status'] == 'failure': + self.log.warning('Build %s step %s failed', build.id, stepname) + step.status = BuildStep.FAILURE + if current_step.onerror == 'fail': + last_step = True + else: + step.status = BuildStep.SUCCESS + step.errors += [error.gettext() for error in elem.children('error')] + step.insert(db=db) + + # Collect log messages from the request body + for idx, log_elem in enumerate(elem.children('log')): + build_log = BuildLog(self.env, build=build.id, step=stepname, + 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) + + # Collect report data from the request body + for report_elem in elem.children('report'): + report = Report(self.env, build=build.id, step=stepname, + category=report_elem.attr.get('category'), + generator=report_elem.attr.get('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) + + # If this was the last step in the recipe we mark the build as + # completed + if last_step: + self.log.info('Slave %s completed build %d ("%s" as of [%s])', + build.slave, build.id, build.config, build.rev) + build.stopped = step.stopped + + # Determine overall outcome of the build by checking the outcome + # of the individual steps against the "onerror" specification of + # each step in the recipe + for num, recipe_step in enumerate(recipe): + step = BuildStep.fetch(self.env, build.id, recipe_step.id) + if step.status == BuildStep.FAILURE: + if recipe_step.onerror != 'ignore': + build.status = Build.FAILURE + break + else: + build.status = Build.SUCCESS + + build.update(db=db) + + db.commit() + + if last_step: + for listener in BuildSystem(self.env).listeners: + 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 + + +def _parse_iso_datetime(string): + """Minimal parser for ISO date-time strings. + + Return the time as floating point number. Only handles UTC timestamps + without time zone information.""" + try: + string = string.split('.', 1)[0] # strip out microseconds + return calendar.timegm(time.strptime(string, '%Y-%m-%dT%H:%M:%S')) + except ValueError, e: + raise ValueError('Invalid ISO date/time %r' % string) diff --git a/trac-0.11/bitten/model.py b/trac-0.11/bitten/model.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/model.py @@ -0,0 +1,940 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Model classes for objects persisted in the database.""" + +from trac.db import Table, Column, Index + +__docformat__ = 'restructuredtext en' + + +class BuildConfig(object): + """Representation of a build configuration.""" + + _schema = [ + Table('bitten_config', key='name')[ + Column('name'), Column('path'), Column('active', type='int'), + Column('recipe'), Column('min_rev'), Column('max_rev'), + Column('label'), Column('description') + ] + ] + + def __init__(self, env, name=None, path=None, active=False, recipe=None, + min_rev=None, max_rev=None, label=None, description=None): + """Initialize a new build configuration with the specified attributes. + + To actually create this configuration in the database, the `insert` + method needs to be called. + """ + self.env = env + self._old_name = None + self.name = name + self.path = path or '' + self.active = bool(active) + self.recipe = recipe or '' + self.min_rev = min_rev or None + self.max_rev = max_rev or None + self.label = label or '' + self.description = description or '' + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.name) + + exists = property(fget=lambda self: self._old_name is not None, + doc='Whether this configuration exists in the database') + + def delete(self, db=None): + """Remove a build configuration and all dependent objects from the + database.""" + assert self.exists, 'Cannot delete non-existing configuration' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + for platform in list(TargetPlatform.select(self.env, self.name, db=db)): + platform.delete(db=db) + + for build in list(Build.select(self.env, config=self.name, db=db)): + build.delete(db=db) + + cursor = db.cursor() + cursor.execute("DELETE FROM bitten_config WHERE name=%s", (self.name,)) + + if handle_ta: + db.commit() + self._old_name = None + + def insert(self, db=None): + """Insert a new configuration into the database.""" + assert not self.exists, 'Cannot insert existing configuration' + assert self.name, 'Configuration requires a name' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,active," + "recipe,min_rev,max_rev,label,description) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", + (self.name, self.path, int(self.active or 0), + self.recipe or '', self.min_rev, self.max_rev, + self.label or '', self.description or '')) + + if handle_ta: + db.commit() + self._old_name = self.name + + def update(self, db=None): + """Save changes to an existing build configuration.""" + assert self.exists, 'Cannot update a non-existing configuration' + assert self.name, 'Configuration requires a name' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + cursor = db.cursor() + cursor.execute("UPDATE bitten_config SET name=%s,path=%s,active=%s," + "recipe=%s,min_rev=%s,max_rev=%s,label=%s," + "description=%s WHERE name=%s", + (self.name, self.path, int(self.active or 0), + self.recipe, self.min_rev, self.max_rev, + self.label, self.description, self._old_name)) + if self.name != self._old_name: + cursor.execute("UPDATE bitten_platform SET config=%s " + "WHERE config=%s", (self.name, self._old_name)) + cursor.execute("UPDATE bitten_build SET config=%s " + "WHERE config=%s", (self.name, self._old_name)) + + if handle_ta: + db.commit() + self._old_name = self.name + + def fetch(cls, env, name, db=None): + """Retrieve an existing build configuration from the database by + name. + """ + if not db: + db = env.get_db_cnx() + + cursor = db.cursor() + cursor.execute("SELECT path,active,recipe,min_rev,max_rev,label," + "description FROM bitten_config WHERE name=%s", (name,)) + row = cursor.fetchone() + if not row: + return None + + config = BuildConfig(env) + config.name = config._old_name = name + config.path = row[0] or '' + config.active = bool(row[1]) + config.recipe = row[2] or '' + config.min_rev = row[3] or None + config.max_rev = row[4] or None + config.label = row[5] or '' + config.description = row[6] or '' + return config + + fetch = classmethod(fetch) + + def select(cls, env, include_inactive=False, db=None): + """Retrieve existing build configurations from the database that match + the specified criteria. + """ + if not db: + db = env.get_db_cnx() + + cursor = db.cursor() + if include_inactive: + cursor.execute("SELECT name,path,active,recipe,min_rev,max_rev," + "label,description FROM bitten_config ORDER BY name") + else: + cursor.execute("SELECT name,path,active,recipe,min_rev,max_rev," + "label,description FROM bitten_config " + "WHERE active=1 ORDER BY name") + for name, path, active, recipe, min_rev, max_rev, label, description \ + in cursor: + config = BuildConfig(env, name=name, path=path or '', + active=bool(active), recipe=recipe or '', + min_rev=min_rev or None, + max_rev=max_rev or None, label=label or '', + description=description or '') + config._old_name = name + yield config + + select = classmethod(select) + + +class TargetPlatform(object): + """Target platform for a build configuration.""" + + _schema = [ + Table('bitten_platform', key='id')[ + Column('id', auto_increment=True), Column('config'), Column('name') + ], + Table('bitten_rule', key=('id', 'propname'))[ + Column('id'), Column('propname'), Column('pattern'), + Column('orderno', type='int') + ] + ] + + def __init__(self, env, config=None, name=None): + """Initialize a new target platform with the specified attributes. + + To actually create this platform in the database, the `insert` method + needs to be called. + """ + self.env = env + self.id = None + self.config = config + self.name = name + self.rules = [] + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.id) + + exists = property(fget=lambda self: self.id is not None, + doc='Whether this target platform exists in the database') + + def delete(self, db=None): + """Remove the target platform from the database.""" + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + cursor = db.cursor() + cursor.execute("DELETE FROM bitten_rule WHERE id=%s", (self.id,)) + cursor.execute("DELETE FROM bitten_platform WHERE id=%s", (self.id,)) + if handle_ta: + db.commit() + + def insert(self, db=None): + """Insert a new target platform into the database.""" + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + assert not self.exists, 'Cannot insert existing target platform' + assert self.config, 'Target platform needs to be associated with a ' \ + 'configuration' + assert self.name, 'Target platform requires a name' + + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_platform (config,name) " + "VALUES (%s,%s)", (self.config, self.name)) + self.id = db.get_last_id(cursor, 'bitten_platform') + if self.rules: + cursor.executemany("INSERT INTO bitten_rule VALUES (%s,%s,%s,%s)", + [(self.id, propname, pattern, idx) + for idx, (propname, pattern) + in enumerate(self.rules)]) + + if handle_ta: + db.commit() + + def update(self, db=None): + """Save changes to an existing target platform.""" + assert self.exists, 'Cannot update a non-existing platform' + assert self.config, 'Target platform needs to be associated with a ' \ + 'configuration' + assert self.name, 'Target platform requires a name' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + cursor = db.cursor() + cursor.execute("UPDATE bitten_platform SET name=%s WHERE id=%s", + (self.name, self.id)) + cursor.execute("DELETE FROM bitten_rule WHERE id=%s", (self.id,)) + if self.rules: + cursor.executemany("INSERT INTO bitten_rule VALUES (%s,%s,%s,%s)", + [(self.id, propname, pattern, idx) + for idx, (propname, pattern) + in enumerate(self.rules)]) + + if handle_ta: + db.commit() + + def fetch(cls, env, id, db=None): + """Retrieve an existing target platform from the database by ID.""" + if not db: + db = env.get_db_cnx() + + cursor = db.cursor() + cursor.execute("SELECT config,name FROM bitten_platform " + "WHERE id=%s", (id,)) + row = cursor.fetchone() + if not row: + return None + + platform = TargetPlatform(env, config=row[0], name=row[1]) + platform.id = id + cursor.execute("SELECT propname,pattern FROM bitten_rule " + "WHERE id=%s ORDER BY orderno", (id,)) + for propname, pattern in cursor: + platform.rules.append((propname, pattern)) + return platform + + fetch = classmethod(fetch) + + def select(cls, env, config=None, db=None): + """Retrieve existing target platforms from the database that match the + specified criteria. + """ + if not db: + db = env.get_db_cnx() + + where_clauses = [] + if config is not None: + where_clauses.append(("config=%s", config)) + 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_platform %s ORDER BY name" + % where, [wc[1] for wc in where_clauses]) + for (id,) in cursor: + yield TargetPlatform.fetch(env, id) + + select = classmethod(select) + + +class Build(object): + """Representation of a build.""" + + _schema = [ + Table('bitten_build', key='id')[ + Column('id', auto_increment=True), Column('config'), Column('rev'), + Column('rev_time', type='int'), Column('platform', type='int'), + Column('slave'), Column('started', type='int'), + Column('stopped', type='int'), Column('status', size=1), + Index(['config', 'rev', 'slave']) + ], + Table('bitten_slave', key=('build', 'propname'))[ + Column('build', type='int'), Column('propname'), Column('propvalue') + ] + ] + + # Build status codes + PENDING = 'P' + IN_PROGRESS = 'I' + SUCCESS = 'S' + FAILURE = 'F' + + # Standard slave properties + IP_ADDRESS = 'ipnr' + MAINTAINER = 'owner' + OS_NAME = 'os' + OS_FAMILY = 'family' + OS_VERSION = 'version' + MACHINE = 'machine' + PROCESSOR = 'processor' + + def __init__(self, env, config=None, rev=None, platform=None, slave=None, + started=0, stopped=0, rev_time=0, status=PENDING): + """Initialize a new build with the specified attributes. + + To actually create this build in the database, the `insert` method needs + to be called. + """ + self.env = env + self.id = None + self.config = config + self.rev = rev and str(rev) or None + self.platform = platform + self.slave = slave + self.started = started or 0 + self.stopped = stopped or 0 + self.rev_time = rev_time + self.status = status + self.slave_info = {} + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.id) + + exists = property(fget=lambda self: self.id is not None, + doc='Whether this build exists in the database') + completed = property(fget=lambda self: self.status != Build.IN_PROGRESS, + doc='Whether the build has been completed') + successful = property(fget=lambda self: self.status == Build.SUCCESS, + doc='Whether the build was successful') + + def delete(self, db=None): + """Remove the build from the database.""" + assert self.exists, 'Cannot delete a non-existing build' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + for step in list(BuildStep.select(self.env, build=self.id)): + step.delete(db=db) + + cursor = db.cursor() + cursor.execute("DELETE FROM bitten_slave WHERE build=%s", (self.id,)) + cursor.execute("DELETE FROM bitten_build WHERE id=%s", (self.id,)) + + if handle_ta: + db.commit() + + def insert(self, db=None): + """Insert a new build into the database.""" + assert not self.exists, 'Cannot insert an existing build' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + assert self.config and self.rev and self.rev_time and self.platform + assert self.status in (self.PENDING, self.IN_PROGRESS, self.SUCCESS, + self.FAILURE) + if not self.slave: + assert self.status == self.PENDING + + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_build (config,rev,rev_time,platform," + "slave,started,stopped,status) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", + (self.config, self.rev, int(self.rev_time), + self.platform, self.slave or '', self.started or 0, + self.stopped or 0, self.status)) + self.id = db.get_last_id(cursor, 'bitten_build') + if self.slave_info: + cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)", + [(self.id, name, value) for name, value + in self.slave_info.items()]) + + if handle_ta: + db.commit() + + def update(self, db=None): + """Save changes to an existing build.""" + assert self.exists, 'Cannot update a non-existing build' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + assert self.config and self.rev + assert self.status in (self.PENDING, self.IN_PROGRESS, self.SUCCESS, + self.FAILURE) + if not self.slave: + assert self.status == self.PENDING + + cursor = db.cursor() + cursor.execute("UPDATE bitten_build SET slave=%s,started=%s," + "stopped=%s,status=%s WHERE id=%s", + (self.slave or '', self.started or 0, + self.stopped or 0, self.status, self.id)) + cursor.execute("DELETE FROM bitten_slave WHERE build=%s", (self.id,)) + if self.slave_info: + cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)", + [(self.id, name, value) for name, value + in self.slave_info.items()]) + if handle_ta: + db.commit() + + 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 config,rev,rev_time,platform,slave,started," + "stopped,status FROM bitten_build WHERE id=%s", (id,)) + row = cursor.fetchone() + if not row: + return None + + build = Build(env, config=row[0], rev=row[1], rev_time=int(row[2]), + platform=int(row[3]), slave=row[4], + started=row[5] and int(row[5]) or 0, + stopped=row[6] and int(row[6]) or 0, status=row[7]) + build.id = int(id) + cursor.execute("SELECT propname,propvalue FROM bitten_slave " + "WHERE build=%s", (id,)) + for propname, propvalue in cursor: + build.slave_info[propname] = propvalue + return build + + fetch = classmethod(fetch) + + def select(cls, env, config=None, rev=None, platform=None, slave=None, + status=None, db=None): + """Retrieve existing builds from the database that match the specified + criteria. + """ + if not db: + db = env.get_db_cnx() + + where_clauses = [] + if config is not None: + where_clauses.append(("config=%s", config)) + if rev is not None: + where_clauses.append(("rev=%s", rev)) + if platform is not None: + where_clauses.append(("platform=%s", platform)) + if slave is not None: + where_clauses.append(("slave=%s", slave)) + if status is not None: + where_clauses.append(("status=%s", status)) + 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_build %s " + "ORDER BY config,rev_time DESC,slave" + % where, [wc[1] for wc in where_clauses]) + for (id,) in cursor: + yield Build.fetch(env, id) + select = classmethod(select) + + +class BuildStep(object): + """Represents an individual step of an executed build.""" + + _schema = [ + Table('bitten_step', key=('build', 'name'))[ + Column('build', type='int'), Column('name'), Column('description'), + Column('status', size=1), Column('started', type='int'), + Column('stopped', type='int') + ], + Table('bitten_error', key=('build', 'step', 'orderno'))[ + Column('build', type='int'), Column('step'), Column('message'), + Column('orderno', type='int') + ] + ] + + # Step status codes + SUCCESS = 'S' + FAILURE = 'F' + + def __init__(self, env, build=None, name=None, description=None, + status=None, started=None, stopped=None): + """Initialize a new build step with the specified attributes. + + To actually create this build step in the database, the `insert` method + needs to be called. + """ + self.env = env + self.build = build + self.name = name + self.description = description + self.status = status + self.started = started + self.stopped = stopped + self.errors = [] + self._exists = False + + exists = property(fget=lambda self: self._exists, + doc='Whether this build step exists in the database') + successful = property(fget=lambda self: self.status == BuildStep.SUCCESS, + doc='Whether the build step was successful') + + def delete(self, db=None): + """Remove the build step from the database.""" + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + 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", + (self.build, self.name)) + cursor.execute("DELETE FROM bitten_error WHERE build=%s AND step=%s", + (self.build, self.name)) + + if handle_ta: + db.commit() + self._exists = False + + def insert(self, db=None): + """Insert a new build step into the database.""" + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + assert self.build and self.name + assert self.status in (self.SUCCESS, self.FAILURE) + + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_step (build,name,description,status," + "started,stopped) VALUES (%s,%s,%s,%s,%s,%s)", + (self.build, self.name, self.description or '', + self.status, self.started or 0, self.stopped or 0)) + if self.errors: + cursor.executemany("INSERT INTO bitten_error (build,step,message," + "orderno) VALUES (%s,%s,%s,%s)", + [(self.build, self.name, message, idx) + for idx, message in enumerate(self.errors)]) + + if handle_ta: + db.commit() + self._exists = True + + def fetch(cls, env, build, name, db=None): + """Retrieve an existing build from the database by build ID and step + name.""" + if not db: + db = env.get_db_cnx() + + cursor = db.cursor() + cursor.execute("SELECT description,status,started,stopped " + "FROM bitten_step WHERE build=%s AND name=%s", + (build, name)) + row = cursor.fetchone() + if not row: + return None + step = BuildStep(env, build, name, row[0] or '', row[1], + row[2] and int(row[2]), row[3] and int(row[3])) + step._exists = True + + cursor.execute("SELECT message FROM bitten_error WHERE build=%s " + "AND step=%s ORDER BY orderno", (build, name)) + for row in cursor: + step.errors.append(row[0] or '') + return step + + fetch = classmethod(fetch) + + def select(cls, env, build=None, name=None, status=None, db=None): + """Retrieve existing build steps from the database that match the + specified criteria. + """ + if not db: + db = env.get_db_cnx() + + assert status in (None, BuildStep.SUCCESS, BuildStep.FAILURE) + + where_clauses = [] + if build is not None: + where_clauses.append(("build=%s", build)) + if name is not None: + where_clauses.append(("name=%s", name)) + if status is not None: + where_clauses.append(("status=%s", status)) + if where_clauses: + where = "WHERE " + " AND ".join([wc[0] for wc in where_clauses]) + else: + where = "" + + cursor = db.cursor() + cursor.execute("SELECT build,name FROM bitten_step %s ORDER BY stopped" + % where, [wc[1] for wc in where_clauses]) + for build, name in cursor: + yield BuildStep.fetch(env, build, name, db=db) + + select = classmethod(select) + + +class BuildLog(object): + """Represents a build log.""" + + _schema = [ + Table('bitten_log', key='id')[ + Column('id', auto_increment=True), Column('build', type='int'), + 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'), + Column('level', size=1), Column('message') + ] + ] + + # Message levels + DEBUG = 'D' + INFO = 'I' + WARNING = 'W' + ERROR = 'E' + + 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 + needs to be called. + """ + self.env = env + self.id = None + self.build = build + self.step = step + self.generator = generator or '' + self.orderno = orderno and int(orderno) or 0 + self.messages = [] + + exists = property(fget=lambda self: self.id is not None, + doc='Whether this build log exists in the database') + + def delete(self, db=None): + """Remove the build log from the database.""" + assert self.exists, 'Cannot delete a non-existing build log' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + cursor = db.cursor() + cursor.execute("DELETE FROM bitten_log_message WHERE log=%s", + (self.id,)) + cursor.execute("DELETE FROM bitten_log 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 + + cursor = db.cursor() + 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') + if self.messages: + cursor.executemany("INSERT INTO bitten_log_message " + "(log,line,level,message) VALUES (%s,%s,%s,%s)", + [(id, idx, msg[0], msg[1]) for idx, msg in + enumerate(self.messages)]) + + 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,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], row[3]) + log.id = id + cursor.execute("SELECT level,message FROM bitten_log_message " + "WHERE log=%s ORDER BY line", (id,)) + log.messages = cursor.fetchall() or [] + + return log + + fetch = classmethod(fetch) + + def select(cls, env, build=None, step=None, generator=None, db=None): + """Retrieve existing build logs from the database that match the + specified criteria. + """ + if not db: + db = env.get_db_cnx() + + where_clauses = [] + if build is not None: + where_clauses.append(("build=%s", build)) + if step is not None: + where_clauses.append(("step=%s", step)) + 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 orderno" + % where, [wc[1] for wc in where_clauses]) + for (id, ) in cursor: + yield BuildLog.fetch(env, id, db=db) + + 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, + doc='Whether this report exists in the database') + + 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([item for item in self.items if item]): + 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 + Report._schema +schema_version = 7 diff --git a/trac-0.11/bitten/queue.py b/trac-0.11/bitten/queue.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/queue.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +"""Implements the scheduling of builds for a project. + +This module provides the functionality for scheduling builds for a specific +Trac environment. It is used by both the build master and the web interface to +get the list of required builds (revisions not built yet). + +Furthermore, the `BuildQueue` class is used by the build master to determine +the next pending build, and to match build slaves against configured target +platforms. +""" + +from datetime import datetime +from itertools import ifilter +import logging +import re +import time + +from trac.versioncontrol import NoSuchNode +from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep + +__docformat__ = 'restructuredtext en' + +log = logging.getLogger('bitten.queue') + + +def collect_changes(repos, config, db=None): + """Collect all changes for a build configuration that either have already + been built, or still need to be built. + + This function is a generator that yields ``(platform, rev, build)`` tuples, + where ``platform`` is a `TargetPlatform` object, ``rev`` is the identifier + of the changeset, and ``build`` is a `Build` object or `None`. + + :param repos: the version control repository + :param config: the build configuration + :param db: a database connection (optional) + """ + env = config.env + if not db: + db = env.get_db_cnx() + try: + node = repos.get_node(config.path) + except NoSuchNode, e: + env.log.warn('Node for configuration %r not found', config.name, + exc_info=True) + return + + for path, rev, chg in node.get_history(): + + # Don't follow moves/copies + if path != repos.normalize_path(config.path): + break + + # Stay within the limits of the build config + if config.min_rev and repos.rev_older_than(rev, config.min_rev): + break + if config.max_rev and repos.rev_older_than(config.max_rev, rev): + continue + + # Make sure the repository directory isn't empty at this + # revision + old_node = repos.get_node(path, rev) + is_empty = True + for entry in old_node.get_entries(): + is_empty = False + break + if is_empty: + continue + + # For every target platform, check whether there's a build + # of this revision + for platform in TargetPlatform.select(env, config.name, db=db): + builds = list(Build.select(env, config.name, rev, platform.id, + db=db)) + if builds: + build = builds[0] + else: + build = None + + yield platform, rev, build + + +class BuildQueue(object): + """Enapsulates the build queue of an environment. + + A build queue manages the the registration of build slaves and detection of + repository revisions that need to be built. + """ + + def __init__(self, env, build_all=False, stabilize_wait=0, timeout=0): + """Create the build queue. + + :param env: the Trac environment + :param build_all: whether older revisions should be built + :param stabilize_wait: The time in seconds to wait before considering + the repository stable to create a build in the queue. + :param timeout: the time in seconds after which an in-progress build + should be considered orphaned, and reset to pending + state + """ + self.env = env + self.log = env.log + self.build_all = build_all + self.stabilize_wait = stabilize_wait + self.timeout = timeout + + # Build scheduling + + def get_build_for_slave(self, name, properties): + """Check whether one of the pending builds can be built by the build + slave. + + :param name: the name of the slave + :type name: `basestring` + :param properties: the slave configuration + :type properties: `dict` + :return: the allocated build, or `None` if no build was found + :rtype: `Build` + """ + log.debug('Checking for pending builds...') + + db = self.env.get_db_cnx() + repos = self.env.get_repository() + + self.reset_orphaned_builds() + + # Iterate through pending builds by descending revision timestamp, to + # avoid the first configuration/platform getting all the builds + platforms = [p.id for p in self.match_slave(name, properties)] + build = None + builds_to_delete = [] + for build in Build.select(self.env, status=Build.PENDING, db=db): + if self.should_delete_build(build, repos): + self.log.info('Scheduling build %d for deletion', build.id) + builds_to_delete.append(build) + elif build.platform in platforms: + break + else: + self.log.debug('No pending builds.') + build = None + + # delete any obsolete builds + for build_to_delete in builds_to_delete: + build_to_delete.delete(db=db) + + if build: + build.slave = name + build.slave_info.update(properties) + build.status = Build.IN_PROGRESS + build.update(db=db) + + if build or builds_to_delete: + db.commit() + + return build + + def match_slave(self, name, properties): + """Match a build slave against available target platforms. + + :param name: the name of the slave + :type name: `basestring` + :param properties: the slave configuration + :type properties: `dict` + :return: the list of platforms the slave matched + """ + platforms = [] + + for config in BuildConfig.select(self.env): + for platform in TargetPlatform.select(self.env, config=config.name): + match = True + for propname, pattern in ifilter(None, platform.rules): + try: + propvalue = properties.get(propname) + if not propvalue or not re.match(pattern, propvalue): + match = False + break + except re.error: + self.log.error('Invalid platform matching pattern "%s"', + pattern, exc_info=True) + match = False + break + if match: + self.log.debug('Slave %r matched target platform %r of ' + 'build configuration %r', name, + platform.name, config.name) + platforms.append(platform) + + if not platforms: + self.log.warning('Slave %r matched none of the target platforms', + name) + + return platforms + + def populate(self): + """Add a build for the next change on each build configuration to the + queue. + + The next change is the latest repository check-in for which there isn't + a corresponding build on each target platform. Repeatedly calling this + method will eventually result in the entire change history of the build + configuration being in the build queue. + """ + repos = self.env.get_repository() + if hasattr(repos, 'sync'): + repos.sync() + + db = self.env.get_db_cnx() + builds = [] + + for config in BuildConfig.select(self.env, db=db): + platforms = [] + for platform, rev, build in collect_changes(repos, config, db): + + if not self.build_all and platform.id in platforms: + # We've seen this platform already, so these are older + # builds that should only be built if built_all=True + self.log.debug('Ignoring older revisions for configuration ' + '%r on %r', config.name, platform.name) + break + + platforms.append(platform.id) + + if build is None: + self.log.info('Enqueuing build of configuration "%s" at ' + 'revision [%s] on %s', config.name, rev, + platform.name) + + rev_time = repos.get_changeset(rev).date + if isinstance(rev_time, datetime): # Trac>=0.11 + from trac.util.datefmt import to_timestamp + rev_time = to_timestamp(rev_time) + age = int(time.time()) - rev_time + if self.stabilize_wait and age < self.stabilize_wait: + self.log.info('Delaying build of revision %s until %s ' + 'seconds pass. Current age is: %s ' + 'seconds' % (rev, self.stabilize_wait, + age)) + continue + + build = Build(self.env, config=config.name, + platform=platform.id, rev=str(rev), + rev_time=rev_time) + builds.append(build) + + for build in builds: + build.insert(db=db) + + db.commit() + + def reset_orphaned_builds(self): + """Reset all in-progress builds to ``PENDING`` state if they've been + running so long that the configured timeout has been reached. + + This is used to cleanup after slaves that have unexpectedly cancelled + a build without notifying the master, or are for some other reason not + reporting back status updates. + """ + if not self.timeout: + # If no timeout is set, none of the in-progress builds can be + # considered orphaned + return + + db = self.env.get_db_cnx() + now = int(time.time()) + for build in Build.select(self.env, status=Build.IN_PROGRESS, db=db): + if now - build.started < self.timeout: + # This build has not reached the timeout yet, assume it's still + # being executed + # FIXME: ideally, we'd base this check on the last activity on + # the build, not the start time + continue + build.status = Build.PENDING + build.slave = None + build.slave_info = {} + build.started = 0 + for step in list(BuildStep.select(self.env, build=build.id, db=db)): + step.delete(db=db) + build.update(db=db) + db.commit() + + def should_delete_build(self, build, repos): + # Ignore pending builds for deactived build configs + config = BuildConfig.fetch(self.env, build.config) + if not config.active: + log.info('Dropping build of configuration "%s" at ' + 'revision [%s] on "%s" because the configuration is ' + 'deactivated', config.name, build.rev, + TargetPlatform.fetch(self.env, build.platform).name) + return True + + # Stay within the revision limits of the build config + if (config.min_rev and repos.rev_older_than(build.rev, + config.min_rev)) \ + or (config.max_rev and repos.rev_older_than(config.max_rev, + build.rev)): + # This minimum and/or maximum revision has changed since + # this build was enqueued, so drop it + log.info('Dropping build of configuration "%s" at revision [%s] on ' + '"%s" because it is outside of the revision range of the ' + 'configuration', config.name, build.rev, + TargetPlatform.fetch(self.env, build.platform).name) + return True + + return False diff --git a/trac-0.11/bitten/recipe.py b/trac-0.11/bitten/recipe.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/recipe.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +"""Execution of build recipes. + +This module provides various classes that can be used to process build recipes, +most importantly the `Recipe` class. +""" + +import keyword +import logging +import os +try: + set +except NameError: + from sets import Set as set + +from pkg_resources import WorkingSet +from bitten.build import BuildError +from bitten.build.config import Configuration +from bitten.util import xmlio + +__all__ = ['Context', 'Recipe', 'Step', 'InvalidRecipeError'] +__docformat__ = 'restructuredtext en' + +log = logging.getLogger('bitten.recipe') + + +class InvalidRecipeError(Exception): + """Exception raised when a recipe is not valid.""" + + +class Context(object): + """The context in which a build is executed.""" + + step = None # The current step + generator = None # The current generator (namespace#name) + + def __init__(self, basedir, config=None, vars=None): + """Initialize the context. + + :param basedir: a string containing the working directory for the build. + (may be a pattern for replacement ex: 'build_${build}' + :param config: the build slave configuration + :type config: `Configuration` + """ + self.config = config or Configuration() + self.vars = vars or {} + self.output = [] + self.basedir = os.path.realpath(self.config.interpolate(basedir, + **self.vars)) + + def run(self, step, namespace, name, attr): + """Run the specified recipe command. + + :param step: the build step that the command belongs to + :param namespace: the namespace URI of the command + :param name: the local tag name of the command + :param attr: a dictionary containing the attributes defined on the + command element + """ + self.step = step + + try: + function = None + qname = '#'.join(filter(None, [namespace, name])) + if namespace: + group = 'bitten.recipe_commands' + for entry_point in WorkingSet().iter_entry_points(group, qname): + function = entry_point.load() + break + elif name == 'report': + function = Context.report_file + if not function: + raise InvalidRecipeError('Unknown recipe command %s' % qname) + + def escape(name): + name = name.replace('-', '_') + if keyword.iskeyword(name) or name in __builtins__: + name = name + '_' + return name + args = dict([(escape(name), + self.config.interpolate(attr[name], **self.vars)) + for name in attr]) + + self.generator = qname + log.debug('Executing %s with arguments: %s', function, args) + function(self, **args) + + finally: + self.generator = None + self.step = None + + def error(self, message): + """Record an error message. + + :param message: a string containing the error message. + """ + self.output.append((Recipe.ERROR, None, self.generator, message)) + + def log(self, xml): + """Record log output. + + :param xml: an XML fragment containing the log messages + """ + self.output.append((Recipe.LOG, None, self.generator, xml)) + + def report(self, category, xml): + """Record report data. + + :param category: the name of category of the report + :param xml: an XML fragment containing the report data + """ + self.output.append((Recipe.REPORT, category, self.generator, xml)) + + def report_file(self, category=None, file_=None): + """Read report data from a file and record it. + + :param category: the name of the category of the report + :param file\_: the path to the file containing the report data, relative + to the base directory + """ + filename = self.resolve(file_) + try: + fileobj = file(filename, 'r') + try: + xml_elem = xmlio.Fragment() + for child in xmlio.parse(fileobj).children(): + child_elem = xmlio.Element(child.name, **dict([ + (name, value) for name, value in child.attr.items() + if value is not None + ])) + xml_elem.append(child_elem[ + [xmlio.Element(grandchild.name)[grandchild.gettext()] + for grandchild in child.children()] + ]) + self.output.append((Recipe.REPORT, category, None, xml_elem)) + finally: + fileobj.close() + except xmlio.ParseError, e: + self.error('Failed to parse %s report at %s: %s' + % (category, filename, e)) + except IOError, e: + self.error('Failed to read %s report at %s: %s' + % (category, filename, e)) + + def resolve(self, *path): + """Return the path of a file relative to the base directory. + + Accepts any number of positional arguments, which are joined using the + system path separator to form the path. + """ + return os.path.normpath(os.path.join(self.basedir, *path)) + + +class Step(object): + """Represents a single step of a build recipe. + + Iterate over an object of this class to get the commands to execute, and + their keyword arguments. + """ + + def __init__(self, elem): + """Create the step. + + :param elem: the XML element representing the step + :type elem: `ParsedElement` + """ + self._elem = elem + self.id = elem.attr['id'] + self.description = elem.attr.get('description') + self.onerror = elem.attr.get('onerror', 'fail') + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.id) + + def execute(self, ctxt): + """Execute this step in the given context. + + :param ctxt: the build context + :type ctxt: `Context` + """ + for child in self._elem: + ctxt.run(self, child.namespace, child.name, child.attr) + + errors = [] + while ctxt.output: + type, category, generator, output = ctxt.output.pop(0) + yield type, category, generator, output + if type == Recipe.ERROR: + errors.append((generator, output)) + if errors: + if self.onerror != 'ignore': + raise BuildError('Build step %s failed' % self.id) + log.warning('Continuing despite errors in step %s (%s)', self.id, + ', '.join([error[1] for error in errors])) + + +class Recipe(object): + """A build recipe. + + Iterate over this object to get the individual build steps in the order + they have been defined in the recipe file. + """ + + ERROR = 'error' + LOG = 'log' + REPORT = 'report' + + def __init__(self, xml, basedir=os.getcwd(), config=None): + """Create the recipe. + + :param xml: the XML document representing the recipe + :type xml: `ParsedElement` + :param basedir: the base directory for the build + :param config: the slave configuration (optional) + :type config: `Configuration` + """ + assert isinstance(xml, xmlio.ParsedElement) + vars = dict([(name, value) for name, value in xml.attr.items() + if not name.startswith('xmlns')]) + self.ctxt = Context(basedir, config, vars) + self._root = xml + + def __iter__(self): + """Iterate over the individual steps of the recipe.""" + for child in self._root.children('step'): + yield Step(child) + + def validate(self): + """Validate the recipe. + + This method checks a number of constraints: + - the name of the root element must be "build" + - the only permitted child elements or the root element with the name + "step" + - the recipe must contain at least one step + - step elements must have a unique "id" attribute + - a step must contain at least one nested command + - commands must not have nested content + + :raise InvalidRecipeError: in case any of the above contraints is + violated + """ + if self._root.name != 'build': + raise InvalidRecipeError('Root element must be ') + steps = list(self._root.children()) + if not steps: + raise InvalidRecipeError('Recipe defines no build steps') + + step_ids = set() + for step in steps: + if step.name != 'step': + raise InvalidRecipeError('Only elements allowed at ' + 'top level of recipe') + if not step.attr.get('id'): + raise InvalidRecipeError('Steps must have an "id" attribute') + + if step.attr['id'] in step_ids: + raise InvalidRecipeError('Duplicate step ID "%s"' % + step.attr['id']) + step_ids.add(step.attr['id']) + + cmds = list(step.children()) + if not cmds: + raise InvalidRecipeError('Step "%s" has no recipe commands' % + step.attr['id']) + for cmd in cmds: + if len(list(cmd.children())): + raise InvalidRecipeError('Recipe command <%s> has nested ' + 'content' % cmd.name) diff --git a/trac-0.11/bitten/report/__init__.py b/trac-0.11/bitten/report/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/report/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +__docformat__ = 'restructuredtext en' diff --git a/trac-0.11/bitten/report/coverage.py b/trac-0.11/bitten/report/coverage.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/report/coverage.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +from trac.core import * +from trac.mimeview.api import IHTMLPreviewAnnotator +from trac.web.chrome import Chrome, add_stylesheet +from trac.web.clearsilver import HDFWrapper +from bitten.api import IReportChartGenerator, IReportSummarizer +from bitten.model import BuildConfig, Build, Report + +__docformat__ = 'restructuredtext en' + + +class TestCoverageChartGenerator(Component): + implements(IReportChartGenerator) + + # IReportChartGenerator methods + + def get_supported_categories(self): + return ['coverage'] + + def generate_chart_data(self, req, config, category): + assert category == 'coverage' + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute(""" +SELECT build.rev, SUM(%s) AS loc, SUM(%s * %s / 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_time, build.rev, build.platform +ORDER BY build.rev_time""" % (db.cast('item_lines.value', 'int'), + db.cast('item_lines.value', 'int'), + db.cast('item_percentage.value', 'int')), + (config.name,)) + + 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] = int(loc) + if cov > coverage[-1][2]: + coverage[-1][2] = int(cov) + prev_rev = rev + + data = {'title': 'Test Coverage', + 'data': [ + [''] + ['[%s]' % 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.html', data + + +class TestCoverageSummarizer(Component): + implements(IReportSummarizer) + + # IReportSummarizer methods + + def get_supported_categories(self): + return ['coverage'] + + def render_summary(self, req, config, build, step, category): + assert category == 'coverage' + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute(""" +SELECT item_name.value AS unit, item_file.value AS file, + max(item_lines.value) AS loc, max(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 AND step=%s +GROUP BY file, item_name.value +ORDER BY item_name.value""", (build.id, step.name)) + + units = [] + total_loc, total_cov = 0, 0 + for unit, file, loc, cov in cursor: + try: + loc, cov = int(loc), float(cov) + except TypeError: + continue # no rows + if loc: + d = {'name': unit, 'loc': loc, 'cov': int(cov)} + if file: + d['href'] = req.href.browser(config.path, file, rev=build.rev, annotate='coverage') + units.append(d) + total_loc += loc + total_cov += loc * cov + + coverage = 0 + if total_loc != 0: + coverage = total_cov // total_loc + + return 'bitten_summary_coverage.html', { + 'units': units, + 'totals': {'loc': total_loc, 'cov': int(coverage)} + } + + +# Coverage annotation requires the new interface from 0.11 +if hasattr(IHTMLPreviewAnnotator, 'get_annotation_data'): + class TestCoverageAnnotator(Component): + """ + >>> from genshi.builder import tag + >>> from trac.test import Mock, MockPerm + >>> from trac.mimeview import Context + >>> from trac.web.href import Href + >>> from bitten.model import BuildConfig, Build, Report + >>> from bitten.report.tests.coverage import env_stub_with_tables + >>> env = env_stub_with_tables() + + >>> BuildConfig(env, name='trunk', path='trunk').insert() + >>> Build(env, rev=123, config='trunk', rev_time=12345, platform=1).insert() + >>> rpt = Report(env, build=1, step='test', category='coverage') + >>> rpt.items.append({'file': 'foo.py', 'line_hits': '5 - 0'}) + >>> rpt.insert() + + >>> ann = TestCoverageAnnotator(env) + >>> req = Mock(href=Href('/'), perm=MockPerm(), chrome={}) + + Version in the branch should not match: + >>> context = Context.from_request(req, 'source', 'branches/blah/foo.py', 123) + >>> ann.get_annotation_data(context) + [] + + Version in the trunk should match: + >>> context = Context.from_request(req, 'source', 'trunk/foo.py', 123) + >>> data = ann.get_annotation_data(context) + >>> print data + [u'5', u'-', u'0'] + + >>> def annotate_row(lineno, line): + ... row = tag.tr() + ... ann.annotate_row(context, row, lineno, line, data) + ... return row.generate().render('html') + + >>> annotate_row(1, 'x = 1') + '5' + >>> annotate_row(2, '') + '' + >>> annotate_row(3, 'y = x') + '0' + """ + implements(IHTMLPreviewAnnotator) + + # IHTMLPreviewAnnotator methods + + def get_annotation_type(self): + return 'coverage', 'Cov', 'Code coverage' + + def get_annotation_data(self, context): + add_stylesheet(context.req, 'bitten/bitten_coverage.css') + + resource = context.resource + builds = Build.select(self.env, rev=resource.version) + reports = [] + for build in builds: + config = BuildConfig.fetch(self.env, build.config) + if not resource.id.startswith(config.path): + continue + reports = Report.select(self.env, build=build.id, + category='coverage') + path_in_config = resource.id[len(config.path):].lstrip('/') + for report in reports: + for item in report.items: + if item.get('file') == path_in_config: + # TODO should aggregate coverage across builds + return item.get('line_hits', '').split() + return [] + + def annotate_row(self, context, row, lineno, line, data): + self.log.debug('%s', data) + from genshi.builder import tag + lineno -= 1 # 0-based index for data + if lineno >= len(data): + row.append(tag.th()) + return + row_data = data[lineno] + if row_data == '-': + row.append(tag.th()) + elif row_data == '0': + row.append(tag.th(row_data, class_='uncovered')) + else: + row.append(tag.th(row_data, class_='covered')) diff --git a/trac-0.11/bitten/report/testing.py b/trac-0.11/bitten/report/testing.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/report/testing.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +from trac.core import * +from trac.web.chrome import Chrome +from trac.web.clearsilver import HDFWrapper +from bitten.api import IReportChartGenerator, IReportSummarizer + +__docformat__ = 'restructuredtext en' + + +class TestResultsChartGenerator(Component): + implements(IReportChartGenerator) + + # IReportChartGenerator methods + + def get_supported_categories(self): + return ['test'] + + def generate_chart_data(self, req, config, category): + assert category == 'test' + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute(""" +SELECT build.rev, build.platform, 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_time, build.rev, build.platform, item_status.value +ORDER BY build.rev_time, build.platform""", (config.name,)) + + prev_rev = None + prev_platform, platform_total = None, 0 + tests = [] + for rev, platform, status, num in cursor: + if rev != prev_rev: + tests.append([rev, 0, 0]) + prev_rev = rev + platform_total = 0 + if platform != prev_platform: + prev_platform = platform + platform_total = 0 + + platform_total += num + tests[-1][1] = max(platform_total, tests[-1][1]) + if status != 'success': + tests[-1][2] = max(num, tests[-1][2]) + + data = {'title': 'Unit Tests', + 'data': [ + [''] + ['[%s]' % item[0] for item in tests], + ['Total'] + [item[1] for item in tests], + ['Failures'] + [item[2] for item in tests] + ]} + + return 'bitten_chart_tests.html', data + + +class TestResultsSummarizer(Component): + implements(IReportSummarizer) + + # IReportSummarizer methods + + def get_supported_categories(self): + return ['test'] + + def render_summary(self, req, config, build, step, category): + assert category == 'test' + + 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 AND step=%s +GROUP BY file, fixture +ORDER BY fixture""", (build.id, step.name)) + + fixtures = [] + total_success, total_failure, total_error = 0, 0, 0 + for fixture, file, num_success, num_failure, num_error in cursor: + fixtures.append({'name': fixture, 'num_success': num_success, + 'num_error': num_error, + 'num_failure': num_failure}) + total_success += num_success + total_failure += num_failure + total_error += num_error + if file: + fixtures[-1]['href'] = req.href.browser(config.path, file) + + data = {'fixtures': fixtures, + 'totals': {'success': total_success, 'failure': total_failure, + 'error': total_error} + } + return 'bitten_summary_tests.html', data diff --git a/trac-0.11/bitten/report/tests/__init__.py b/trac-0.11/bitten/report/tests/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/report/tests/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import unittest + +from bitten.report.tests import coverage, testing + +def suite(): + suite = unittest.TestSuite() + suite.addTest(coverage.suite()) + suite.addTest(testing.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/report/tests/coverage.py b/trac-0.11/bitten/report/tests/coverage.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/report/tests/coverage.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import doctest +import unittest + +from trac.db import DatabaseManager +from trac.test import EnvironmentStub, Mock +from bitten.model import * +from bitten.report import coverage +from bitten.report.coverage import TestCoverageChartGenerator + +def env_stub_with_tables(): + env = EnvironmentStub() + db = env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + return env + +class TestCoverageChartGeneratorTestCase(unittest.TestCase): + + def setUp(self): + self.env = env_stub_with_tables() + self.env.path = '' + + def test_supported_categories(self): + generator = TestCoverageChartGenerator(self.env) + self.assertEqual(['coverage'], generator.get_supported_categories()) + + def test_no_reports(self): + req = Mock() + config = Mock(name='trunk') + generator = TestCoverageChartGenerator(self.env) + template, data = generator.generate_chart_data(req, config, 'coverage') + self.assertEqual('bitten_chart_coverage.html', template) + self.assertEqual('Test Coverage', data['title']) + self.assertEqual('', data['data'][0][0]) + self.assertEqual('Lines of code', data['data'][1][0]) + self.assertEqual('Coverage', data['data'][2][0]) + + def test_single_platform(self): + config = Mock(name='trunk') + build = Build(self.env, config='trunk', platform=1, rev=123, + rev_time=42) + build.insert() + report = Report(self.env, build=build.id, step='foo', + category='coverage') + report.items += [{'lines': '12', 'percentage': '25'}] + report.insert() + + req = Mock() + generator = TestCoverageChartGenerator(self.env) + template, data = generator.generate_chart_data(req, config, 'coverage') + self.assertEqual('bitten_chart_coverage.html', template) + self.assertEqual('Test Coverage', data['title']) + self.assertEqual('', data['data'][0][0]) + self.assertEqual('[123]', data['data'][0][1]) + self.assertEqual('Lines of code', data['data'][1][0]) + self.assertEqual(12, data['data'][1][1]) + self.assertEqual('Coverage', data['data'][2][0]) + self.assertEqual(3, data['data'][2][1]) + + def test_multi_platform(self): + config = Mock(name='trunk') + build = Build(self.env, config='trunk', platform=1, rev=123, + rev_time=42) + build.insert() + report = Report(self.env, build=build.id, step='foo', + category='coverage') + report.items += [{'lines': '12', 'percentage': '25'}] + report.insert() + build = Build(self.env, config='trunk', platform=2, rev=123, + rev_time=42) + build.insert() + report = Report(self.env, build=build.id, step='foo', + category='coverage') + report.items += [{'lines': '12', 'percentage': '50'}] + report.insert() + + req = Mock() + generator = TestCoverageChartGenerator(self.env) + template, data = generator.generate_chart_data(req, config, 'coverage') + self.assertEqual('bitten_chart_coverage.html', template) + self.assertEqual('Test Coverage', data['title']) + self.assertEqual('', data['data'][0][0]) + self.assertEqual('[123]', data['data'][0][1]) + self.assertEqual('Lines of code', data['data'][1][0]) + self.assertEqual(12, data['data'][1][1]) + self.assertEqual('Coverage', data['data'][2][0]) + self.assertEqual(6, data['data'][2][1]) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestCoverageChartGeneratorTestCase)) + suite.addTest(doctest.DocTestSuite(coverage)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/report/tests/testing.py b/trac-0.11/bitten/report/tests/testing.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/report/tests/testing.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import unittest + +from trac.db import DatabaseManager +from trac.test import EnvironmentStub, Mock +from bitten.model import * +from bitten.report.testing import TestResultsChartGenerator + + +class TestResultsChartGeneratorTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + def test_supported_categories(self): + generator = TestResultsChartGenerator(self.env) + self.assertEqual(['test'], generator.get_supported_categories()) + + def test_no_reports(self): + req = Mock() + config = Mock(name='trunk') + generator = TestResultsChartGenerator(self.env) + template, data = generator.generate_chart_data(req, config, 'test') + self.assertEqual('bitten_chart_tests.html', template) + self.assertEqual('Unit Tests', data['title']) + self.assertEqual('', data['data'][0][0]) + self.assertEqual('Total', data['data'][1][0]) + self.assertEqual('Failures', data['data'][2][0]) + + def test_single_platform(self): + config = Mock(name='trunk') + build = Build(self.env, config='trunk', platform=1, rev=123, + rev_time=42) + build.insert() + report = Report(self.env, build=build.id, step='foo', category='test') + report.items += [{'status': 'success'}, {'status': 'failure'}, + {'status': 'success'}] + report.insert() + + req = Mock() + generator = TestResultsChartGenerator(self.env) + template, data = generator.generate_chart_data(req, config, 'test') + self.assertEqual('bitten_chart_tests.html', template) + self.assertEqual('Unit Tests', data['title']) + self.assertEqual('', data['data'][0][0]) + self.assertEqual('[123]', data['data'][0][1]) + self.assertEqual('Total', data['data'][1][0]) + self.assertEqual(3, data['data'][1][1]) + self.assertEqual('Failures', data['data'][2][0]) + self.assertEqual(1, data['data'][2][1]) + + def test_multi_platform(self): + config = Mock(name='trunk') + + build = Build(self.env, config='trunk', platform=1, rev=123, + rev_time=42) + build.insert() + report = Report(self.env, build=build.id, step='foo', category='test') + report.items += [{'status': 'success'}, {'status': 'failure'}, + {'status': 'success'}] + report.insert() + + build = Build(self.env, config='trunk', platform=2, rev=123, + rev_time=42) + build.insert() + report = Report(self.env, build=build.id, step='foo', category='test') + report.items += [{'status': 'success'}, {'status': 'failure'}, + {'status': 'failure'}] + report.insert() + + req = Mock() + generator = TestResultsChartGenerator(self.env) + template, data = generator.generate_chart_data(req, config, 'test') + self.assertEqual('bitten_chart_tests.html', template) + self.assertEqual('Unit Tests', data['title']) + self.assertEqual('', data['data'][0][0]) + self.assertEqual('[123]', data['data'][0][1]) + self.assertEqual('Total', data['data'][1][0]) + self.assertEqual(3, data['data'][1][1]) + self.assertEqual('Failures', data['data'][2][0]) + self.assertEqual(2, data['data'][2][1]) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestResultsChartGeneratorTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/slave.py b/trac-0.11/bitten/slave.py new file mode 100755 --- /dev/null +++ b/trac-0.11/bitten/slave.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +"""Implementation of the build slave.""" + +from datetime import datetime +import errno +import urllib2 +import logging +import os +import platform +import shutil +import socket +import tempfile +import time + +from bitten.build import BuildError +from bitten.build.config import Configuration +from bitten.recipe import Recipe +from bitten.util import xmlio + +EX_OK = getattr(os, "EX_OK", 0) +EX_UNAVAILABLE = getattr(os, "EX_UNAVAILABLE", 69) +EX_PROTOCOL = getattr(os, "EX_PROTOCOL", 76) + +__all__ = ['BuildSlave', 'ExitSlave'] +__docformat__ = 'restructuredtext en' + +log = logging.getLogger('bitten.slave') + +# List of network errors which are usually temporary and non critical. +temp_net_errors = [errno.ENETUNREACH, errno.ENETDOWN, errno.ETIMEDOUT, + errno.ECONNREFUSED] + +def _rmtree(root): + """Catch shutil.rmtree failures on Windows when files are read-only.""" + def _handle_error(fn, path, excinfo): + os.chmod(path, 0666) + fn(path) + return shutil.rmtree(root, onerror=_handle_error) + +class SaneHTTPErrorProcessor(urllib2.HTTPErrorProcessor): + "The HTTPErrorProcessor defined in urllib needs some love." + + def http_response(self, request, response): + code, msg, hdrs = response.code, response.msg, response.info() + if code >= 300: + response = self.parent.error( + 'http', request, response, code, msg, hdrs) + return response + + +class SaneHTTPRequest(urllib2.Request): + + def __init__(self, method, url, data=None, headers={}): + urllib2.Request.__init__(self, url, data, headers) + self.method = method + + def get_method(self): + if self.method is None: + self.method = self.has_data() and 'POST' or 'GET' + return self.method + + +class BuildSlave(object): + """BEEP initiator implementation for the build slave.""" + + def __init__(self, urls, name=None, config=None, dry_run=False, + work_dir=None, build_dir="build_${build}", + keep_files=False, single_build=False, + poll_interval=300, username=None, password=None, + dump_reports=False): + """Create the build slave instance. + + :param urls: a list of URLs of the build masters to connect to, or a + single-element list containing the path to a build recipe + file + :param name: the name with which this slave should identify itself + :param config: the path to the slave configuration file + :param dry_run: wether the build outcome should not be reported back + to the master + :param work_dir: the working directory to use for build execution + :param build_dir: the pattern to use for naming the build subdir + :param keep_files: whether files and directories created for build + execution should be kept when done + :param single_build: whether this slave should exit after completing a + single build, or continue processing builds forever + :param poll_interval: the time in seconds to wait between requesting + builds from the build master (default is five + minutes) + :param username: the username to use when authentication against the + build master is requested + :param password: the password to use when authentication is needed + :param dump_reports: whether report data should be written to the + standard output, in addition to being transmitted + to the build master + """ + self.urls = urls + self.local = len(urls) == 1 and not urls[0].startswith('http://') \ + and not urls[0].startswith('https://') + if name is None: + name = platform.node().split('.', 1)[0].lower() + self.name = name + self.config = Configuration(config) + self.dry_run = dry_run + if not work_dir: + work_dir = tempfile.mkdtemp(prefix='bitten') + elif not os.path.exists(work_dir): + os.makedirs(work_dir) + self.work_dir = work_dir + self.build_dir = build_dir + self.keep_files = keep_files + self.single_build = single_build + self.poll_interval = poll_interval + self.dump_reports = dump_reports + + if not self.local: + self.opener = urllib2.build_opener(SaneHTTPErrorProcessor) + password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm() + if username and password: + log.debug('Enabling authentication with username %r', username) + password_mgr.add_password(None, urls, username, password) + self.opener.add_handler(urllib2.HTTPBasicAuthHandler(password_mgr)) + self.opener.add_handler(urllib2.HTTPDigestAuthHandler(password_mgr)) + + def request(self, method, url, body=None, headers=None): + log.debug('Sending %s request to %r', method, url) + req = SaneHTTPRequest(method, url, body, headers or {}) + try: + return self.opener.open(req) + except urllib2.HTTPError, e: + if e.code >= 300: + log.warning('Server returned error %d: %s', e.code, e.msg) + raise + return e + + def run(self): + if self.local: + fileobj = open(self.urls[0]) + try: + self._execute_build(None, fileobj) + finally: + fileobj.close() + return EX_OK + + urls = [] + while True: + if not urls: + urls[:] = self.urls + url = urls.pop(0) + try: + try: + job_done = self._create_build(url) + if job_done: + continue + except urllib2.HTTPError, e: + # HTTPError doesn't have the "reason" attribute of URLError + log.error(e) + raise ExitSlave(EX_UNAVAILABLE) + except urllib2.URLError, e: + # Is this a temporary network glitch or something a bit + # more severe? + if isinstance(e.reason, socket.error) and \ + e.reason.args[0] in temp_net_errors: + log.warning(e) + else: + log.error(e) + raise ExitSlave(EX_UNAVAILABLE) + except ExitSlave, e: + return e.exit_code + time.sleep(self.poll_interval) + + def quit(self): + log.info('Shutting down') + raise ExitSlave(EX_OK) + + def _create_build(self, url): + xml = xmlio.Element('slave', name=self.name)[ + xmlio.Element('platform', processor=self.config['processor'])[ + self.config['machine'] + ], + xmlio.Element('os', family=self.config['family'], + version=self.config['version'])[ + self.config['os'] + ], + ] + + log.debug('Configured packages: %s', self.config.packages) + for package, properties in self.config.packages.items(): + xml.append(xmlio.Element('package', name=package, **properties)) + + body = str(xml) + log.debug('Sending slave configuration: %s', body) + resp = self.request('POST', url, body, { + 'Content-Length': len(body), + 'Content-Type': 'application/x-bitten+xml' + }) + + if resp.code == 201: + self._initiate_build(resp.info().get('location')) + return True + elif resp.code == 204: + log.info('No pending builds') + return False + else: + log.error('Unexpected response (%d %s)', resp.code, resp.msg) + raise ExitSlave(EX_PROTOCOL) + + def _initiate_build(self, build_url): + log.info('Build pending at %s', build_url) + try: + resp = self.request('GET', build_url) + if resp.code == 200: + self._execute_build(build_url, resp) + else: + log.error('Unexpected response (%d): %s', resp.code, resp.msg) + self._cancel_build(build_url, exit_code=EX_PROTOCOL) + except KeyboardInterrupt: + log.warning('Build interrupted') + self._cancel_build(build_url) + + def _execute_build(self, build_url, fileobj): + build_id = build_url and int(build_url.split('/')[-1]) or 0 + xml = xmlio.parse(fileobj) + try: + recipe = Recipe(xml, os.path.join(self.work_dir, self.build_dir), + self.config) + basedir = recipe.ctxt.basedir + log.debug('Running build in directory %s' % basedir) + if not os.path.exists(basedir): + os.mkdir(basedir) + + for step in recipe: + log.info('Executing build step %r', step.id) + if not self._execute_step(build_url, recipe, step): + log.warning('Stopping build due to failure') + break + else: + log.info('Build completed') + if self.dry_run: + self._cancel_build(build_url) + finally: + if not self.keep_files: + log.debug('Removing build directory %s' % basedir) + _rmtree(basedir) + if self.single_build: + log.info('Exiting after single build completed.') + raise ExitSlave(EX_OK) + + def _execute_step(self, build_url, recipe, step): + failed = False + started = datetime.utcnow() + xml = xmlio.Element('result', step=step.id, time=started.isoformat()) + try: + for type, category, generator, output in \ + step.execute(recipe.ctxt): + if type == Recipe.ERROR: + failed = True + if type == Recipe.REPORT and self.dump_reports: + print output + xml.append(xmlio.Element(type, category=category, + generator=generator)[ + output + ]) + except KeyboardInterrupt: + log.warning('Build interrupted') + self._cancel_build(build_url) + except BuildError, e: + log.error('Build step %r failed (%s)', step.id, e) + failed = True + except Exception, e: + log.error('Internal error in build step %r', step.id, exc_info=True) + failed = True + xml.attr['duration'] = (datetime.utcnow() - started).seconds + if failed: + xml.attr['status'] = 'failure' + log.warning('Build step %r failed', step.id) + else: + xml.attr['status'] = 'success' + log.info('Build step %s completed successfully', step.id) + + if not self.local and not self.dry_run: + try: + resp = self.request('POST', build_url + '/steps/', str(xml), { + 'Content-Type': 'application/x-bitten+xml' + }) + if resp.code != 201: + log.error('Unexpected response (%d): %s', resp.code, + resp.msg) + except KeyboardInterrupt: + log.warning('Build interrupted') + self._cancel_build(build_url) + + return not failed or step.onerror != 'fail' + + def _cancel_build(self, build_url, exit_code=EX_OK): + log.info('Cancelling build at %s', build_url) + if not self.local: + resp = self.request('DELETE', build_url) + if resp.code not in (200, 204): + log.error('Unexpected response (%d): %s', resp.code, resp.msg) + raise ExitSlave(exit_code) + + +class ExitSlave(Exception): + """Exception used internally by the slave to signal that the slave process + should be stopped. + """ + def __init__(self, exit_code): + self.exit_code = exit_code + Exception.__init__(self) + + +def main(): + """Main entry point for running the build slave.""" + from bitten import __version__ as VERSION + from optparse import OptionParser + + parser = OptionParser(usage='usage: %prog [options] url1 [url2] ...', + version='%%prog %s' % VERSION) + parser.add_option('--name', action='store', dest='name', + help='name of this slave (defaults to host name)') + parser.add_option('-f', '--config', action='store', dest='config', + metavar='FILE', help='path to configuration file') + parser.add_option('-u', '--user', dest='username', + help='the username to use for authentication') + parser.add_option('-p', '--password', dest='password', + help='the password to use when authenticating') + + group = parser.add_option_group('building') + group.add_option('-d', '--work-dir', action='store', dest='work_dir', + metavar='DIR', help='working directory for builds') + group.add_option('--build-dir', action='store', dest='build_dir', + default = 'build_${config}_${build}', + help='name pattern for the build dir to use inside the ' + 'working dir ["%default"]') + group.add_option('-k', '--keep-files', action='store_true', + dest='keep_files', + help='don\'t delete files after builds') + group.add_option('-s', '--single', action='store_true', + dest='single_build', + help='exit after completing a single build') + group.add_option('-n', '--dry-run', action='store_true', dest='dry_run', + help='don\'t report results back to master') + group.add_option('-i', '--interval', dest='interval', metavar='SECONDS', + type='int', help='time to wait between requesting builds') + group = parser.add_option_group('logging') + group.add_option('-l', '--log', dest='logfile', metavar='FILENAME', + help='write log messages to FILENAME') + group.add_option('-v', '--verbose', action='store_const', dest='loglevel', + const=logging.DEBUG, help='print as much as possible') + group.add_option('-q', '--quiet', action='store_const', dest='loglevel', + const=logging.WARN, help='print as little as possible') + group.add_option('--dump-reports', action='store_true', dest='dump_reports', + help='whether report data should be printed') + + parser.set_defaults(dry_run=False, keep_files=False, + loglevel=logging.INFO, single_build=False, + dump_reports=False, interval=300) + options, args = parser.parse_args() + + if len(args) < 1: + parser.error('incorrect number of arguments') + urls = args + + logger = logging.getLogger('bitten') + logger.setLevel(options.loglevel) + handler = logging.StreamHandler() + handler.setLevel(options.loglevel) + formatter = logging.Formatter('[%(levelname)-8s] %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + if options.logfile: + handler = logging.FileHandler(options.logfile) + handler.setLevel(options.loglevel) + formatter = logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: ' + '%(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + + slave = BuildSlave(urls, name=options.name, config=options.config, + dry_run=options.dry_run, work_dir=options.work_dir, + build_dir=options.build_dir, + keep_files=options.keep_files, + single_build=options.single_build, + poll_interval=options.interval, + username=options.username, password=options.password, + dump_reports=options.dump_reports) + try: + try: + exit_code = slave.run() + except KeyboardInterrupt: + slave.quit() + except ExitSlave, e: + exit_code = e.exit_code + + if not options.work_dir: + log.debug('Removing temporary directory %s' % slave.work_dir) + _rmtree(slave.work_dir) + return exit_code + +if __name__ == '__main__': + sys.exit(main()) diff --git a/trac-0.11/bitten/templates/bitten_admin_configs.html b/trac-0.11/bitten/templates/bitten_admin_configs.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_admin_configs.html @@ -0,0 +1,240 @@ + + + + + Manage Build Configurations + + +

    Manage Build Configurations

    +
    + + + + + + + +
    + +

    + +

    + +
    + +

    + +

    +
    +
    + Repository Mapping + + + + + + + + +
    +
    +
    + + +
    +
    +

    Target Platforms

    + + + + + + + + + + + + + + + +
     NameRules
    (No Platforms)
    + + + $platform.name + +
      +
    • + + $rule.property ~= + $rule.pattern + +
    • +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    + Rules + + + + + + + + +
    Property nameMatch pattern
    + +
    +
    +

    + The property name can be any of a set of standard + default properties, or custom properties defined + in slave configuration files. The default + properties are: +

    +
    +
    os:
    +
    The name of the operating system (for example + "Darwin")
    +
    family:
    +
    The type of operating system (for example + "posix" or "nt")
    +
    version:
    +
    The operating system version (for example + "8.10.1)
    +
    machine:
    +
    The hardware architecture (for example "i386"
    +
    processor:
    +
    The CPU model (for example "i386", this may be + empty or the same as for machine +
    +
    name:
    +
    The name of the slave
    +
    ipnr:
    +
    The IP address of the slave
    +
    +

    + The match pattern is a regular expression. +

    +
    +
    + + + + + +
    +
    + + +
    + Add Configuration: + + + + + +
    + +
    +
    + +
    +
    + +
    + + + + + + + + + + + + + + +
     NamePathActive
    (No Build Configurations)
    + + + $config.label + $config.path + +
    +
    + + +
    +
    + + diff --git a/trac-0.11/bitten/templates/bitten_admin_master.html b/trac-0.11/bitten/templates/bitten_admin_master.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_admin_master.html @@ -0,0 +1,73 @@ + + + + + Manage Build Master + + +

    Manage Build Master

    + +
    +
    + Configuration Options +
    + +
    +

    + Whether to build older revisions even when a more recent + revision has already been built. +

    +
    + +
    +

    + Whether the timestamps of builds should be adjusted to be + close to the timestamps of the corresponding changesets. +

    +
    +
    + +
    +

    + The time in seconds to wait for the repository to stabilize + after a check-in before initiating a build. +

    +
    +
    + +
    +

    + The timeout in seconds after which a build started by a slave + is considered aborted, in case there has been no activity from + that slave in that time. +

    +
    + +
    + +
    +
    + + diff --git a/trac-0.11/bitten/templates/bitten_build.html b/trac-0.11/bitten/templates/bitten_build.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_build.html @@ -0,0 +1,73 @@ + + + + + $title + + +
    +

    $title

    +
    +
    Configuration:
    +
    + $build.config.name +
    +
    Triggered by:
    +
    + Changeset [$build.rev] by + $build.chgset_author +
    +
    Built by:
    +
    + $slave.name ($slave.ipnr) +
    +
    Operating system:
    +
    $slave.os_name $slave.os_version ($slave.os_family)
    +
    Hardware:
    +
    + $slave.machine + ($slave.processor) +
    +
    + ${build.stopped and 'Started:' or 'Building since:'} +
    +
    $build.started ($build.started_delta ago)
    +
    Stopped:
    +
    $build.stopped ($build.stopped_delta ago)
    +
    Duration:
    +
    $build.duration
    +
    +
    +
    + + +
    +
    +

    $step.name ($step.duration)

    +
    +

    Errors

    +
      +
    • $error
    • +
    +
    +

    $step.description

    +
    +
    +

    Log

    +
    $item.message
    +
    +
    + +
    +
    +
    +
    + + diff --git a/trac-0.11/bitten/templates/bitten_chart_coverage.html b/trac-0.11/bitten/templates/bitten_chart_coverage.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_chart_coverage.html @@ -0,0 +1,38 @@ + + + area + area + + + + + + + + + $value + $value + + + + + + + + + + bbbbbb + 9999ff + + + + + + + $title + + + diff --git a/trac-0.11/bitten/templates/bitten_chart_tests.html b/trac-0.11/bitten/templates/bitten_chart_tests.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_chart_tests.html @@ -0,0 +1,38 @@ + + + area + column + + + + + + + + + $value + $value + + + + + + + + + + 99dd99 + ff0000 + + + + + + + $title + + + diff --git a/trac-0.11/bitten/templates/bitten_config.html b/trac-0.11/bitten/templates/bitten_config.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_config.html @@ -0,0 +1,178 @@ + + + + + $title + + + + + Success + Failed + In-progress + + + +
    + $slave.name ($slave.ipnr)
    + $slave.os_name $slave.os_version + / + ${slave.processor or slave.machine or ''} +
    + + + +
    +

    $title

    +
    +
    + + +
    +
    + +
    +
    +

    + $config.label +

    +
    + $config.description +
    +

    Latest builds

    + + + +
    + [$youngest_rev.id] + by $youngest_rev.author

    $youngest_rev.date

    +

    $youngest_rev.message

    +
    + + $build.platform +

    $build.stopped

    + ${slave_info(build.slave)} + ${build_status(build.status)} +
    + $build.platform +

    No build yet

    +
    +
    + +
    +
    +
    + This build configuration is currently inactive.
    + No builds will be initiated for this configuration
    + until it is activated. +
    +
    + This configuration is currently active. +
    +
    + + + +
    +
    +

    + Repository path: + $config.path + ${not config.path and '—' or ''} + + (starting at + [$config.min_rev] + , + up to + [$config.max_rev]) + +

    +
    + $config.description +
    +
    + + + + +
    +
    + + + + + + + + + + + + + +
    Chgset$platform.name
    + [$rev_num] + +
    + + $build.id + ${build_status(build.status)} + + ${slave_info(build.slave)} +
    + ${build_steps(build.steps)} +
    +
    + +
    + +

    + $config.label +

    + + + + + + + + +
    ChgsetBuild
    + [$build.rev] + +
    + + $build.id: $build.platform + + ${slave_info(build.slave)} +
    + ${build_steps(build.steps)} +
    +
    + + diff --git a/trac-0.11/bitten/templates/bitten_summary_coverage.html b/trac-0.11/bitten/templates/bitten_summary_coverage.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_summary_coverage.html @@ -0,0 +1,27 @@ + + + +

    Code Coverage

    + + + + + + + + + + + + + + +
    UnitLines of CodeCoverage
    + $item.name + $item.name + $item.loc${item.cov}%
    Total$totals.loc${totals.cov}%
    + + diff --git a/trac-0.11/bitten/templates/bitten_summary_tests.html b/trac-0.11/bitten/templates/bitten_summary_tests.html new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/templates/bitten_summary_tests.html @@ -0,0 +1,31 @@ + + + +

    Test Results

    + + + + + + + + + + + + + + + + + +
    Test FixtureTotalFailuresErrors
    + $item.name + $item.name + ${item.num_success + item.num_failure + item.num_error}$item.num_failure$item.num_error
    Total$totals.success$totals.failure$totals.error
    + + diff --git a/trac-0.11/bitten/tests/__init__.py b/trac-0.11/bitten/tests/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/__init__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import unittest + +from bitten.tests import admin, master, model, recipe, queue, slave, web_ui +from bitten.build import tests as build +from bitten.report import tests as report +from bitten.util import tests as util + +def suite(): + suite = unittest.TestSuite() + suite.addTest(admin.suite()) + suite.addTest(master.suite()) + suite.addTest(model.suite()) + suite.addTest(recipe.suite()) + suite.addTest(queue.suite()) + suite.addTest(slave.suite()) + suite.addTest(web_ui.suite()) + suite.addTest(build.suite()) + suite.addTest(report.suite()) + suite.addTest(util.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/admin.py b/trac-0.11/bitten/tests/admin.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/admin.py @@ -0,0 +1,810 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import shutil +import tempfile +import unittest + +from trac.core import TracError +from trac.db import DatabaseManager +from trac.perm import PermissionCache, PermissionError, PermissionSystem +from trac.test import EnvironmentStub, Mock +from trac.web.href import Href +from trac.web.main import RequestDone +from bitten.main import BuildSystem +from bitten.model import BuildConfig, TargetPlatform, schema +from bitten.admin import BuildMasterAdminPageProvider, \ + BuildConfigurationsAdminPageProvider + +try: + from trac.perm import DefaultPermissionPolicy +except ImportError: + DefaultPermissionPolicy = None + +class BuildMasterAdminPageProviderTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub(enable=['trac.*', 'bitten.*']) + self.env.path = tempfile.mkdtemp() + + # Create tables + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + # Set up permissions + self.env.config.set('trac', 'permission_store', + 'DefaultPermissionStore') + PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN') + if DefaultPermissionPolicy is not None and hasattr(DefaultPermissionPolicy, "CACHE_EXPIRY"): + self.old_perm_cache_expiry = DefaultPermissionPolicy.CACHE_EXPIRY + DefaultPermissionPolicy.CACHE_EXPIRY = -1 + + # Hook up a dummy repository + self.repos = Mock( + get_node=lambda path, rev=None: Mock(get_history=lambda: [], + isdir=True), + normalize_path=lambda path: path, + sync=lambda: None + ) + self.env.get_repository = lambda authname=None: self.repos + + def tearDown(self): + if DefaultPermissionPolicy is not None and hasattr(DefaultPermissionPolicy, "CACHE_EXPIRY"): + DefaultPermissionPolicy.CACHE_EXPIRY = self.old_perm_cache_expiry + shutil.rmtree(self.env.path) + + def test_get_admin_panels(self): + provider = BuildMasterAdminPageProvider(self.env) + + req = Mock(perm=PermissionCache(self.env, 'joe')) + self.assertEqual([('bitten', 'Builds', 'master', 'Master Settings')], + list(provider.get_admin_panels(req))) + + PermissionSystem(self.env).revoke_permission('joe', 'BUILD_ADMIN') + req = Mock(perm=PermissionCache(self.env, 'joe')) + self.assertEqual([], list(provider.get_admin_panels(req))) + + def test_process_get_request(self): + req = Mock(method='GET', chrome={}, href=Href('/'), + perm=PermissionCache(self.env, 'joe')) + + provider = BuildMasterAdminPageProvider(self.env) + template_name, data = provider.render_admin_panel( + req, 'bitten', 'master', '' + ) + + self.assertEqual('bitten_admin_master.html', template_name) + assert 'master' in data + master = data['master'] + self.assertEqual(3600, master.slave_timeout) + self.assertEqual(0, master.stabilize_wait) + assert not master.adjust_timestamps + assert not master.build_all + + def test_process_config_changes(self): + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + args={'slave_timeout': '60', 'adjust_timestamps': ''}) + + provider = BuildMasterAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'master', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/master', + redirected_to[0]) + section = self.env.config['bitten'] + self.assertEqual(60, section.getint('slave_timeout')) + self.assertEqual(True, section.getbool('adjust_timestamps')) + self.assertEqual(False, section.getbool('build_all')) + + +class BuildConfigurationsAdminPageProviderTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub(enable=['trac.*', 'bitten.*']) + self.env.path = tempfile.mkdtemp() + + # Create tables + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + # Set up permissions + self.env.config.set('trac', 'permission_store', + 'DefaultPermissionStore') + PermissionSystem(self.env).grant_permission('joe', 'BUILD_CREATE') + PermissionSystem(self.env).grant_permission('joe', 'BUILD_DELETE') + PermissionSystem(self.env).grant_permission('joe', 'BUILD_MODIFY') + if DefaultPermissionPolicy is not None and hasattr(DefaultPermissionPolicy, "CACHE_EXPIRY"): + self.old_perm_cache_expiry = DefaultPermissionPolicy.CACHE_EXPIRY + DefaultPermissionPolicy.CACHE_EXPIRY = -1 + + # Hook up a dummy repository + self.repos = Mock( + get_node=lambda path, rev=None: Mock(get_history=lambda: [], + isdir=True), + normalize_path=lambda path: path, + sync=lambda: None + ) + self.env.get_repository = lambda authname=None: self.repos + + def tearDown(self): + if DefaultPermissionPolicy is not None and hasattr(DefaultPermissionPolicy, "CACHE_EXPIRY"): + DefaultPermissionPolicy.CACHE_EXPIRY = self.old_perm_cache_expiry + shutil.rmtree(self.env.path) + + def test_get_admin_panels(self): + provider = BuildConfigurationsAdminPageProvider(self.env) + + req = Mock(perm=PermissionCache(self.env, 'joe')) + self.assertEqual([('bitten', 'Builds', 'configs', 'Configurations')], + list(provider.get_admin_panels(req))) + + PermissionSystem(self.env).revoke_permission('joe', 'BUILD_MODIFY') + req = Mock(perm=PermissionCache(self.env, 'joe')) + self.assertEqual([], list(provider.get_admin_panels(req))) + + def test_process_view_configs_empty(self): + req = Mock(method='GET', chrome={}, href=Href('/'), + perm=PermissionCache(self.env, 'joe')) + + provider = BuildConfigurationsAdminPageProvider(self.env) + template_name, data = provider.render_admin_panel( + req, 'bitten', 'configs', '' + ) + + self.assertEqual('bitten_admin_configs.html', template_name) + self.assertEqual([], data['configs']) + + def test_process_view_configs(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + BuildConfig(self.env, name='bar', label='Bar', path='branches/bar', + min_rev='123', max_rev='456').insert() + + req = Mock(method='GET', chrome={}, href=Href('/'), + perm=PermissionCache(self.env, 'joe')) + + provider = BuildConfigurationsAdminPageProvider(self.env) + template_name, data = provider.render_admin_panel( + req, 'bitten', 'configs', '' + ) + + self.assertEqual('bitten_admin_configs.html', template_name) + assert 'configs' in data + configs = data['configs'] + self.assertEqual(2, len(configs)) + self.assertEqual({ + 'name': 'bar', 'href': '/admin/bitten/configs/bar', + 'label': 'Bar', 'min_rev': '123', 'max_rev': '456', + 'path': 'branches/bar', 'active': False + }, configs[0]) + self.assertEqual({ + 'name': 'foo', 'href': '/admin/bitten/configs/foo', + 'label': 'Foo', 'min_rev': None, 'max_rev': None, + 'path': 'branches/foo', 'active': True + }, configs[1]) + + def test_process_view_config(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + TargetPlatform(self.env, config='foo', name='any').insert() + + req = Mock(method='GET', chrome={}, href=Href('/'), + perm=PermissionCache(self.env, 'joe')) + + provider = BuildConfigurationsAdminPageProvider(self.env) + template_name, data = provider.render_admin_panel( + req, 'bitten', 'configs', 'foo' + ) + + self.assertEqual('bitten_admin_configs.html', template_name) + assert 'config' in data + config = data['config'] + self.assertEqual({ + 'name': 'foo', 'label': 'Foo', 'description': '', 'recipe': '', + 'path': 'branches/foo', 'min_rev': None, 'max_rev': None, + 'active': True, 'platforms': [{ + 'href': '/admin/bitten/configs/foo/1', + 'name': 'any', 'id': 1, 'rules': [] + }] + }, config) + + def test_process_activate_config(self): + BuildConfig(self.env, name='foo', path='branches/foo').insert() + BuildConfig(self.env, name='bar', path='branches/bar').insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'apply': '', 'active': ['foo']}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs', + redirected_to[0]) + config = BuildConfig.fetch(self.env, name='foo') + self.assertEqual(True, config.active) + + def test_process_deactivate_config(self): + BuildConfig(self.env, name='foo', path='branches/foo', + active=True).insert() + BuildConfig(self.env, name='bar', path='branches/bar', + active=True).insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'apply': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs', + redirected_to[0]) + config = BuildConfig.fetch(self.env, name='foo') + self.assertEqual(False, config.active) + config = BuildConfig.fetch(self.env, name='bar') + self.assertEqual(False, config.active) + + def test_process_add_config(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'add': '', 'name': 'bar', 'label': 'Bar'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs/bar', + redirected_to[0]) + config = BuildConfig.fetch(self.env, name='bar') + self.assertEqual('Bar', config.label) + + def test_process_add_config_cancel(self): + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + args={'cancel': '', 'name': 'bar', 'label': 'Bar'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs', + redirected_to[0]) + configs = list(BuildConfig.select(self.env, include_inactive=True)) + self.assertEqual(0, len(configs)) + + def test_process_add_config_no_name(self): + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + args={'add': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('Missing required field "name"', e.message) + self.assertEqual('Missing Field', e.title) + + def test_process_add_config_invalid_name(self): + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + args={'add': '', 'name': 'no spaces allowed'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('The field "name" may only contain letters, ' + 'digits, periods, or dashes.', e.message) + self.assertEqual('Invalid Field', e.title) + + def test_new_config_submit_with_invalid_path(self): + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + authname='joe', + args={'add': '', 'name': 'foo', 'path': 'invalid/path'}) + + def get_node(path, rev=None): + raise TracError('No such node') + self.repos = Mock(get_node=get_node) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('No such node', e.message) + self.assertEqual('Invalid Repository Path', e.title) + + def test_process_add_config_no_perms(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + PermissionSystem(self.env).revoke_permission('joe', 'BUILD_CREATE') + + req = Mock(method='POST', + perm=PermissionCache(self.env, 'joe'), + args={'add': '', 'name': 'bar', 'label': 'Bar'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + self.assertRaises(PermissionError, provider.render_admin_panel, req, + 'bitten', 'configs', '') + + def test_process_remove_config(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + BuildConfig(self.env, name='bar', label='Bar', path='branches/bar', + min_rev='123', max_rev='456').insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + args={'remove': '', 'sel': 'bar'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs', + redirected_to[0]) + assert not BuildConfig.fetch(self.env, name='bar') + + def test_process_remove_config_cancel(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + BuildConfig(self.env, name='bar', label='Bar', path='branches/bar', + min_rev='123', max_rev='456').insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + args={'cancel': '', 'sel': 'bar'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs', + redirected_to[0]) + configs = list(BuildConfig.select(self.env, include_inactive=True)) + self.assertEqual(2, len(configs)) + + def test_process_remove_config_no_selection(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + args={'remove': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('No configuration selected', e.message) + + def test_process_remove_config_bad_selection(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + args={'remove': '', 'sel': 'baz'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', '') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual("Configuration 'baz' not found", e.message) + + def test_process_remove_config_no_perms(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + PermissionSystem(self.env).revoke_permission('joe', 'BUILD_DELETE') + + req = Mock(method='POST', + perm=PermissionCache(self.env, 'joe'), + args={'remove': '', 'sel': 'bar'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + self.assertRaises(PermissionError, provider.render_admin_panel, req, + 'bitten', 'configs', '') + + def test_process_update_config(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', args={ + 'save': '', 'name': 'foo', 'label': 'Foobar', + 'description': 'Thanks for all the fish!' + }) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs', + redirected_to[0]) + config = BuildConfig.fetch(self.env, name='foo') + self.assertEqual('Foobar', config.label) + self.assertEqual('Thanks for all the fish!', config.description) + + def test_process_update_config_no_name(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + args={'save': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('Missing required field "name"', e.message) + self.assertEqual('Missing Field', e.title) + + def test_process_update_config_invalid_name(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + args={'save': '', 'name': 'no spaces allowed'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('The field "name" may only contain letters, ' + 'digits, periods, or dashes.', e.message) + self.assertEqual('Invalid Field', e.title) + + def test_process_update_config_invalid_path(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + authname='joe', + args={'save': '', 'name': 'foo', 'path': 'invalid/path'}) + + def get_node(path, rev=None): + raise TracError('No such node') + self.repos = Mock(get_node=get_node) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('No such node', e.message) + self.assertEqual('Invalid Repository Path', e.title) + + def test_process_update_config_non_wellformed_recipe(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + authname='joe', + args={'save': '', 'name': 'foo', 'recipe': 'not_xml'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('Failure parsing recipe: syntax error: line 1, ' + 'column 0', e.message) + self.assertEqual('Invalid Recipe', e.title) + + def test_process_update_config_invalid_recipe(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + authname='joe', + args={'save': '', 'name': 'foo', + 'recipe': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('Steps must have an "id" attribute', e.message) + self.assertEqual('Invalid Recipe', e.title) + + def test_process_new_platform(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + data = {} + req = Mock(method='POST', chrome={}, hdf=data, href=Href('/'), + perm=PermissionCache(self.env, 'joe'), + args={'new': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + template_name, data = provider.render_admin_panel( + req, 'bitten', 'configs', 'foo' + ) + + self.assertEqual('bitten_admin_configs.html', template_name) + assert 'platform' in data + platform = data['platform'] + self.assertEqual({ + 'id': None, 'exists': False, 'name': None, 'rules': [('', '')], + }, platform) + + def test_process_add_platform(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'add': '', 'new': '', 'name': 'Test', + 'property_0': 'family', 'pattern_0': 'posix'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs/foo', + redirected_to[0]) + platforms = list(TargetPlatform.select(self.env, config='foo')) + self.assertEqual(1, len(platforms)) + self.assertEqual('Test', platforms[0].name) + self.assertEqual([('family', 'posix')], platforms[0].rules) + + def test_process_add_platform_cancel(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'cancel': '', 'new': '', 'name': 'Test', + 'property_0': 'family', 'pattern_0': 'posix'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs/foo', + redirected_to[0]) + platforms = list(TargetPlatform.select(self.env, config='foo')) + self.assertEqual(0, len(platforms)) + + def test_process_remove_platforms(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + platform = TargetPlatform(self.env, config='foo', name='any') + platform.insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'remove': '', 'sel': str(platform.id)}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs/foo', + redirected_to[0]) + platforms = list(TargetPlatform.select(self.env, config='foo')) + self.assertEqual(0, len(platforms)) + + def test_process_remove_platforms_no_selection(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + platform = TargetPlatform(self.env, config='foo', name='any') + platform.insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'remove': ''}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', 'foo') + self.fail('Expected TracError') + + except TracError, e: + self.assertEqual('No platform selected', e.message) + + def test_process_edit_platform(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + platform = TargetPlatform(self.env, config='foo', name='any') + platform.insert() + + req = Mock(method='GET', chrome={}, href=Href('/'), + perm=PermissionCache(self.env, 'joe'), args={}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + template_name, data = provider.render_admin_panel( + req, 'bitten', 'configs', 'foo/%d' % platform.id + ) + + self.assertEqual('bitten_admin_configs.html', template_name) + assert 'platform' in data + platform = data['platform'] + self.assertEqual({ + 'id': 1, 'exists': True, 'name': 'any', 'rules': [('', '')], + }, platform) + + def test_process_update_platform(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + platform = TargetPlatform(self.env, config='foo', name='any') + platform.insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'save': '', 'edit': '', 'name': 'Test', + 'property_0': 'family', 'pattern_0': 'posix'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', + 'foo/%d' % platform.id) + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs/foo', + redirected_to[0]) + platforms = list(TargetPlatform.select(self.env, config='foo')) + self.assertEqual(1, len(platforms)) + self.assertEqual('Test', platforms[0].name) + self.assertEqual([('family', 'posix')], platforms[0].rules) + + def test_process_update_platform_cancel(self): + BuildConfig(self.env, name='foo', label='Foo', path='branches/foo', + active=True).insert() + platform = TargetPlatform(self.env, config='foo', name='any') + platform.insert() + + redirected_to = [] + def redirect(url): + redirected_to.append(url) + raise RequestDone + req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'), + abs_href=Href('http://example.org/'), redirect=redirect, + authname='joe', + args={'cancel': '', 'edit': '', 'name': 'Changed', + 'property_0': 'family', 'pattern_0': 'posix'}) + + provider = BuildConfigurationsAdminPageProvider(self.env) + try: + provider.render_admin_panel(req, 'bitten', 'configs', + 'foo/%d' % platform.id) + self.fail('Expected RequestDone') + + except RequestDone: + self.assertEqual('http://example.org/admin/bitten/configs/foo', + redirected_to[0]) + platforms = list(TargetPlatform.select(self.env, config='foo')) + self.assertEqual(1, len(platforms)) + self.assertEqual('any', platforms[0].name) + self.assertEqual([], platforms[0].rules) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite( + BuildMasterAdminPageProviderTestCase, 'test' + )) + suite.addTest(unittest.makeSuite( + BuildConfigurationsAdminPageProviderTestCase, 'test' + )) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/master.py b/trac-0.11/bitten/tests/master.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/master.py @@ -0,0 +1,748 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import re +import shutil +from StringIO import StringIO +import tempfile +import unittest + +from trac.db import DatabaseManager +from trac.perm import PermissionCache, PermissionSystem +from trac.test import EnvironmentStub, Mock +from trac.web.api import HTTPBadRequest, HTTPMethodNotAllowed, HTTPNotFound, \ + HTTPForbidden, RequestDone +from trac.web.href import Href + +from bitten.master import BuildMaster +from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \ + BuildLog, Report, schema + + +class BuildMasterTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub(enable=['trac.*', 'bitten.*']) + self.env.path = tempfile.mkdtemp() + + PermissionSystem(self.env).grant_permission('hal', 'BUILD_EXEC') + + # Create tables + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + self.repos = Mock() + self.env.get_repository = lambda authname=None: self.repos + + def tearDown(self): + shutil.rmtree(self.env.path) + + def test_create_build(self): + BuildConfig(self.env, 'test', path='somepath', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('family', 'posix')) + platform.insert() + + self.repos = Mock( + get_node=lambda path, rev=None: Mock( + get_entries=lambda: [Mock(), Mock()], + get_history=lambda: [('somepath', 123, 'edit'), + ('somepath', 121, 'edit'), + ('somepath', 120, 'edit')] + ), + get_changeset=lambda rev: Mock(date=42), + normalize_path=lambda path: path, + rev_older_than=lambda rev1, rev2: rev1 < rev2 + ) + + inheaders = {'Content-Type': 'application/x-bitten+xml'} + inbody = StringIO(""" + Power Macintosh + Darwin + +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', path_info='/builds', + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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) + + def test_create_build_invalid_xml(self): + inheaders = {'Content-Type': 'application/x-bitten+xml'} + inbody = 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) + + 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) + + def test_create_build_no_post(self): + req = Mock(method='GET', base_path='', path_info='/builds', + href=Href('/trac'), remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal')) + 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) + + def test_create_build_no_match(self): + inheaders = {'Content-Type': 'application/x-bitten+xml'} + inbody = StringIO(""" + Power Macintosh + Darwin +""") + 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, + 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 RequestDone') + except RequestDone: + self.assertEqual(204, outheaders['Status']) + self.assertEqual('', outbody.getvalue()) + + def test_cancel_build(self): + config = BuildConfig(self.env, 'test', path='somepath', active=True, + recipe='') + config.insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + status=Build.IN_PROGRESS, started=42) + build.insert() + + outheaders = {} + outbody = StringIO() + req = Mock(method='DELETE', base_path='', + path_info='/builds/%d' % build.id, + href=Href('/trac'), remote_addr='127.0.0.1', args={}, + 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 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 + + def test_initiate_build(self): + config = BuildConfig(self.env, 'test', path='somepath', active=True, + recipe='') + config.insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('family', 'posix')) + platform.insert() + build = Build(self.env, 'test', '123', platform.id, slave='hal', + rev_time=42) + build.insert() + + outheaders = {} + outbody = StringIO() + + req = Mock(method='GET', base_path='', + path_info='/builds/%d' % build.id, + href=Href('/trac'), remote_addr='127.0.0.1', args={}, + 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 RequestDone') + except RequestDone: + self.assertEqual(200, outheaders['Status']) + self.assertEqual('63', 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('', + 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): + 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')) + + 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) + + def test_process_unknown_collection(self): + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe='').insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42) + build.insert() + + 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')) + + 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) + + def test_process_build_step_success(self): + recipe = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + build.insert() + + inbody = StringIO(""" +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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) + + def test_process_build_step_success_with_log(self): + recipe = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + build.insert() + + inbody = StringIO(""" + + Doing stuff + Ouch that hurt + +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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) + + 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 = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + build.insert() + + inbody = StringIO(""" + + + Doing my thing + + +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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) + + 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_wrong_slave(self): + recipe = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '192.168.1.1'; + build.insert() + + inbody = StringIO(""" + + Doing stuff + Ouch that hurt + +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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 + + steps = list(BuildStep.select(self.env, build.id)) + self.assertEqual(0, len(steps)) + + + def test_process_build_step_invalidated_build(self): + recipe = """ + + + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + build.insert() + + inbody = StringIO(""" + + Doing stuff + Ouch that hurt + +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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)) + + # invalidate the build. + + build = Build.fetch(self.env, build.id) + build.slave = None + build.status = Build.PENDING + build.update() + + # have this slave submit more data. + inbody = StringIO(""" + + This is a step after invalidation + +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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('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) + + def test_process_build_step_failure(self): + recipe = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + build.insert() + + inbody = StringIO(""" +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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 + + 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 = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + + build.insert() + + inbody = StringIO(""" +""") + outheaders = {} + outbody = StringIO() + req = Mock(method='POST', base_path='', + path_info='/builds/%d/steps/' % build.id, + href=Href('/trac'), abs_href=Href('http://example.org/trac'), + remote_addr='127.0.0.1', args={}, + perm=PermissionCache(self.env, 'hal'), + 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 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.FAILURE, steps[0].status) + + def test_process_build_step_invalid_xml(self): + recipe = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42) + build.insert() + + inbody = 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) + + 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) + + def test_process_build_step_invalid_datetime(self): + recipe = """ + + +""" + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe=recipe).insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42, status=Build.IN_PROGRESS) + build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'; + build.insert() + + inbody = 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) + + 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) + + def test_process_build_step_no_post(self): + BuildConfig(self.env, 'test', path='somepath', active=True, + recipe='').insert() + build = Build(self.env, 'test', '123', 1, slave='hal', rev_time=42, + started=42) + build.insert() + + 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')) + + 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) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(BuildMasterTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/model.py b/trac-0.11/bitten/tests/model.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/model.py @@ -0,0 +1,733 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import unittest + +from trac.db import DatabaseManager +from trac.test import EnvironmentStub +from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \ + BuildLog, Report, schema + + +class BuildConfigTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + db.commit() + + def test_new(self): + config = BuildConfig(self.env, name='test') + assert not config.exists + + def test_fetch(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,label,active) " + "VALUES (%s,%s,%s,%s)", ('test', 'trunk', 'Test', 0)) + config = BuildConfig.fetch(self.env, name='test') + assert config.exists + self.assertEqual('test', config.name) + self.assertEqual('trunk', config.path) + self.assertEqual('Test', config.label) + self.assertEqual(False, config.active) + + def test_fetch_none(self): + config = BuildConfig.fetch(self.env, name='test') + self.assertEqual(None, config) + + def test_select_none(self): + configs = BuildConfig.select(self.env) + self.assertRaises(StopIteration, configs.next) + + def test_select_none(self): + configs = BuildConfig.select(self.env) + self.assertRaises(StopIteration, configs.next) + + def test_insert(self): + config = BuildConfig(self.env, name='test', path='trunk', label='Test') + config.insert() + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT name,path,label,active,description " + "FROM bitten_config") + self.assertEqual(('test', 'trunk', 'Test', 0, ''), cursor.fetchone()) + + def test_insert_no_name(self): + config = BuildConfig(self.env) + self.assertRaises(AssertionError, config.insert) + + def test_update(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,label,active) " + "VALUES (%s,%s,%s,%s)", ('test', 'trunk', 'Test', 0)) + + config = BuildConfig.fetch(self.env, 'test') + config.path = 'some_branch' + config.label = 'Updated' + config.active = True + config.description = 'Bla bla bla' + config.update() + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT name,path,label,active,description " + "FROM bitten_config") + self.assertEqual(('test', 'some_branch', 'Updated', 1, 'Bla bla bla'), + cursor.fetchone()) + self.assertEqual(None, cursor.fetchone()) + + def test_update_name(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,label,active) " + "VALUES (%s,%s,%s,%s)", ('test', 'trunk', 'Test', 0)) + + config = BuildConfig.fetch(self.env, 'test') + config.name = 'foobar' + config.update() + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT name,path,label,active,description " + "FROM bitten_config") + self.assertEqual(('foobar', 'trunk', 'Test', 0, ''), cursor.fetchone()) + self.assertEqual(None, cursor.fetchone()) + + def test_update_no_name(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,label,active) " + "VALUES (%s,%s,%s,%s)", ('test', 'trunk', 'Test', 0)) + + config = BuildConfig.fetch(self.env, 'test') + config.name = None + self.assertRaises(AssertionError, config.update) + + def test_update_name_with_platform(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,label,active) " + "VALUES (%s,%s,%s,%s)", ('test', 'trunk', 'Test', 0)) + cursor.execute("INSERT INTO bitten_platform (config,name) " + "VALUES (%s,%s)", ('test', 'NetBSD')) + + config = BuildConfig.fetch(self.env, 'test') + config.name = 'foobar' + config.update() + + cursor.execute("SELECT config,name FROM bitten_platform") + self.assertEqual(('foobar', 'NetBSD'), cursor.fetchone()) + self.assertEqual(None, cursor.fetchone()) + + def test_delete(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_config (name,path,label,active) " + "VALUES (%s,%s,%s,%s)", ('test', 'trunk', 'Test', 0)) + + config = BuildConfig.fetch(self.env, 'test') + config.delete() + self.assertEqual(False, config.exists) + + cursor.execute("SELECT * FROM bitten_config WHERE name=%s", ('test',)) + self.assertEqual(None, cursor.fetchone()) + + def test_delete_non_existing(self): + config = BuildConfig(self.env, 'test') + self.assertRaises(AssertionError, config.delete) + + +class TargetPlatformTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in TargetPlatform._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + db.commit() + + def test_new(self): + platform = TargetPlatform(self.env) + self.assertEqual(False, platform.exists) + self.assertEqual([], platform.rules) + + def test_insert(self): + platform = TargetPlatform(self.env, config='test', name='Windows XP') + platform.rules += [(Build.OS_NAME, 'Windows'), (Build.OS_VERSION, 'XP')] + platform.insert() + + assert platform.exists + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT config,name FROM bitten_platform " + "WHERE id=%s", (platform.id,)) + self.assertEqual(('test', 'Windows XP'), cursor.fetchone()) + + cursor.execute("SELECT propname,pattern,orderno FROM bitten_rule " + "WHERE id=%s", (platform.id,)) + self.assertEqual((Build.OS_NAME, 'Windows', 0), cursor.fetchone()) + self.assertEqual((Build.OS_VERSION, 'XP', 1), cursor.fetchone()) + + def test_fetch(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_platform (config,name) " + "VALUES (%s,%s)", ('test', 'Windows')) + id = db.get_last_id(cursor, 'bitten_platform') + platform = TargetPlatform.fetch(self.env, id) + assert platform.exists + self.assertEqual('test', platform.config) + self.assertEqual('Windows', platform.name) + + def test_select(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.executemany("INSERT INTO bitten_platform (config,name) " + "VALUES (%s,%s)", [('test', 'Windows'), + ('test', 'Mac OS X')]) + platforms = list(TargetPlatform.select(self.env, config='test')) + self.assertEqual(2, len(platforms)) + + +class BuildTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in Build._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + db.commit() + + def test_new(self): + build = Build(self.env) + self.assertEqual(None, build.id) + self.assertEqual(Build.PENDING, build.status) + self.assertEqual(0, build.stopped) + self.assertEqual(0, build.started) + + def test_insert(self): + build = Build(self.env, config='test', rev='42', rev_time=12039, + platform=1) + build.slave_info.update({Build.IP_ADDRESS: '127.0.0.1', + Build.MAINTAINER: 'joe@example.org'}) + build.insert() + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT config,rev,platform,slave,started,stopped,status" + " FROM bitten_build WHERE id=%s" % build.id) + self.assertEqual(('test', '42', 1, '', 0, 0, 'P'), cursor.fetchone()) + + cursor.execute("SELECT propname,propvalue FROM bitten_slave") + expected = {Build.IP_ADDRESS: '127.0.0.1', + Build.MAINTAINER: 'joe@example.org'} + for propname, propvalue in cursor: + self.assertEqual(expected[propname], propvalue) + + def test_insert_no_config_or_rev_or_rev_time_or_platform(self): + build = Build(self.env) + self.assertRaises(AssertionError, build.insert) + + build = Build(self.env, rev='42', rev_time=12039, platform=1) + self.assertRaises(AssertionError, build.insert) # No config + + build = Build(self.env, config='test', rev_time=12039, platform=1) + self.assertRaises(AssertionError, build.insert) # No rev + + build = Build(self.env, config='test', rev='42', platform=1) + self.assertRaises(AssertionError, build.insert) # No rev time + + build = Build(self.env, config='test', rev='42', rev_time=12039) + self.assertRaises(AssertionError, build.insert) # No platform + + def test_insert_no_slave(self): + build = Build(self.env, config='test', rev='42', rev_time=12039, + platform=1) + build.status = Build.SUCCESS + self.assertRaises(AssertionError, build.insert) + build.status = Build.FAILURE + self.assertRaises(AssertionError, build.insert) + build.status = Build.IN_PROGRESS + self.assertRaises(AssertionError, build.insert) + build.status = Build.PENDING + build.insert() + + def test_insert_invalid_status(self): + build = Build(self.env, config='test', rev='42', rev_time=12039, + status='DUNNO') + self.assertRaises(AssertionError, build.insert) + + def test_fetch(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_build (config,rev,rev_time,platform," + "slave,started,stopped,status) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", + ('test', '42', 12039, 1, 'tehbox', 15006, 16007, + Build.SUCCESS)) + build_id = db.get_last_id(cursor, 'bitten_build') + cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)", + [(build_id, Build.IP_ADDRESS, '127.0.0.1'), + (build_id, Build.MAINTAINER, 'joe@example.org')]) + + build = Build.fetch(self.env, build_id) + self.assertEquals(build_id, build.id) + self.assertEquals('127.0.0.1', build.slave_info[Build.IP_ADDRESS]) + self.assertEquals('joe@example.org', build.slave_info[Build.MAINTAINER]) + + def test_update(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_build (config,rev,rev_time,platform," + "slave,started,stopped,status) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", + ('test', '42', 12039, 1, 'tehbox', 15006, 16007, + Build.SUCCESS)) + build_id = db.get_last_id(cursor, 'bitten_build') + cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)", + [(build_id, Build.IP_ADDRESS, '127.0.0.1'), + (build_id, Build.MAINTAINER, 'joe@example.org')]) + + build = Build.fetch(self.env, build_id) + build.status = Build.FAILURE + build.update() + + +class BuildStepTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in BuildStep._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + db.commit() + + def test_new(self): + step = BuildStep(self.env) + self.assertEqual(False, step.exists) + self.assertEqual(None, step.build) + self.assertEqual(None, step.name) + + def test_insert(self): + step = BuildStep(self.env, build=1, name='test', description='Foo bar', + status=BuildStep.SUCCESS) + step.insert() + self.assertEqual(True, step.exists) + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT build,name,description,status,started,stopped " + "FROM bitten_step") + self.assertEqual((1, 'test', 'Foo bar', BuildStep.SUCCESS, 0, 0), + cursor.fetchone()) + + def test_insert_with_errors(self): + step = BuildStep(self.env, build=1, name='test', description='Foo bar', + status=BuildStep.SUCCESS) + step.errors += ['Foo', 'Bar'] + step.insert() + self.assertEqual(True, step.exists) + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT build,name,description,status,started,stopped " + "FROM bitten_step") + self.assertEqual((1, 'test', 'Foo bar', BuildStep.SUCCESS, 0, 0), + cursor.fetchone()) + cursor.execute("SELECT message FROM bitten_error ORDER BY orderno") + self.assertEqual(('Foo',), cursor.fetchone()) + self.assertEqual(('Bar',), cursor.fetchone()) + + def test_insert_no_build_or_name(self): + step = BuildStep(self.env, name='test') + self.assertRaises(AssertionError, step.insert) # No build + + step = BuildStep(self.env, build=1) + self.assertRaises(AssertionError, step.insert) # No name + + def test_fetch(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_step VALUES (%s,%s,%s,%s,%s,%s)", + (1, 'test', 'Foo bar', BuildStep.SUCCESS, 0, 0)) + + step = BuildStep.fetch(self.env, build=1, name='test') + self.assertEqual(1, step.build) + self.assertEqual('test', step.name) + self.assertEqual('Foo bar', step.description) + self.assertEqual(BuildStep.SUCCESS, step.status) + + def test_fetch_with_errors(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO bitten_step VALUES (%s,%s,%s,%s,%s,%s)", + (1, 'test', 'Foo bar', BuildStep.SUCCESS, 0, 0)) + cursor.executemany("INSERT INTO bitten_error VALUES (%s,%s,%s,%s)", + [(1, 'test', 'Foo', 0), (1, 'test', 'Bar', 1)]) + + step = BuildStep.fetch(self.env, build=1, name='test') + self.assertEqual(1, step.build) + self.assertEqual('test', step.name) + self.assertEqual('Foo bar', step.description) + self.assertEqual(BuildStep.SUCCESS, step.status) + self.assertEqual(['Foo', 'Bar'], step.errors) + + def test_select(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.executemany("INSERT INTO bitten_step VALUES (%s,%s,%s,%s,%s,%s)", + [(1, 'test', 'Foo bar', BuildStep.SUCCESS, 1, 2), + (1, 'dist', 'Foo baz', BuildStep.FAILURE, 2, 3)]) + + steps = list(BuildStep.select(self.env, build=1)) + self.assertEqual(1, steps[0].build) + self.assertEqual('test', steps[0].name) + self.assertEqual('Foo bar', steps[0].description) + self.assertEqual(BuildStep.SUCCESS, steps[0].status) + self.assertEqual(1, steps[1].build) + self.assertEqual('dist', steps[1].name) + self.assertEqual('Foo baz', steps[1].description) + self.assertEqual(BuildStep.FAILURE, steps[1].status) + + +class BuildLogTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in BuildLog._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + db.commit() + + def test_new(self): + log = BuildLog(self.env) + self.assertEqual(False, log.exists) + self.assertEqual(None, log.id) + self.assertEqual(None, log.build) + self.assertEqual(None, log.step) + self.assertEqual('', log.generator) + self.assertEqual([], log.messages) + + def test_insert(self): + log = BuildLog(self.env, build=1, step='test', generator='distutils') + log.messages = [ + (BuildLog.INFO, 'running tests'), + (BuildLog.ERROR, 'tests failed') + ] + log.insert() + self.assertNotEqual(None, log.id) + + db = self.env.get_db_cnx() + cursor = db.cursor() + 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 " + "WHERE log=%s ORDER BY line", (log.id,)) + self.assertEqual((BuildLog.INFO, 'running tests'), cursor.fetchone()) + self.assertEqual((BuildLog.ERROR, 'tests failed'), cursor.fetchone()) + + def test_insert_empty(self): + log = BuildLog(self.env, build=1, step='test', generator='distutils') + log.messages = [] + log.insert() + self.assertNotEqual(None, log.id) + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT build,step,generator FROM bitten_log " + "WHERE id=%s", (log.id,)) + self.assertEqual((1, 'test', 'distutils'), cursor.fetchone()) + cursor.execute("SELECT COUNT(*) FROM bitten_log_message " + "WHERE log=%s", (log.id,)) + self.assertEqual(0, cursor.fetchone()[0]) + + def test_insert_no_build_or_step(self): + log = BuildLog(self.env, step='test') + self.assertRaises(AssertionError, log.insert) # No build + + step = BuildStep(self.env, build=1) + self.assertRaises(AssertionError, log.insert) # No step + + def test_delete(self): + db = self.env.get_db_cnx() + cursor = db.cursor() + 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 " + "VALUES (%s,%s,%s,%s)", + [(id, 1, BuildLog.INFO, 'running tests'), + (id, 2, BuildLog.ERROR, 'tests failed')]) + + log = BuildLog.fetch(self.env, id=id, db=db) + self.assertEqual(True, log.exists) + log.delete() + self.assertEqual(False, log.exists) + + cursor.execute("SELECT * FROM bitten_log WHERE id=%s", (id,)) + self.assertEqual(True, not cursor.fetchall()) + cursor.execute("SELECT * FROM bitten_log_message WHERE log=%s", (id,)) + self.assertEqual(True, not cursor.fetchall()) + + def test_delete_new(self): + 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,generator) " + "VALUES (%s,%s,%s)", (1, 'test', 'distutils')) + id = db.get_last_id(cursor, 'bitten_log') + cursor.executemany("INSERT INTO bitten_log_message " + "VALUES (%s,%s,%s,%s)", + [(id, 1, BuildLog.INFO, 'running tests'), + (id, 2, BuildLog.ERROR, 'tests failed')]) + + log = BuildLog.fetch(self.env, id=id, db=db) + self.assertEqual(True, log.exists) + self.assertEqual(id, log.id) + self.assertEqual(1, log.build) + self.assertEqual('test', log.step) + 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,generator) " + "VALUES (%s,%s,%s)", (1, 'test', 'distutils')) + id = db.get_last_id(cursor, 'bitten_log') + cursor.executemany("INSERT INTO bitten_log_message " + "VALUES (%s,%s,%s,%s)", + [(id, 1, BuildLog.INFO, 'running tests'), + (id, 2, BuildLog.ERROR, 'tests failed')]) + + logs = BuildLog.select(self.env, build=1, step='test', db=db) + log = logs.next() + self.assertEqual(True, log.exists) + self.assertEqual(id, log.id) + self.assertEqual(1, log.build) + self.assertEqual('test', log.step) + 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() + self.env.path = '' + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in Report._schema: + for stmt in connector.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_insert_empty_items(self): + report = Report(self.env, build=1, step='test', category='test', + generator='unittest') + report.items = [{}, {}] + 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 COUNT(*) FROM bitten_report_item " + "WHERE report=%s", (report.id,)) + self.assertEqual(0, cursor.fetchone()[0]) + + 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')) + suite.addTest(unittest.makeSuite(TargetPlatformTestCase, 'test')) + 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__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/queue.py b/trac-0.11/bitten/tests/queue.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/queue.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import shutil +import tempfile +import time +import unittest + +from trac.db import DatabaseManager +from trac.test import EnvironmentStub, Mock +from bitten.model import BuildConfig, TargetPlatform, Build, schema +from bitten.queue import BuildQueue, collect_changes + + +class CollectChangesTestCase(unittest.TestCase): + """ + Unit tests for the `bitten.queue.collect_changes` function. + """ + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = tempfile.mkdtemp() + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + self.config = BuildConfig(self.env, name='test', path='somepath') + self.config.insert(db=db) + self.platform = TargetPlatform(self.env, config='test', name='Foo') + self.platform.insert(db=db) + db.commit() + + def tearDown(self): + shutil.rmtree(self.env.path) + + def test_stop_on_copy(self): + self.env.get_repository = lambda authname=None: Mock( + get_node=lambda path, rev=None: Mock( + get_history=lambda: [('otherpath', 123, 'copy')] + ), + normalize_path=lambda path: path + ) + + retval = list(collect_changes(self.env.get_repository(), self.config)) + self.assertEqual(0, len(retval)) + + def test_stop_on_minrev(self): + self.env.get_repository = lambda authname=None: Mock( + get_node=lambda path, rev=None: Mock( + get_entries=lambda: [Mock(), Mock()], + get_history=lambda: [('somepath', 123, 'edit'), + ('somepath', 121, 'edit'), + ('somepath', 120, 'edit')] + ), + normalize_path=lambda path: path, + rev_older_than=lambda rev1, rev2: rev1 < rev2 + ) + + self.config.min_rev = 123 + self.config.update() + + retval = list(collect_changes(self.env.get_repository(), self.config)) + self.assertEqual(1, len(retval)) + self.assertEqual(123, retval[0][1]) + + def test_skip_until_maxrev(self): + self.env.get_repository = lambda authname=None: Mock( + get_node=lambda path, rev=None: Mock( + get_entries=lambda: [Mock(), Mock()], + get_history=lambda: [('somepath', 123, 'edit'), + ('somepath', 121, 'edit'), + ('somepath', 120, 'edit')] + ), + normalize_path=lambda path: path, + rev_older_than=lambda rev1, rev2: rev1 < rev2 + ) + + self.config.max_rev=121 + self.config.update() + + retval = list(collect_changes(self.env.get_repository(), self.config)) + self.assertEqual(2, len(retval)) + self.assertEqual(121, retval[0][1]) + self.assertEqual(120, retval[1][1]) + + def test_skip_empty_dir(self): + def _mock_get_node(path, rev=None): + if rev and rev == 121: + return Mock( + get_entries=lambda: [] + ) + else: + return Mock( + get_entries=lambda: [Mock(), Mock()], + get_history=lambda: [('somepath', 123, 'edit'), + ('somepath', 121, 'edit'), + ('somepath', 120, 'edit')] + ) + + self.env.get_repository = lambda authname=None: Mock( + get_node=_mock_get_node, + normalize_path=lambda path: path, + rev_older_than=lambda rev1, rev2: rev1 < rev2 + ) + + retval = list(collect_changes(self.env.get_repository(), self.config)) + self.assertEqual(2, len(retval)) + self.assertEqual(123, retval[0][1]) + self.assertEqual(120, retval[1][1]) + + +class BuildQueueTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub() + self.env.path = tempfile.mkdtemp() + os.mkdir(os.path.join(self.env.path, 'snapshots')) + + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + db.commit() + + # Hook up a dummy repository + self.repos = Mock() + self.env.get_repository = lambda authname=None: self.repos + + def tearDown(self): + shutil.rmtree(self.env.path) + + def test_get_build_for_slave(self): + """ + Make sure that a pending build of an activated configuration is + scheduled for a slave that matches the target platform. + """ + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name='Foo') + platform.insert() + build = Build(self.env, config='test', platform=platform.id, rev=123, + rev_time=42, status=Build.PENDING) + build.insert() + build_id = build.id + + queue = BuildQueue(self.env) + build = queue.get_build_for_slave('foobar', {}) + self.assertEqual(build_id, build.id) + + def test_next_pending_build_no_matching_slave(self): + """ + Make sure that builds for which there is no slave matching the target + platform are not scheduled. + """ + BuildConfig(self.env, 'test', active=True).insert() + build = Build(self.env, config='test', platform=1, rev=123, rev_time=42, + status=Build.PENDING) + build.insert() + build_id = build.id + + queue = BuildQueue(self.env) + build = queue.get_build_for_slave('foobar', {}) + self.assertEqual(None, build) + + def test_next_pending_build_inactive_config(self): + """ + Make sure that builds for a deactived build config are not scheduled. + """ + BuildConfig(self.env, 'test').insert() + platform = TargetPlatform(self.env, config='test', name='Foo') + platform.insert() + build = Build(self.env, config='test', platform=platform.id, rev=123, + rev_time=42, status=Build.PENDING) + build.insert() + + queue = BuildQueue(self.env) + build = queue.get_build_for_slave('foobar', {}) + self.assertEqual(None, build) + + def test_populate_not_build_all(self): + self.env.get_repository = lambda authname=None: Mock( + get_changeset=lambda rev: Mock(date=rev * 1000), + get_node=lambda path, rev=None: Mock( + get_entries=lambda: [Mock(), Mock()], + get_history=lambda: [('somepath', 123, 'edit'), + ('somepath', 121, 'edit'), + ('somepath', 120, 'edit')] + ), + normalize_path=lambda path: path, + rev_older_than=lambda rev1, rev2: rev1 < rev2 + ) + BuildConfig(self.env, 'test', path='somepath', active=True).insert() + platform1 = TargetPlatform(self.env, config='test', name='P1') + platform1.insert() + platform2 = TargetPlatform(self.env, config='test', name='P2') + platform2.insert() + + queue = BuildQueue(self.env) + queue.populate() + queue.populate() + queue.populate() + + builds = list(Build.select(self.env, config='test')) + builds.sort(lambda a, b: cmp(a.platform, b.platform)) + self.assertEqual(2, len(builds)) + self.assertEqual(platform1.id, builds[0].platform) + self.assertEqual('123', builds[0].rev) + self.assertEqual(platform2.id, builds[1].platform) + self.assertEqual('123', builds[1].rev) + + def test_populate_build_all(self): + self.env.get_repository = lambda authname=None: Mock( + get_changeset=lambda rev: Mock(date=rev * 1000), + get_node=lambda path, rev=None: Mock( + get_entries=lambda: [Mock(), Mock()], + get_history=lambda: [('somepath', 123, 'edit'), + ('somepath', 121, 'edit'), + ('somepath', 120, 'edit')] + ), + normalize_path=lambda path: path, + rev_older_than=lambda rev1, rev2: rev1 < rev2 + ) + BuildConfig(self.env, 'test', path='somepath', active=True).insert() + platform1 = TargetPlatform(self.env, config='test', name='P1') + platform1.insert() + platform2 = TargetPlatform(self.env, config='test', name='P2') + platform2.insert() + + queue = BuildQueue(self.env, build_all=True) + queue.populate() + queue.populate() + queue.populate() + + builds = list(Build.select(self.env, config='test')) + builds.sort(lambda a, b: cmp(a.platform, b.platform)) + self.assertEqual(6, len(builds)) + self.assertEqual(platform1.id, builds[0].platform) + self.assertEqual('123', builds[0].rev) + self.assertEqual(platform1.id, builds[1].platform) + self.assertEqual('121', builds[1].rev) + self.assertEqual(platform1.id, builds[2].platform) + self.assertEqual('120', builds[2].rev) + self.assertEqual(platform2.id, builds[3].platform) + self.assertEqual('123', builds[3].rev) + self.assertEqual(platform2.id, builds[4].platform) + self.assertEqual('121', builds[4].rev) + self.assertEqual(platform2.id, builds[5].platform) + self.assertEqual('120', builds[5].rev) + + def test_reset_orphaned_builds(self): + BuildConfig(self.env, 'test').insert() + platform = TargetPlatform(self.env, config='test', name='Foo') + platform.insert() + build1 = Build(self.env, config='test', platform=platform.id, rev=123, + rev_time=42, status=Build.IN_PROGRESS, slave='heinz', + started=time.time() - 600) # Started ten minutes ago + build1.insert() + + build2 = Build(self.env, config='test', platform=platform.id, rev=124, + rev_time=42, status=Build.IN_PROGRESS, slave='heinz', + started=time.time() - 60) # Started a minute ago + build2.insert() + + queue = BuildQueue(self.env, timeout=300) # 5 minutes timeout + build = queue.reset_orphaned_builds() + self.assertEqual(Build.PENDING, Build.fetch(self.env, build1.id).status) + self.assertEqual(Build.IN_PROGRESS, + Build.fetch(self.env, build2.id).status) + + def test_match_slave_match(self): + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('family', 'posix')) + platform.insert() + platform_id = platform.id + + queue = BuildQueue(self.env) + platforms = queue.match_slave('foo', {'family': 'posix'}) + self.assertEqual(1, len(platforms)) + self.assertEqual(platform_id, platforms[0].id) + + def test_register_slave_match_simple_fail(self): + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('family', 'posix')) + platform.insert() + + queue = BuildQueue(self.env) + platforms = queue.match_slave('foo', {'family': 'nt'}) + self.assertEqual([], platforms) + + def test_register_slave_match_regexp(self): + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('version', '8\.\d\.\d')) + platform.insert() + platform_id = platform.id + + queue = BuildQueue(self.env) + platforms = queue.match_slave('foo', {'version': '8.2.0'}) + self.assertEqual(1, len(platforms)) + self.assertEqual(platform_id, platforms[0].id) + + def test_register_slave_match_regexp_multi(self): + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('os', '^Linux')) + platform.rules.append(('processor', '^[xi]\d?86$')) + platform.insert() + platform_id = platform.id + + queue = BuildQueue(self.env) + platforms = queue.match_slave('foo', {'os': 'Linux', 'processor': 'i686'}) + self.assertEqual(1, len(platforms)) + self.assertEqual(platform_id, platforms[0].id) + + def test_register_slave_match_regexp_fail(self): + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('version', '8\.\d\.\d')) + platform.insert() + + queue = BuildQueue(self.env) + platforms = queue.match_slave('foo', {'version': '7.8.1'}) + self.assertEqual([], platforms) + + def test_register_slave_match_regexp_invalid(self): + BuildConfig(self.env, 'test', active=True).insert() + platform = TargetPlatform(self.env, config='test', name="Unix") + platform.rules.append(('version', '8(\.\d')) + platform.insert() + + queue = BuildQueue(self.env) + platforms = queue.match_slave('foo', {'version': '7.8.1'}) + self.assertEqual([], platforms) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(CollectChangesTestCase, 'test')) + suite.addTest(unittest.makeSuite(BuildQueueTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/recipe.py b/trac-0.11/bitten/tests/recipe.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/recipe.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import shutil +import tempfile +import unittest + +from bitten.recipe import Recipe, InvalidRecipeError +from bitten.util import xmlio + + +class RecipeTestCase(unittest.TestCase): + + def setUp(self): + self.basedir = os.path.realpath(tempfile.mkdtemp()) + + def tearDown(self): + shutil.rmtree(self.basedir) + + def test_empty_recipe(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertEqual(self.basedir, recipe.ctxt.basedir) + steps = list(recipe) + self.assertEqual(0, len(steps)) + + def test_empty_step(self): + xml = xmlio.parse('' + ' ' + '') + recipe = Recipe(xml, basedir=self.basedir) + steps = list(recipe) + self.assertEqual(1, len(steps)) + self.assertEqual('foo', steps[0].id) + self.assertEqual('Bar', steps[0].description) + self.assertEqual('fail', steps[0].onerror) + + def test_validate_bad_root(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_no_steps(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_child_not_step(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_child_not_step(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_step_without_id(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_step_with_empty_id(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_step_without_commands(self): + xml = xmlio.parse('') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_step_with_command_children(self): + xml = xmlio.parse('' + '' + '') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_step_with_duplicate_id(self): + xml = xmlio.parse('' + '' + '' + '') + recipe = Recipe(xml, basedir=self.basedir) + self.assertRaises(InvalidRecipeError, recipe.validate) + + def test_validate_successful(self): + xml = xmlio.parse('' + '' + '' + '') + recipe = Recipe(xml, basedir=self.basedir) + recipe.validate() + +def suite(): + return unittest.makeSuite(RecipeTestCase, 'test') + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/slave.py b/trac-0.11/bitten/tests/slave.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/slave.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import os +import shutil +import tempfile +import unittest + +from trac.test import Mock +from bitten.slave import BuildSlave + + +class BuildSlaveTestCase(unittest.TestCase): + + def setUp(self): + self.work_dir = tempfile.mkdtemp(prefix='bitten_test') + self.slave = BuildSlave(None, work_dir=self.work_dir) + + def tearDown(self): + shutil.rmtree(self.work_dir) + + def _create_file(self, *path): + filename = os.path.join(self.work_dir, *path) + fd = file(filename, 'w') + fd.close() + return filename + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(BuildSlaveTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/tests/web_ui.py b/trac-0.11/bitten/tests/web_ui.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/tests/web_ui.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import shutil +import tempfile +import unittest + +from trac.core import TracError +from trac.db import DatabaseManager +from trac.perm import PermissionCache, PermissionSystem +from trac.test import EnvironmentStub, Mock +from trac.util.html import Markup +from trac.web.href import Href +from bitten.main import BuildSystem +from bitten.model import Build, BuildConfig, BuildStep, TargetPlatform, schema +from bitten.web_ui import BuildConfigController, SourceFileLinkFormatter + + +class BuildConfigControllerTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub(enable=['trac.*', 'bitten.*']) + self.env.path = tempfile.mkdtemp() + + # Create tables + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + # Set up permissions + self.env.config.set('trac', 'permission_store', + 'DefaultPermissionStore') + + # Hook up a dummy repository + self.repos = Mock( + get_node=lambda path, rev=None: Mock(get_history=lambda: [], + isdir=True), + normalize_path=lambda path: path, + sync=lambda: None + ) + self.env.get_repository = lambda authname=None: self.repos + + def tearDown(self): + shutil.rmtree(self.env.path) + + def test_overview(self): + PermissionSystem(self.env).grant_permission('joe', 'BUILD_VIEW') + req = Mock(method='GET', base_path='', cgi_location='', + path_info='/build', href=Href('/trac'), args={}, chrome={}, + perm=PermissionCache(self.env, 'joe')) + + module = BuildConfigController(self.env) + assert module.match_request(req) + _, data, _ = module.process_request(req) + + self.assertEqual('overview', data['page_mode']) + + def test_view_config(self): + config = BuildConfig(self.env, name='test', path='trunk') + config.insert() + platform = TargetPlatform(self.env, config='test', name='any') + platform.insert() + + PermissionSystem(self.env).grant_permission('joe', 'BUILD_VIEW') + req = Mock(method='GET', base_path='', cgi_location='', + path_info='/build/test', href=Href('/trac'), args={}, + chrome={}, authname='joe', + perm=PermissionCache(self.env, 'joe')) + + root = Mock(get_entries=lambda: ['foo'], + get_history=lambda: [('trunk', rev, 'edit') for rev in + range(123, 111, -1)]) + self.repos = Mock(get_node=lambda path, rev=None: root, + sync=lambda: None, normalize_path=lambda path: path) + + module = BuildConfigController(self.env) + assert module.match_request(req) + _, data, _ = module.process_request(req) + + self.assertEqual('view_config', data['page_mode']) + assert not 'next' in req.chrome['links'] + + def test_view_config_paging(self): + config = BuildConfig(self.env, name='test', path='trunk') + config.insert() + platform = TargetPlatform(self.env, config='test', name='any') + platform.insert() + + PermissionSystem(self.env).grant_permission('joe', 'BUILD_VIEW') + req = Mock(method='GET', base_path='', cgi_location='', + path_info='/build/test', href=Href('/trac'), args={}, + chrome={}, authname='joe', + perm=PermissionCache(self.env, 'joe')) + + root = Mock(get_entries=lambda: ['foo'], + get_history=lambda: [('trunk', rev, 'edit') for rev in + range(123, 110, -1)]) + self.repos = Mock(get_node=lambda path, rev=None: root, + sync=lambda: None, normalize_path=lambda path: path) + + module = BuildConfigController(self.env) + assert module.match_request(req) + _, data, _ = module.process_request(req) + + if req.chrome: + self.assertEqual('/trac/build/test?page=2', + req.chrome['links']['next'][0]['href']) + + +class SourceFileLinkFormatterTestCase(unittest.TestCase): + + def setUp(self): + self.env = EnvironmentStub(enable=['trac.*', 'bitten.*']) + + # Create tables + db = self.env.get_db_cnx() + cursor = db.cursor() + connector, _ = DatabaseManager(self.env)._get_connector() + for table in schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + # Hook up a dummy repository + self.repos = Mock( + get_node=lambda path, rev=None: Mock(get_history=lambda: [], + isdir=True), + normalize_path=lambda path: path, + sync=lambda: None + ) + self.env.get_repository = lambda authname=None: self.repos + + def tearDown(self): + pass + + def test_format_simple_link_in_repos(self): + BuildConfig(self.env, name='test', path='trunk').insert() + build = Build(self.env, config='test', platform=1, rev=123, rev_time=42, + status=Build.SUCCESS, slave='hal') + build.insert() + step = BuildStep(self.env, build=build.id, name='foo', + status=BuildStep.SUCCESS) + step.insert() + + self.repos.get_node = lambda path, rev: (path, rev) + + req = Mock(method='GET', href=Href('/trac'), authname='hal') + comp = SourceFileLinkFormatter(self.env) + formatter = comp.get_formatter(req, build) + + output = formatter(step, None, None, u'error in foo/bar.c: bad') + self.assertEqual(Markup, type(output)) + self.assertEqual('error in ' + 'foo/bar.c: bad', output) + + def test_format_simple_link_not_in_repos(self): + BuildConfig(self.env, name='test', path='trunk').insert() + build = Build(self.env, config='test', platform=1, rev=123, rev_time=42, + status=Build.SUCCESS, slave='hal') + build.insert() + step = BuildStep(self.env, build=build.id, name='foo', + status=BuildStep.SUCCESS) + step.insert() + + def _raise(): + raise TracError('No such node') + self.repos.get_node = lambda path, rev: _raise() + + req = Mock(method='GET', href=Href('/trac'), authname='hal') + comp = SourceFileLinkFormatter(self.env) + formatter = comp.get_formatter(req, build) + + output = formatter(step, None, None, u'error in foo/bar.c: bad') + self.assertEqual(Markup, type(output)) + self.assertEqual('error in foo/bar.c: bad', output) + + def test_format_link_in_repos_with_line(self): + BuildConfig(self.env, name='test', path='trunk').insert() + build = Build(self.env, config='test', platform=1, rev=123, rev_time=42, + status=Build.SUCCESS, slave='hal') + build.insert() + step = BuildStep(self.env, build=build.id, name='foo', + status=BuildStep.SUCCESS) + step.insert() + + self.repos.get_node = lambda path, rev: (path, rev) + + req = Mock(method='GET', href=Href('/trac'), authname='hal') + comp = SourceFileLinkFormatter(self.env) + formatter = comp.get_formatter(req, build) + + output = formatter(step, None, None, u'error in foo/bar.c:123: bad') + self.assertEqual(Markup, type(output)) + self.assertEqual('error in ' + 'foo/bar.c:123: bad', output) + + def test_format_link_not_in_repos_with_line(self): + BuildConfig(self.env, name='test', path='trunk').insert() + build = Build(self.env, config='test', platform=1, rev=123, rev_time=42, + status=Build.SUCCESS, slave='hal') + build.insert() + step = BuildStep(self.env, build=build.id, name='foo', + status=BuildStep.SUCCESS) + step.insert() + + def _raise(): + raise TracError('No such node') + self.repos.get_node = lambda path, rev: _raise() + + req = Mock(method='GET', href=Href('/trac'), authname='hal') + comp = SourceFileLinkFormatter(self.env) + formatter = comp.get_formatter(req, build) + + output = formatter(step, None, None, u'error in foo/bar.c:123: bad') + self.assertEqual(Markup, type(output)) + self.assertEqual('error in foo/bar.c:123: bad', output) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(BuildConfigControllerTestCase, 'test')) + suite.addTest(unittest.makeSuite(SourceFileLinkFormatterTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/upgrades.py b/trac-0.11/bitten/upgrades.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/upgrades.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +"""Automated upgrades for the Bitten database tables, and other data stored +in the Trac environment.""" + +import os +import sys + +from trac.db import DatabaseManager + +__docformat__ = 'restructuredtext en' + +def add_log_table(env, db): + """Add a table for storing the builds logs.""" + from bitten.model import BuildLog, BuildStep + cursor = db.cursor() + + connector, _ = DatabaseManager(env)._get_connector() + for table in BuildLog._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + cursor.execute("SELECT build,name,log FROM bitten_step " + "WHERE log IS NOT NULL") + for build, step, log in cursor: + build_log = BuildLog(env, build, step) + build_log.messages = [(BuildLog.INFO, msg) for msg in log.splitlines()] + build_log.insert(db) + + cursor.execute("CREATE TEMP TABLE old_step AS SELECT * FROM bitten_step") + cursor.execute("DROP TABLE bitten_step") + for table in BuildStep._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + cursor.execute("INSERT INTO bitten_step (build,name,description,status," + "started,stopped) SELECT build,name,description,status," + "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() + + cursor.execute("CREATE TEMP TABLE old_config AS " + "SELECT * FROM bitten_config") + cursor.execute("DROP TABLE bitten_config") + + connector, _ = DatabaseManager(env)._get_connector() + for table in BuildConfig._schema: + for stmt in connector.to_sql(table): + cursor.execute(stmt) + + cursor.execute("INSERT INTO bitten_config (name,path,active,recipe,min_rev," + "max_rev,label,description) SELECT name,path,0,'',NULL," + "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 as bdb + import dbxml + except ImportError: + 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) + uc = mgr.createUpdateContext() + + container.addIndex(xtn, '', 'config', 'node-metadata-equality-string', uc) + + qc = mgr.createQueryContext() + for value in mgr.query(xtn, 'collection("%s")/report' % dbfile, qc): + doc = value.asDocument() + metaval = dbxml.XmlValue() + if doc.getMetaData('', 'build', metaval): + build_id = int(metaval.asNumber()) + build = Build.fetch(env, id=build_id, db=db) + if build: + doc.setMetaData('', 'config', dbxml.XmlValue(build.config)) + container.updateDocument(xtn, doc, uc) + else: + # an orphaned report, for whatever reason... just remove it + container.deleteDocument(xtn, doc, uc) + + xtn.commit() + 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") + + connector, _ = DatabaseManager(env)._get_connector() + for stmt in connector.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() + + connector, _ = DatabaseManager(env)._get_connector() + for table in Report._schema: + for stmt in connector.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: + 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) + +def normalize_file_paths(env, db): + """Normalize the file separator in file names in reports.""" + cursor = db.cursor() + cursor.execute("SELECT report,item,value FROM bitten_report_item " + "WHERE name='file'") + rows = cursor.fetchall() or [] + for report, item, value in rows: + if '\\' in value: + cursor.execute("UPDATE bitten_report_item SET value=%s " + "WHERE report=%s AND item=%s AND name='file'", + (value.replace('\\', '/'), report, item)) + +def fixup_generators(env, db): + """Upgrade the identifiers for the recipe commands that generated log + messages and report data.""" + + mapping = { + 'pipe': 'http://bitten.cmlenz.net/tools/sh#pipe', + 'make': 'http://bitten.cmlenz.net/tools/c#make', + 'distutils': 'http://bitten.cmlenz.net/tools/python#distutils', + 'exec_': 'http://bitten.cmlenz.net/tools/python#exec' # Ambigious + } + cursor = db.cursor() + cursor.execute("SELECT id,generator FROM bitten_log " + "WHERE generator IN (%s)" + % ','.join([repr(key) for key in mapping.keys()])) + for log_id, generator in cursor: + cursor.execute("UPDATE bitten_log SET generator=%s " + "WHERE id=%s", (mapping[generator], log_id)) + + mapping = { + 'unittest': 'http://bitten.cmlenz.net/tools/python#unittest', + 'trace': 'http://bitten.cmlenz.net/tools/python#trace', + 'pylint': 'http://bitten.cmlenz.net/tools/python#pylint' + } + cursor.execute("SELECT id,generator FROM bitten_report " + "WHERE generator IN (%s)" + % ','.join([repr(key) for key in mapping.keys()])) + for report_id, generator in cursor: + cursor.execute("UPDATE bitten_report SET generator=%s " + "WHERE id=%s", (mapping[generator], report_id)) + +def add_error_table(env, db): + """Add the bitten_error table for recording step failure reasons.""" + from trac.db import Table, Column + + table = Table('bitten_error', key=('build', 'step', 'orderno'))[ + Column('build', type='int'), Column('step'), Column('message'), + Column('orderno', type='int') + ] + cursor = db.cursor() + + connector, _ = DatabaseManager(env)._get_connector() + for stmt in connector.to_sql(table): + cursor.execute(stmt) + +map = { + 2: [add_log_table], + 3: [add_recipe_to_config], + 4: [add_config_to_reports], + 5: [add_order_to_log, add_report_tables, xmldb_to_db], + 6: [normalize_file_paths, fixup_generators], + 7: [add_error_table] +} diff --git a/trac-0.11/bitten/util/__init__.py b/trac-0.11/bitten/util/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/util/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Generic utility functions and classes. + +Functionality in these modules have no dependencies on modules outside of this +package, so that they could theoretically be used in other projects. +""" + +__docformat__ = 'restructuredtext en' diff --git a/trac-0.11/bitten/util/loc.py b/trac-0.11/bitten/util/loc.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/util/loc.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 1998 Dinu C. Gherman +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# +# 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.edgewall.org/wiki/License. +# +# This module is based on the pycount.py script written by Dinu C. +# Gherman, and is used here under the following license: +# +# Permission to use, copy, modify, and distribute this software +# and its documentation without fee and for any purpose, except +# direct commerial advantage, is hereby granted, provided that +# the above copyright notice appear in all copies and that both +# that copyright notice and this permission notice appear in +# supporting documentation. + +"""Support for counting the lines of code in Python programs.""" + +import re + +__all__ = ['BLANK', 'CODE', 'COMMENT', 'DOC', 'count'] +__docformat__ = 'restructuredtext en' + +# Reg. exps. to find the end of a triple quote, given that +# we know we're in one; use the "match" method; .span()[1] +# will be the index of the character following the final +# quote. +_squote3_finder = re.compile( + r"([^\']|" + r"\.|" + r"'[^\']|" + r"'\.|" + r"''[^\']|" + r"''\.)*'''") + +_dquote3_finder = re.compile( + r'([^\"]|' + r'\.|' + r'"[^\"]|' + r'"\.|' + r'""[^\"]|' + r'""\.)*"""') + +# Reg. exps. to find the leftmost one-quoted string; use the +# "search" method; .span()[0] bounds the string found. +_dquote1_finder = re.compile(r'"([^"]|\.)*"') +_squote1_finder = re.compile(r"'([^']|\.)*'") + +# _is_comment matches pure comment line. +_is_comment = re.compile(r"^[ \t]*#").match + +# _is_blank matches empty line. +_is_blank = re.compile(r"^[ \t]*$").match + +# find leftmost splat or quote. +_has_nightmare = re.compile(r"""[\"'#]""").search + +# _is_doc_candidate matches lines that start with a triple quote. +_is_doc_candidate = re.compile(r"^[ \t]*('''|\"\"\")") + +BLANK, CODE, COMMENT, DOC = 0, 1, 2, 3 + +def count(source): + """Parse the given file-like object as Python source code. + + For every line in the code, this function yields a ``(lineno, type, line)`` + tuple, where ``lineno`` is the line number (starting at 0), ``type`` is + one of `BLANK`, `CODE`, `COMMENT` or `DOC`, and ``line`` is the actual + content of the line. + + :param source: a file-like object containing Python code + """ + + quote3_finder = {'"': _dquote3_finder, "'": _squote3_finder} + quote1_finder = {'"': _dquote1_finder, "'": _squote1_finder } + + in_doc = False + in_triple_quote = None + + for lineno, line in enumerate(source): + classified = False + + if in_triple_quote: + if in_doc: + yield lineno, DOC, line + else: + yield lineno, CODE, line + classified = True + m = in_triple_quote.match(line) + if m == None: + continue + # Get rid of everything through the end of the triple. + end = m.span()[1] + line = line[end:] + in_doc = in_triple_quote = False + + if _is_blank(line): + if not classified: + yield lineno, BLANK, line + continue + + if _is_comment(line): + if not classified: + yield lineno, COMMENT, line + continue + + # Now we have a code line, a doc start line, or crap left + # over following the close of a multi-line triple quote; in + # (& only in) the last case, classified==1. + if not classified: + if _is_doc_candidate.match(line): + yield lineno, DOC, line + in_doc = True + else: + yield lineno, CODE, line + + # The only reason to continue parsing is to make sure the + # start of a multi-line triple quote isn't missed. + while True: + m = _has_nightmare(line) + if not m: + break + else: + i = m.span()[0] + + ch = line[i] # splat or quote + if ch == '#': + # Chop off comment; and there are no quotes + # remaining because splat was leftmost. + break + # A quote is leftmost. + elif ch * 3 == line[i:i + 3]: + # at the start of a triple quote + in_triple_quote = quote3_finder[ch] + m = in_triple_quote.match(line, i + 3) + if m: + # Remove the string & continue. + end = m.span()[1] + line = line[:i] + line[end:] + in_doc = in_triple_quote = False + else: + # Triple quote doesn't end on this line. + break + else: + # At a single quote; remove the string & continue. + prev_line = line[:] + line = re.sub(quote1_finder[ch], ' ', line, 1) + # No more change detected, so be quiet or give up. + if prev_line == line: + # Let's be quiet and hope only one line is affected. + line = '' diff --git a/trac-0.11/bitten/util/testrunner.py b/trac-0.11/bitten/util/testrunner.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/util/testrunner.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2008 Matt Good +# Copyright (C) 2008 Edgewall Software +# 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.edgewall.org/wiki/License. + +from distutils import log +from distutils.errors import DistutilsOptionError +import os +import re +from StringIO import StringIO +import sys +import time +from pkg_resources import Distribution, EntryPoint, PathMetadata, \ + normalize_path, require, working_set +from setuptools.command.test import test +from unittest import _TextTestResult, TextTestRunner + +from bitten import __version__ as VERSION +from bitten.util import xmlio + +__docformat__ = 'restructuredtext en' + + +class XMLTestResult(_TextTestResult): + + def __init__(self, stream, descriptions, verbosity): + _TextTestResult.__init__(self, stream, descriptions, verbosity) + self.tests = [] + + 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(), None, None]) + + def stopTest(self, test): + self.tests[-1][2] = time.time() - self.tests[-1][2] + _TextTestResult.stopTest(self, test) + + +class XMLTestRunner(TextTestRunner): + + def __init__(self, stream=sys.stdout, xml_stream=None): + TextTestRunner.__init__(self, stream, descriptions=0, verbosity=2) + self.xml_stream = xml_stream + + def _makeResult(self): + return XMLTestResult(self.stream, self.descriptions, self.verbosity) + + def run(self, test): + result = TextTestRunner.run(self, test) + if not self.xml_stream: + return result + + root = xmlio.Element('unittest-results') + 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] + elif testcase in [f[0] for f in result.failures]: + status = 'failure' + tb = [f[1] for f in result.failures if f[0] is testcase][0] + + name = str(testcase) + fixture = None + description = testcase.shortDescription() or '' + if description.startswith('doctest of '): + name = 'doctest' + fixture = description[11:] + description = None + else: + match = re.match('(\w+)\s+\(([\w.]+)\)', name) + if match: + name = match.group(1) + fixture = match.group(2) + + test_elem = xmlio.Element('test', file=filename, name=name, + fixture=fixture, status=status, + duration=timetaken) + if description: + test_elem.append(xmlio.Element('description')[description]) + if stdout: + test_elem.append(xmlio.Element('stdout')[stdout]) + if stderr: + test_elem.append(xmlio.Element('stdout')[stderr]) + if tb: + test_elem.append(xmlio.Element('traceback')[tb]) + root.append(test_elem) + + root.write(self.xml_stream, newlines=True) + return result + + +class unittest(test): + description = test.description + ', and optionally record code coverage' + + user_options = test.user_options + [ + ('xml-output=', None, + "Path to the XML file where test results are written to"), + ('coverage-dir=', None, + "Directory where coverage files are to be stored"), + ('coverage-summary=', None, + "Path to the file where the coverage summary should be stored"), + ('coverage-method=', None, + "Whether to use trace.py or coverage.py to collect code coverage. " + "Valid options are 'trace' (the default) or 'coverage'.") + ] + + def initialize_options(self): + test.initialize_options(self) + self.xml_output = None + self.xml_output_file = None + self.coverage_summary = None + self.coverage_dir = None + self.coverage_method = 'trace' + + def finalize_options(self): + test.finalize_options(self) + + if self.xml_output is not None: + output_dir = os.path.dirname(self.xml_output) or '.' + if not os.path.exists(output_dir): + os.makedirs(output_dir) + self.xml_output_file = open(self.xml_output, 'w') + + if self.coverage_method not in ('trace', 'coverage', 'figleaf'): + raise DistutilsOptionError('Unknown coverage method %r' % + self.coverage_method) + + def run_tests(self): + if self.coverage_summary: + if self.coverage_method == 'coverage': + self._run_with_coverage() + elif self.coverage_method == 'figleaf': + self._run_with_figleaf() + else: + self._run_with_trace() + else: + self._run_tests() + + def _run_with_figleaf(self): + import figleaf + figleaf.start() + try: + self._run_tests() + finally: + figleaf.stop() + figleaf.write_coverage(self.coverage_summary) + + def _run_with_coverage(self): + import coverage + coverage.use_cache(False) + coverage.start() + try: + self._run_tests() + finally: + coverage.stop() + + modules = [m for _, m in sys.modules.items() + if m is not None and hasattr(m, '__file__') + and os.path.splitext(m.__file__)[-1] in ('.py', '.pyc')] + + # Generate summary file + buf = StringIO() + coverage.report(modules, file=buf) + buf.seek(0) + fileobj = open(self.coverage_summary, 'w') + try: + filter_coverage(buf, fileobj) + finally: + fileobj.close() + + if self.coverage_dir: + if not os.path.exists(self.coverage_dir): + os.makedirs(self.coverage_dir) + coverage.annotate(modules, directory=self.coverage_dir, + ignore_errors=True) + + def _run_with_trace(self): + from trace import Trace + trace = Trace(ignoredirs=[sys.prefix, sys.exec_prefix], trace=False, + count=True) + try: + trace.runfunc(self._run_tests) + finally: + results = trace.results() + real_stdout = sys.stdout + sys.stdout = open(self.coverage_summary, 'w') + try: + results.write_results(show_missing=True, summary=True, + coverdir=self.coverage_dir) + finally: + sys.stdout.close() + sys.stdout = real_stdout + + def _run_tests(self): + old_path = sys.path[:] + ei_cmd = self.get_finalized_command("egg_info") + path_item = normalize_path(ei_cmd.egg_base) + metadata = PathMetadata( + path_item, normalize_path(ei_cmd.egg_info) + ) + dist = Distribution(path_item, metadata, project_name=ei_cmd.egg_name) + working_set.add(dist) + require(str(dist.as_requirement())) + loader_ep = EntryPoint.parse("x=" + self.test_loader) + loader_class = loader_ep.load(require=False) + + try: + import unittest + unittest.main( + None, None, [unittest.__file__] + self.test_args, + testRunner=XMLTestRunner(stream=sys.stdout, + xml_stream=self.xml_output_file), + testLoader=loader_class() + ) + except SystemExit, e: + return e.code + + +def filter_coverage(infile, outfile): + for idx, line in enumerate(infile): + if idx < 2 or line.startswith('--'): + outfile.write(line) + continue + parts = line.split() + name = parts[0] + if name == 'TOTAL': + continue + if name not in sys.modules: + outfile.write(line) + continue + filename = os.path.normpath(sys.modules[name].__file__) + if filename.endswith('.pyc') or filename.endswith('.pyo'): + filename = filename[:-1] + outfile.write(line.rstrip() + ' ' + filename + '\n') + + +def main(): + from distutils.dist import Distribution + from optparse import OptionParser + + parser = OptionParser(usage='usage: %prog [options] test_suite ...', + version='%%prog %s' % VERSION) + parser.add_option('-o', '--xml-output', action='store', dest='xml_output', + metavar='FILE', help='write XML test results to FILE') + parser.add_option('-d', '--coverage-dir', action='store', + dest='coverage_dir', metavar='DIR', + help='store coverage results in DIR') + parser.add_option('-s', '--coverage-summary', action='store', + dest='coverage_summary', metavar='FILE', + help='write coverage summary to FILE') + options, args = parser.parse_args() + if len(args) < 1: + parser.error('incorrect number of arguments') + + cmd = unittest(Distribution()) + cmd.initialize_options() + cmd.test_suite = args[0] + if hasattr(options, 'xml_output'): + cmd.xml_output = options.xml_output + if hasattr(options, 'coverage_summary'): + cmd.coverage_summary = options.coverage_summary + if hasattr(options, 'coverage_dir'): + cmd.coverage_dir = options.coverage_dir + cmd.finalize_options() + cmd.run() + +if __name__ == '__main__': + main(sys.argv) diff --git a/trac-0.11/bitten/util/tests/__init__.py b/trac-0.11/bitten/util/tests/__init__.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/util/tests/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +import doctest +import unittest + +from bitten.util import xmlio + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(xmlio)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/trac-0.11/bitten/util/xmlio.py b/trac-0.11/bitten/util/xmlio.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/util/xmlio.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Utility code for easy input and output of XML. + +The current implementation uses `xml.dom.minidom` under the hood for parsing. +""" + +import os +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO +from UserDict import DictMixin + +import cgi +import string + +__trans = string.maketrans ("", "") +__todel = "" +for c in range (0, 256): + c1 = chr (c) + if not c1 in string.printable: + __todel += c1 +del c, c1 + +__all__ = ['Fragment', 'Element', 'ParsedElement', 'parse'] +__docformat__ = 'restructuredtext en' + +def _escape_text(text): + """Escape special characters in the provided text so that it can be safely + included in XML text nodes. + """ + return cgi.escape (str(text)).translate (__trans, __todel) + +def _escape_attr(attr): + """Escape special characters in the provided text so that it can be safely + included in XML attribute values. + """ + return _escape_text(attr).replace('"', '"') + + +class Fragment(object): + """A collection of XML elements.""" + __slots__ = ['children'] + + def __init__(self): + """Create an XML fragment.""" + self.children = [] + + def __getitem__(self, nodes): + """Add nodes to the fragment.""" + if not isinstance(nodes, (list, tuple)): + nodes = [nodes] + for node in nodes: + self.append(node) + return self + + def __str__(self): + """Return a string representation of the XML fragment.""" + buf = StringIO() + self.write(buf) + return buf.getvalue() + + def append(self, node): + """Append an element or fragment as child.""" + if isinstance(node, Element): + self.children.append(node) + elif isinstance(node, Fragment): + self.children += node.children + elif node is not None and node != '': + self.children.append(str(node)) + + def write(self, out, newlines=False): + """Serializes the element and writes the XML to the given output + stream. + """ + for child in self.children: + if isinstance(child, (Element, ParsedElement)): + child.write(out, newlines=newlines) + else: + if child.startswith('<'): + out.write('') + else: + out.write(_escape_text(child)) + + +class Element(Fragment): + """Simple XML output generator based on the builder pattern. + + Construct XML elements by passing the tag name to the constructor: + + >>> print Element('foo') + + + Attributes can be specified using keyword arguments. The values of the + arguments will be converted to strings and any special XML characters + escaped: + + >>> print Element('foo', bar=42) + + >>> print Element('foo', bar='1 < 2') + + >>> print Element('foo', bar='"baz"') + + + The order in which attributes are rendered is undefined. + + Elements can be using item access notation: + + >>> print Element('foo')[Element('bar'), Element('baz')] + + + Text nodes can be nested in an element by using strings instead of elements + in item access. Any special characters in the strings are escaped + automatically: + + >>> print Element('foo')['Hello world'] + Hello world + >>> print Element('foo')[42] + 42 + >>> print Element('foo')['1 < 2'] + 1 < 2 + + This technique also allows mixed content: + + >>> print Element('foo')['Hello ', Element('b')['world']] + Hello world + + Finally, text starting with an opening angle bracket is treated specially: + under the assumption that the text actually contains XML itself, the whole + thing is wrapped in a CDATA block instead of escaping all special characters + individually: + + >>> print Element('foo')[''] + ]]> + """ + __slots__ = ['name', 'attr'] + + def __init__(self, name_, **attr): + """Create an XML element using the specified tag name. + + The tag name must be supplied as the first positional argument. All + keyword arguments following it are handled as attributes of the element. + """ + Fragment.__init__(self) + self.name = name_ + self.attr = dict([(name, value) for name, value in attr.items() + if value is not None]) + + def write(self, out, newlines=False): + """Serializes the element and writes the XML to the given output + stream. + """ + out.write('<') + out.write(self.name) + for name, value in self.attr.items(): + out.write(' %s="%s"' % (name, _escape_attr(value))) + if self.children: + out.write('>') + Fragment.write(self, out, newlines) + out.write('') + else: + out.write('/>') + if newlines: + out.write(os.linesep) + + +class ParseError(Exception): + """Exception thrown when there's an error parsing an XML document.""" + + +def parse(text_or_file): + """Parse an XML document provided as string or file-like object. + + Returns an instance of `ParsedElement` that can be used to traverse the + parsed document. + """ + from xml.dom import minidom + from xml.parsers import expat + try: + if isinstance(text_or_file, (str, unicode)): + dom = minidom.parseString(text_or_file) + else: + dom = minidom.parse(text_or_file) + return ParsedElement(dom.documentElement) + except expat.error, e: + raise ParseError(e) + + +class ParsedElement(object): + """Representation of an XML element that was parsed from a string or + file. + + This class should not be used directly. Rather, XML text parsed using + `xmlio.parse()` will return an instance of this class. + + >>> xml = parse('') + >>> print xml.name + root + + Parsed elements can be serialized to a string using the `write()` method: + + >>> import sys + >>> parse('').write(sys.stdout) + + + For convenience, this is also done when coercing the object to a string + using the builtin ``str()`` function, which is used when printing an + object: + + >>> print parse('') + + + (Note that serializing the element will produce a normalized representation + that may not excatly match the input string.) + + Attributes are accessed via the `attr` member: + + >>> print parse('').attr['foo'] + bar + + Attributes can also be updated, added or removed: + + >>> xml = parse('') + >>> xml.attr['foo'] = 'baz' + >>> print xml + + + >>> del xml.attr['foo'] + >>> print xml + + + >>> xml.attr['foo'] = 'bar' + >>> print xml + + + CDATA sections are included in the text content of the element returned by + `gettext()`: + + >>> xml = parse('foo ]]>baz') + >>> xml.gettext() + 'foo baz' + """ + __slots__ = ['_node', 'attr'] + + class _Attrs(DictMixin): + """Simple wrapper around the element attributes to provide a dictionary + interface.""" + def __init__(self, node): + self._node = node + def __getitem__(self, name): + attr = self._node.getAttributeNode(name) + if not attr: + raise KeyError(name) + return attr.value.encode('utf-8') + def __setitem__(self, name, value): + self._node.setAttribute(name, value) + def __delitem__(self, name): + self._node.removeAttribute(name) + def keys(self): + return [key.encode('utf-8') for key in self._node.attributes.keys()] + + def __init__(self, node): + self._node = node + self.attr = ParsedElement._Attrs(node) + + name = property(fget=lambda self: self._node.localName, + doc='Local name of the element') + namespace = property(fget=lambda self: self._node.namespaceURI, + doc='Namespace URI of the element') + + def children(self, name=None): + """Iterate over the child elements of this element. + + If the parameter `name` is provided, only include elements with a + matching local name. Otherwise, include all elements. + """ + for child in [c for c in self._node.childNodes if c.nodeType == 1]: + if name in (None, child.tagName): + yield ParsedElement(child) + + def __iter__(self): + return self.children() + + def gettext(self): + """Return the text content of this element. + + This concatenates the values of all text and CDATA nodes that are + immediate children of this element. + """ + return ''.join([c.nodeValue.encode('utf-8') + for c in self._node.childNodes + if c.nodeType in (3, 4)]) + + def write(self, out, newlines=False): + """Serializes the element and writes the XML to the given output + stream. + """ + self._node.writexml(out, newl=newlines and '\n' or '') + + def __str__(self): + """Return a string representation of the XML element.""" + buf = StringIO() + self.write(buf) + return buf.getvalue() + + +if __name__ == '__main__': + import doctest + doctest.testmod() diff --git a/trac-0.11/bitten/web_ui.py b/trac-0.11/bitten/web_ui.py new file mode 100644 --- /dev/null +++ b/trac-0.11/bitten/web_ui.py @@ -0,0 +1,626 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2007 Christopher Lenz +# Copyright (C) 2007 Edgewall Software +# 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.edgewall.org/wiki/License. + +"""Implementation of the Bitten web interface.""" + +from datetime import datetime +import posixpath +import re +from StringIO import StringIO + +import pkg_resources +from genshi.builder import tag +from trac.core import * +from trac.timeline import ITimelineEventProvider +from trac.util import escape, pretty_timedelta, format_datetime, shorten_line, \ + Markup +from trac.util.html import html +from trac.web import IRequestHandler +from trac.web.chrome import INavigationContributor, ITemplateProvider, \ + add_link, add_stylesheet, add_ctxtnav, \ + prevnext_nav, add_script +from trac.wiki import wiki_to_html, wiki_to_oneliner +from bitten.api import ILogFormatter, IReportChartGenerator, IReportSummarizer +from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \ + BuildLog, Report +from bitten.queue import collect_changes + +_status_label = {Build.PENDING: 'pending', + Build.IN_PROGRESS: 'in progress', + Build.SUCCESS: 'completed', + Build.FAILURE: 'failed'} + +def _get_build_data(env, req, build): + data = {'id': build.id, 'name': build.slave, 'rev': build.rev, + 'status': _status_label[build.status], + 'cls': _status_label[build.status].replace(' ', '-'), + 'href': req.href.build(build.config, build.id), + 'chgset_href': req.href.changeset(build.rev)} + if build.started: + data['started'] = format_datetime(build.started) + data['started_delta'] = pretty_timedelta(build.started) + data['duration'] = pretty_timedelta(build.started) + if build.stopped: + data['stopped'] = format_datetime(build.stopped) + data['stopped_delta'] = pretty_timedelta(build.stopped) + data['duration'] = pretty_timedelta(build.stopped, build.started) + data['slave'] = { + 'name': build.slave, + 'ipnr': build.slave_info.get(Build.IP_ADDRESS), + 'os_name': build.slave_info.get(Build.OS_NAME), + 'os_family': build.slave_info.get(Build.OS_FAMILY), + 'os_version': build.slave_info.get(Build.OS_VERSION), + 'machine': build.slave_info.get(Build.MACHINE), + 'processor': build.slave_info.get(Build.PROCESSOR) + } + return data + + +class BittenChrome(Component): + """Provides the Bitten templates and static resources.""" + + implements(INavigationContributor, ITemplateProvider) + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + """Called by Trac to determine which navigation item should be marked + as active. + + :param req: the request object + """ + return 'build' + + def get_navigation_items(self, req): + """Return the navigation item for access the build status overview from + the Trac navigation bar.""" + if 'BUILD_VIEW' in req.perm: + yield ('mainnav', 'build', + tag.a('Builds Status', href=req.href.build(), accesskey=5)) + + # ITemplatesProvider methods + + def get_htdocs_dirs(self): + """Return the directories containing static resources.""" + return [('bitten', pkg_resources.resource_filename(__name__, 'htdocs'))] + + def get_templates_dirs(self): + """Return the directories containing templates.""" + return [pkg_resources.resource_filename(__name__, 'templates')] + + +class BuildConfigController(Component): + """Implements the web interface for build configurations.""" + + implements(IRequestHandler) + + # IRequestHandler methods + + def match_request(self, req): + match = re.match(r'/build(?:/([\w.-]+))?/?$', req.path_info) + if match: + if match.group(1): + req.args['config'] = match.group(1) + return True + + def process_request(self, req): + req.perm.require('BUILD_VIEW') + + action = req.args.get('action') + view = req.args.get('view') + config = req.args.get('config') + + if config: + data = self._render_config(req, config) + elif view == 'inprogress': + data = self._render_inprogress(req) + else: + data = self._render_overview(req) + + add_stylesheet(req, 'bitten/bitten.css') + return 'bitten_config.html', data, None + + # Internal methods + + def _render_overview(self, req): + data = {'title': 'Build Status'} + show_all = False + if req.args.get('show') == 'all': + show_all = True + data['show_all'] = show_all + + configs = [] + for config in BuildConfig.select(self.env, include_inactive=show_all): + description = config.description + if description: + description = wiki_to_html(description, self.env, req) + config_data = { + 'name': config.name, 'label': config.label or config.name, + 'active': config.active, 'path': config.path, + 'description': description, + 'href': req.href.build(config.name), + 'builds': [] + } + configs.append(config_data) + if not config.active: + continue + + repos = self.env.get_repository(req.authname) + if hasattr(repos, 'sync'): + repos.sync() + + prev_rev = None + for platform, rev, build in collect_changes(repos, config): + if rev != prev_rev: + if prev_rev is None: + chgset = repos.get_changeset(rev) + config_data['youngest_rev'] = { + 'id': rev, 'href': req.href.changeset(rev), + 'author': chgset.author or 'anonymous', + 'date': format_datetime(chgset.date), + 'message': wiki_to_oneliner( + shorten_line(chgset.message), self.env, req=req) + } + else: + break + prev_rev = rev + if build: + build_data = _get_build_data(self.env, req, build) + build_data['platform'] = platform.name + config_data['builds'].append(build_data) + else: + config_data['builds'].append({ + 'platform': platform.name, 'status': 'pending' + }) + + data['configs'] = configs + data['page_mode'] = 'overview' + add_link(req, 'views', req.href.build(view='inprogress'), + 'In Progress Builds') + add_ctxtnav(req, 'In Progress Builds', + req.href.build(view='inprogress')) + return data + + def _render_inprogress(self, req): + data = {'title': 'In Progress Builds', + 'page_mode': 'view-inprogress'} + + db = self.env.get_db_cnx() + + configs = [] + for config in BuildConfig.select(self.env, include_inactive=False): + self.log.debug(config.name) + if not config.active: + continue + + in_progress_builds = Build.select(self.env, config=config.name, + status=Build.IN_PROGRESS, db=db) + + current_builds = 0 + builds = [] + # sort correctly by revision. + for build in sorted(in_progress_builds, + cmp=lambda x, y: int(y.rev) - int(x.rev)): + rev = build.rev + build_data = _get_build_data(self.env, req, build) + build_data['rev'] = rev + build_data['rev_href'] = req.href.changeset(rev) + platform = TargetPlatform.fetch(self.env, build.platform) + build_data['platform'] = platform.name + build_data['steps'] = [] + + for step in BuildStep.select(self.env, build=build.id, db=db): + build_data['steps'].append({ + 'name': step.name, + 'description': step.description, + 'duration': datetime.fromtimestamp(step.stopped) - \ + datetime.fromtimestamp(step.started), + 'failed': not step.successful, + 'errors': step.errors, + 'href': build_data['href'] + '#step_' + step.name + }) + + builds.append(build_data) + current_builds += 1 + + if current_builds == 0: + continue + + description = config.description + if description: + description = wiki_to_html(description, self.env, req) + configs.append({ + 'name': config.name, 'label': config.label or config.name, + 'active': config.active, 'path': config.path, + 'description': description, + 'href': req.href.build(config.name), + 'builds': builds + }) + + data['configs'] = configs + return data + + def _render_config(self, req, config_name): + db = self.env.get_db_cnx() + + config = BuildConfig.fetch(self.env, config_name, db=db) + data = {'title': 'Build Configuration "%s"' \ + % config.label or config.name, + 'page_mode': 'view_config'} + add_link(req, 'up', req.href.build(), 'Build Status') + description = config.description + if description: + description = wiki_to_html(description, self.env, req) + data['config'] = { + 'name': config.name, 'label': config.label, 'path': config.path, + 'min_rev': config.min_rev, + 'min_rev_href': req.href.changeset(config.min_rev), + 'max_rev': config.max_rev, + 'max_rev_href': req.href.changeset(config.max_rev), + 'active': config.active, 'description': description, + 'browser_href': req.href.browser(config.path) + } + + platforms = list(TargetPlatform.select(self.env, config=config_name, + db=db)) + data['config']['platforms'] = [ + {'name': platform.name, 'id': platform.id} + for platform in platforms + ] + + has_reports = False + for report in Report.select(self.env, config=config.name, db=db): + has_reports = True + break + + if has_reports: + chart_generators = [] + for generator in ReportChartController(self.env).generators: + for category in generator.get_supported_categories(): + chart_generators.append({ + 'href': req.href.build(config.name, 'chart/' + category) + }) + data['config']['charts'] = chart_generators + charts_license = self.config.get('bitten', 'charts_license') + if charts_license: + data['config']['charts_license'] = charts_license + + page = max(1, int(req.args.get('page', 1))) + more = False + data['page_number'] = page + + repos = self.env.get_repository(req.authname) + if hasattr(repos, 'sync'): + repos.sync() + + builds_per_page = 12 * len(platforms) + idx = 0 + builds = {} + for platform, rev, build in collect_changes(repos, config): + if idx >= page * builds_per_page: + more = True + break + elif idx >= (page - 1) * builds_per_page: + builds.setdefault(rev, {}) + builds[rev].setdefault('href', req.href.changeset(rev)) + if build and build.status != Build.PENDING: + build_data = _get_build_data(self.env, req, build) + build_data['steps'] = [] + for step in BuildStep.select(self.env, build=build.id, + db=db): + build_data['steps'].append({ + 'name': step.name, + 'description': step.description, + 'duration': datetime.fromtimestamp(step.stopped) - \ + datetime.fromtimestamp(step.started), + 'failed': not step.successful, + 'errors': step.errors, + 'href': build_data['href'] + '#step_' + step.name + }) + builds[rev][platform.id] = build_data + idx += 1 + data['config']['builds'] = builds + + if page > 1: + if page == 2: + prev_href = req.href.build(config.name) + else: + prev_href = req.href.build(config.name, page=page - 1) + add_link(req, 'prev', prev_href, 'Previous Page') + if more: + next_href = req.href.build(config.name, page=page + 1) + add_link(req, 'next', next_href, 'Next Page') + prevnext_nav(req, 'Page') + return data + + +class BuildController(Component): + """Renders the build page.""" + implements(INavigationContributor, IRequestHandler, ITimelineEventProvider) + + log_formatters = ExtensionPoint(ILogFormatter) + report_summarizers = ExtensionPoint(IReportSummarizer) + + # 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'/build/([\w.-]+)/(\d+)', req.path_info) + if match: + if match.group(1): + req.args['config'] = match.group(1) + if match.group(2): + req.args['id'] = match.group(2) + return True + + def process_request(self, req): + req.perm.require('BUILD_VIEW') + + db = self.env.get_db_cnx() + build_id = int(req.args.get('id')) + build = Build.fetch(self.env, build_id, db=db) + assert build, 'Build %s does not exist' % build_id + + if req.method == 'POST': + if req.args.get('action') == 'invalidate': + self._do_invalidate(req, build, db) + req.redirect(req.href.build(build.config, build.id)) + + add_link(req, 'up', req.href.build(build.config), + 'Build Configuration') + status2title = {Build.SUCCESS: 'Success', Build.FAILURE: 'Failure', + Build.IN_PROGRESS: 'In Progress'} + data = {'title': 'Build %s - %s' % (build_id, + status2title[build.status]), + 'page_mode': 'view_build', + 'build': {}} + config = BuildConfig.fetch(self.env, build.config, db=db) + data['build']['config'] = { + 'name': config.label, + 'href': req.href.build(config.name) + } + + formatters = [] + for formatter in self.log_formatters: + formatters.append(formatter.get_formatter(req, build)) + + summarizers = {} # keyed by report type + for summarizer in self.report_summarizers: + categories = summarizer.get_supported_categories() + summarizers.update(dict([(cat, summarizer) for cat in categories])) + + data['build'].update(_get_build_data(self.env, req, build)) + steps = [] + for step in BuildStep.select(self.env, build=build.id, db=db): + steps.append({ + 'name': step.name, 'description': step.description, + 'duration': pretty_timedelta(step.started, step.stopped), + 'failed': step.status == BuildStep.FAILURE, + 'errors': step.errors, + 'log': self._render_log(req, build, formatters, step), + 'reports': self._render_reports(req, config, build, summarizers, + step) + }) + data['build']['steps'] = steps + data['build']['can_delete'] = ('BUILD_DELETE' in req.perm) + + repos = self.env.get_repository(req.authname) + chgset = repos.get_changeset(build.rev) + data['build']['chgset_author'] = chgset.author + + add_script(req, 'bitten/tabset.js') + add_stylesheet(req, 'bitten/bitten.css') + return 'bitten_build.html', data, None + + # ITimelineEventProvider methods + + def get_timeline_filters(self, req): + if 'BUILD_VIEW' in req.perm: + yield ('build', 'Builds') + + def get_timeline_events(self, req, start, stop, filters): + if 'build' not in filters: + return + + if isinstance(start, datetime): # Trac>=0.11 + from trac.util.datefmt import to_timestamp + start = to_timestamp(start) + stop = to_timestamp(stop) + + add_stylesheet(req, 'bitten/bitten.css') + + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT b.id,b.config,c.label,b.rev,p.name," + "b.stopped,b.status FROM bitten_build AS b" + " INNER JOIN bitten_config AS c ON (c.name=b.config) " + " INNER JOIN bitten_platform AS p ON (p.id=b.platform) " + "WHERE b.stopped>=%s AND b.stopped<=%s " + "AND b.status IN (%s, %s) ORDER BY b.stopped", + (start, stop, Build.SUCCESS, Build.FAILURE)) + + event_kinds = {Build.SUCCESS: 'successbuild', + Build.FAILURE: 'failedbuild'} + for id, config, label, rev, platform, stopped, status in cursor: + + errors = [] + if status == Build.FAILURE: + for step in BuildStep.select(self.env, build=id, + status=BuildStep.FAILURE, + db=db): + errors += [(step.name, error) for error + in step.errors] + + title = tag('Build of ', tag.em('%s [%s]' % (label, rev)), + ' on %s %s' % (platform, _status_label[status])) + message = '' + if req.args.get('format') == 'rss': + href = req.abs_href.build(config, id) + if errors: + buf = StringIO() + prev_step = None + for step, error in errors: + if step != prev_step: + if prev_step is not None: + buf.write('') + buf.write('

    Step %s failed:

      ' \ + % escape(step)) + prev_step = step + buf.write('
    • %s
    • ' % escape(error)) + buf.write('
    ') + message = Markup(buf.getvalue()) + else: + href = req.href.build(config, id) + if errors: + steps = [] + for step, error in errors: + if step not in steps: + steps.append(step) + steps = [Markup('%s') % step for step in steps] + if len(steps) < 2: + message = steps[0] + elif len(steps) == 2: + message = Markup(' and ').join(steps) + elif len(steps) > 2: + message = Markup(', ').join(steps[:-1]) + ', and ' + \ + steps[-1] + message = Markup('Step%s %s failed') % ( + len(steps) != 1 and 's' or '', message + ) + yield event_kinds[status], href, title, stopped, None, message + + # Internal methods + + def _do_invalidate(self, req, build, db): + self.log.info('Invalidating build %d', build.id) + + for step in BuildStep.select(self.env, build=build.id, db=db): + step.delete(db=db) + + build.slave = None + build.started = build.stopped = 0 + build.status = Build.PENDING + build.slave_info = {} + build.update() + + db.commit() + + req.redirect(req.href.build(build.config)) + + def _render_log(self, req, build, formatters, step): + items = [] + for log in BuildLog.select(self.env, build=build.id, step=step.name): + for level, message in log.messages: + for format in formatters: + message = format(step, log.generator, level, message) + items.append({'level': level, 'message': message}) + return items + + def _render_reports(self, req, config, build, summarizers, step): + reports = [] + for report in Report.select(self.env, build=build.id, step=step.name): + summarizer = summarizers.get(report.category) + if summarizer: + tmpl, data = summarizer.render_summary(req, config, build, + step, report.category) + else: + tmpl = data = None + reports.append({'category': report.category, + 'template': tmpl, 'data': data}) + return reports + + +class ReportChartController(Component): + implements(IRequestHandler) + + generators = ExtensionPoint(IReportChartGenerator) + + # IRequestHandler methods + + def match_request(self, req): + match = re.match(r'/build/([\w.-]+)/chart/(\w+)', req.path_info) + if match: + req.args['config'] = match.group(1) + req.args['category'] = match.group(2) + return True + + def process_request(self, req): + category = req.args.get('category') + config = BuildConfig.fetch(self.env, name=req.args.get('config')) + + for generator in self.generators: + if category in generator.get_supported_categories(): + tmpl, data = generator.generate_chart_data(req, config, + category) + break + else: + raise TracError('Unknown report category "%s"' % category) + + return tmpl, data, 'text/xml' + + +class SourceFileLinkFormatter(Component): + """Detects references to files in the build log and renders them as links + to the repository browser. + """ + + implements(ILogFormatter) + + _fileref_re = re.compile('(?P[\w.-]+(?:/[\w.-]+)+)(?P(:\d+))?') + + def get_formatter(self, req, build): + """Return the log message formatter function.""" + config = BuildConfig.fetch(self.env, name=build.config) + repos = self.env.get_repository(req.authname) + href = req.href.browser + cache = {} + + def _replace(m): + filepath = posixpath.normpath(m.group('path').replace('\\', '/')) + if not cache.get(filepath) is True: + parts = filepath.split('/') + path = '' + for part in parts: + path = posixpath.join(path, part) + if path not in cache: + try: + repos.get_node(posixpath.join(config.path, path), + build.rev) + cache[path] = True + except TracError: + cache[path] = False + if cache[path] is False: + return m.group(0) + link = href(config.path, filepath) + if m.group('line'): + link += '#L' + m.group('line')[1:] + return Markup(tag.a(m.group(0), href=link)) + + def _formatter(step, type, level, message): + buf = [] + offset = 0 + for mo in self._fileref_re.finditer(message): + start, end = mo.span() + if start > offset: + buf.append(message[offset:start]) + buf.append(_replace(mo)) + offset = end + if offset < len(message): + buf.append(message[offset:]) + return Markup("").join(buf) + + return _formatter diff --git a/trac-0.11/doc/commands.txt b/trac-0.11/doc/commands.txt new file mode 100644 --- /dev/null +++ b/trac-0.11/doc/commands.txt @@ -0,0 +1,890 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +===================== +Build Recipe Commands +===================== + +`Build recipes`_ are represented by XML documents. This page describes what +commands are generally available in recipes. Please note, though, that +third-party packages can add additional commands, which would then be +documented by that third party. + +.. _`build recipes`: recipes.html + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +Generic Commands +================ + +These are commands that are used without a namespace prefix. + + +------------ +```` +------------ + +Parse an XML file and send it to the master as a report with a given category. +Use this command in conjunction with the ```` or ```` +commands to send custom reports to the build master. + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``category`` | Category of the report (for example "test" or "coverage"). | ++--------------+-------------------------------------------------------------+ +| ``file`` | Path to the XML file containing the report data, relative | +| | to the project directory. | ++--------------+-------------------------------------------------------------+ + +Both parameters must be specified. + + +Shell Tools +=========== + +A bundle of generic tools that are not specific to any programming language or +tool-chain. + +:Namespace: ``http://bitten.cmlenz.net/tools/sh`` +:Common prefix: ``sh`` + + +------------- +```` +------------- + +Executes a program or script. + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``executable`` | The name of the executable program. | ++----------------+-----------------------------------------------------------+ +| ``file`` | Path to the script to execute, relative to the project | +| | directory | ++----------------+-----------------------------------------------------------+ +| ``output`` | Path to the output file | ++----------------+-----------------------------------------------------------+ +| ``args`` | Any arguments to pass to the executable or script | ++----------------+-----------------------------------------------------------+ + +Either ``executable`` or ``file`` must be specified. + +Examples +-------- + +TODO + + +------------- +```` +------------- + +Pipes the content of a file through a program or script. + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``executable`` | The name of the executable program. | ++----------------+-----------------------------------------------------------+ +| ``file`` | Path to the script to execute, relative to the project | +| | directory | ++----------------+-----------------------------------------------------------+ +| ``input`` | Path to the input file | ++----------------+-----------------------------------------------------------+ +| ``output`` | Path to the output file | ++----------------+-----------------------------------------------------------+ +| ``args`` | Any arguments to pass to the executable or script | ++----------------+-----------------------------------------------------------+ + +Either ``executable`` or ``file`` must be specified. + +Examples +-------- + +TODO + + +C/Unix Tools +============ + +These commands provide support for tools commonly used for development of C/C++ +applications on Unix platforms, such as ``make``. + +:Namespace: ``http://bitten.cmlenz.net/tools/c`` +:Common prefix: ``c`` + + +------------------ +```` +------------------ + +Executes ths autotool autoreconf. + +Parameters +---------- + + :param force: consider all files obsolete + :param install: copy missing auxiliary files + :param symlink: install symbolic links instead of copies + :param warnings: report the warnings falling in CATEGORY + :prepend_include: prepend directories to search path + :include: append directories to search path + + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``force`` | Consider all files obsolete | ++--------------+-------------------------------------------------------------+ +| ``install`` | Copy missing auxiliary files | ++--------------+-------------------------------------------------------------+ +| ``symlink`` | Install symbolic links instead of copies | ++--------------+-------------------------------------------------------------+ +| ``warnings`` | Report the warnings related to category | +| | (which can actually be a comma separated list) | ++--------------+-------------------------------------------------------------+ +| ``prepend_include`` | Prepend directories to search path | ++--------------+-------------------------------------------------------------+ +| ``include`` | Append directories to search path | ++--------------+-------------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Runs the ``autoreconf`` tool in the base directory with the option: force, install +and 3 warning categories active: cross,syntax,error. This is equivalent to:: + + autoreconf --force --install --warnings=cross,syntax,error + + +----------------- +```` +----------------- + +Executes a configure script as generated by Autoconf. + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``file`` | Name of the configure script (defaults to "configure") | ++--------------+-------------------------------------------------------------+ +| ``enable`` | List of features to enable, separated by spaces. | ++--------------+-------------------------------------------------------------+ +| ``disable`` | List of features to disable, separated by spaces. | ++--------------+-------------------------------------------------------------+ +| ``with`` | List of packages to include, separated by spaces. | ++--------------+-------------------------------------------------------------+ +| ``without`` | List of packages to exclude, separated by spaces. | ++--------------+-------------------------------------------------------------+ +| ``cflags`` | Value of the `CFLAGS` variable to pass to the script. | ++--------------+-------------------------------------------------------------+ +| ``cxxflags`` | Value of the `CXXFLAGS` variable to pass to the script. | ++--------------+-------------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Runs the ``configure`` script in the base directory, enable the ``threadsafe`` +feature, and passing ``-O`` as ``CFLAGS``. This is equivalent to:: + + ./configure --enable-threadsafe CFLAGS="-O" + + +------------ +```` +------------ + +Run gcov_ to extract coverage data where available. + +.. _gcov: http://gcc.gnu.org/onlinedocs/gcc/Gcov-Intro.html + +Parameters +---------- + ++--------------+------------------------------------------------------------+ +| Name | Description | ++==============+============================================================+ +| ``include`` | List of glob patterns (separated by space) that specify | +| | which source files should be included in the coverage | +| | report | ++--------------+------------------------------------------------------------+ +| ``exclude`` | List of glob patterns (separated by space) that specify | +| | which source files should be excluded from the coverage | +| | report | ++--------------+------------------------------------------------------------+ +| ``prefix`` | Optional prefix name that is added to object files by the | +| | build system | ++--------------+------------------------------------------------------------+ + + +------------ +```` +------------ + +Executes a Makefile. + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``target`` | Name of the target to execute (defaults to "all") | ++----------------+-----------------------------------------------------------+ +| ``file`` | Path to the Makefile that should be used. | ++----------------+-----------------------------------------------------------+ +| ``keep-going`` | Whether `make` should try to continue even after | +| | encountering errors. | ++----------------+-----------------------------------------------------------+ +| ``jobs`` | Number of parallel jobs used by make. | ++----------------+-----------------------------------------------------------+ +| ``directory`` | Path of the directory in which make should be called. | ++----------------+-----------------------------------------------------------+ +| ``args`` | Any space separated arguments to pass to the makefile. | +| | Usually in the form: | +| | ``"parameter1=value1 parameter2=value2"``. | ++----------------+-----------------------------------------------------------+ + + +Examples +-------- + +.. code-block:: xml + + + +Runs the target "compile" of the ``Makefile`` located in the sub-directory +``build``. + +.. code-block:: xml + + + +Same as previous but execute the command in the ``work`` directory and call +the makefile with the command line argument ``coverage=1``. + +--------------- +```` +--------------- + +Report the test output generated by the CppUnit_ unit testing framework. The +output from CppUnit must be in XML format and in already, specified by the +``file`` argument of this recipe. + +.. _cppunit: http://cppunit.sourceforge.net + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path to the cppunit XML output file. | ++----------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + + +Runs the program ``run_unit_tests`` to gather the data output by CppUnit in the +``test_results.xml`` file and then reports it. + + +Java Tools +========== + +A bundle of recipe commands that support tools commonly used by Java projects. + +:Namespace: ``http://bitten.cmlenz.net/tools/java`` +:Common prefix: ``java`` + + +-------------- +```` +-------------- + +Runs an Ant_ build. + +.. _ant: http://ant.apache.org/ + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path of the build file, relative to the project source | +| | directory (default is ``build.xml``). | ++----------------+-----------------------------------------------------------+ +| ``target`` | Name of the build target(s) to execute. | ++----------------+-----------------------------------------------------------+ +| ``args`` | Additional arguments to pass to Ant, separated by | +| | whitespace. | ++----------------+-----------------------------------------------------------+ +| ``keep_going`` | Tell Ant to continue even when errors are in encountered | +| | in the build. | ++----------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Executes the target ``compile`` of the ``build.xml`` buildfile at the top of the +project source directory. + + +-------------------- +```` +-------------------- + +Extract code coverage data from a Cobertura_ XML file. + +.. _cobertura: http://cobertura.sourceforge.net/ + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path to the XML file generated by Cobertura | ++----------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Reads the specifid XML file, extracts the coverage data, and builds a coverage +report to be sent to the build master. + + +---------------- +```` +---------------- + +Extracts information about unit test results from a file in JUnit_ XML format. + +.. _junit: http://junit.org/index.htm + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path to the JUnit XML test results file. This can include | +| | wildcards, in which case all the file matching the | +| | pattern will be included. | ++----------------+-----------------------------------------------------------+ +| ``srcdir`` | Path of the directory unit test sources. Used to link the | +| | test cases to files. | ++----------------+-----------------------------------------------------------+ + +The ``file`` attribute is required. + +Examples +-------- + +.. code-block:: xml + + + +Collects the test results from all files in the `build/tests/results` directory +that match the pattern `TEST-*.xml`. Also, maps the class names in the results +files to Java source files in the directory `src/tests`. + + +PHP Tools +========= + +A bundle of recipe commands for PHP_ projects. + +:Namespace: ``http://bitten.cmlenz.net/tools/php`` +:Common prefix: ``php`` + +.. _php: http://php.net/ + +--------------- +```` +--------------- + +Runs a Phing_ build. + +.. _phing: http://phing.info/ + +Parameters +---------- + ++-------------------+-------------------------------------------------------+ +| Name | Description | ++===================+=======================================================+ +| ``file`` | Path of the build file, relative to the project | +| | source directory (default is ``build.xml``). | ++-------------------+-------------------------------------------------------+ +| ``target`` | Name of the build target(s) to execute. | ++-------------------+-------------------------------------------------------+ +| ``args`` | Additional arguments to pass to Phing, separated by | +| | whitespace. | ++-------------------+-------------------------------------------------------+ +| ``executable`` | Phing executable program (default is ``phing``). | ++-------------------+-------------------------------------------------------+ + + +Examples +-------- + +.. code-block:: xml + + + +Executes the target ``compile`` of the ``build.xml`` buildfile at the top of the +project source directory. + + +----------------- +```` +----------------- + +Extracts information from PHPUnit_ test results recorded in an XML file. + +.. _phpunit: http://www.phpunit.de/ + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path to the XML results file, relative to the project | +| | source directory. | ++----------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Extracts the test results from the XML file located at +``build/test-results.xml``. + + +------------------ +```` +------------------ + +Extracts coverage information Phing_'s code coverage task recorded in an XML +file. + +Parameters +---------- + ++---------------+-----------------------------------------------------------+ +| Name | Description | ++===============+===========================================================+ +| ``file`` | Path to the XML coverage file, relative to the project | +| | source directory. | ++---------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + + +Python Tools +============ + +A bundle of recipe commands that support tools commonly used by Python_ +projects. + +:Namespace: ``http://bitten.cmlenz.net/tools/python`` +:Common prefix: ``python`` + +.. _python: http://www.python.org/ + + +----------------- +```` +----------------- + +Executes a Python script. + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path of the script to execute, relative to the project | +| | source directory. | ++----------------+-----------------------------------------------------------+ +| ``module`` | Name of the Python module to execute. | ++----------------+-----------------------------------------------------------+ +| ``function`` | Name of the function in the Python module to run. Only | +| | works when also specifying the `module` attribute. | ++----------------+-----------------------------------------------------------+ +| ``args`` | Any arguments that should be passed to the script. | ++----------------+-----------------------------------------------------------+ +| ``output`` | Path to a file where any output by the script should be | +| | recorded. | ++----------------+-----------------------------------------------------------+ + +Either `file` or `module` must be specified. + +Examples +-------- + +.. code-block:: xml + + + +Executes Pylint_ on the module/package ``myproj`` and stores the output into a +file named ``pylint-report.txt``. + + +---------------------- +```` +---------------------- + +Executes a distutils_ script. + +.. _distutils: http://docs.python.org/lib/module-distutils.html + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| `command` | The name of the `distutils` command that should be run | ++----------------+-----------------------------------------------------------+ +| `options` | Additional options to pass to the command, separated by | +| | spaces | ++----------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Instructs `distutils` to produce a source distribution. + +.. code-block:: xml + + + +Instructs `distutils` to run the ``unittest`` command (which is provided by +Bitten), and passes the options needed to determine the output paths for test +results and code coverage reports. + + +--------------------- +```` +--------------------- + +Extracts information from unittest_ results recorded in an XML file. + +.. _unittest: http://docs.python.org/lib/module-unittest.html +.. note:: This report must be used in conjunction with the ``distutils`` command + "unittest" that comes with Bitten. + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``file`` | Path to the XML results file, relative to the project | +| | source directory. | ++----------------+-----------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +Extracts the test results from the XML file located at +``build/test-results.xml``. + + +------------------ +```` +------------------ + +Extracts coverage information recorded by the built-in Python module +``trace.py``. + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``summary`` | Path to the summary file written by ``trace.py``, | +| | relative to the project source directory. | ++--------------+-------------------------------------------------------------+ +| ``coverdir`` | Path to the directory containing the coverage files written | +| | by ``trace.py``, relative to the project source directory. | ++--------------+-------------------------------------------------------------+ +| ``include`` | List of glob patterns (separated by space) that specify | +| | which Python file should be included in the coverage report | ++--------------+-------------------------------------------------------------+ +| ``exclude`` | List of glob patterns (separated by space) that specify | +| | which Python file should be excluded from the coverage | +| | report | ++--------------+-------------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +------------------- +```` +------------------- + +Extracts information from Pylint_ reports. + +.. _pylint: http://www.logilab.org/projects/pylint + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``file`` | Path to the file containing the Pylint output, relative to | +| | the project source directory. | ++--------------+-------------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + + +Subversion Tools +================ + +A collection of recipe commands for working with the Subversion_ version +control system. This commands are commonly used as the first step of a build +recipe to actually pull the code that should be built from the repository. + +.. _subversion: http://subversion.tigris.org/ + +:Namespace: ``http://bitten.cmlenz.net/tools/svn`` +:Common prefix: ``svn`` + + +------------------ +```` +------------------ + +Check out a working copy from a Subversion repository. + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``url`` | URL of the repository. | ++--------------+-------------------------------------------------------------+ +| ``path`` | The path inside the repository that should be checked out. | +| | You should normally set this to ``${path}`` so that the | +| | path of the build configuration is used. | ++--------------+-------------------------------------------------------------+ +| ``revision`` | The revision that should be checked out. You should | +| | normally set this to ``${revision}`` so that the revision | +| | of the build is used. | ++--------------+-------------------------------------------------------------+ +| ``dir`` | Path specifying which directory the sources should be | +| | checked out to (defaults to '.'). | ++--------------+-------------------------------------------------------------+ +| ``verbose`` | Whether to log the list of checked out files (defaults to | +| | False). | ++--------------+-------------------------------------------------------------+ + + +Examples +-------- + +.. code-block:: xml + + + +This checks out the a working copy into the current directory. + + +---------------- +```` +---------------- + +Download a file or directory from a Subversion repository. This is similar to +performing a checkout, but will not include the meta-data Subversion uses to +connect the local working copy to the repository (i.e. it does not include the +``.svn`` directories.) + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``url`` | URL of the repository. | ++--------------+-------------------------------------------------------------+ +| ``path`` | The path inside the repository that should be checked out. | +| | You should normally set this to ``${path}`` so that the | +| | path of the build configuration is used. | ++--------------+-------------------------------------------------------------+ +| ``revision`` | The revision that should be checked out. You should | +| | normally set this to ``${revision}`` so that the revision | +| | of the build is used. | ++--------------+-------------------------------------------------------------+ +| ``dir`` | Path specifying which directory the sources should be | +| | exported to (defaults to '.') | ++--------------+-------------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +This downloads the file or directory at ``${path}`` from the Subversion +repository at ``http://svn.example.org/repos/myproject/``. Variables are used +for the ``path`` and ``revision`` attributes so they are populated from the +properties of the build and build configuration. + + +---------------- +```` +---------------- + +Update an existing working copy from a Subversion repository to a specific +revision. + +Parameters +---------- + ++--------------+-------------------------------------------------------------+ +| Name | Description | ++==============+=============================================================+ +| ``revision`` | The revision that should be checked out. You should | +| | normally set this to ``${revision}`` so that the revision | +| | of the build is used. | ++--------------+-------------------------------------------------------------+ +| ``dir`` | Path specifying the directory containing the sources to be | +| | updated (defaults to '.') | ++--------------+-------------------------------------------------------------+ + +Examples +-------- + +.. code-block:: xml + + + +This updates the working copy in the current directory. The revision is +specified as a variable so that it is populated from the properties of the +build. + + +XML Tools +========= + +A collection of recipe commands for XML processing. + +:Namespace: ``http://bitten.cmlenz.net/tools/xml`` +:Common prefix: ``x`` + + +----------------- +```` +----------------- + +Apply an XSLT stylesheet . + +.. note:: that this command requires either libxslt_ (with `Python bindings`_) + or, on Windows platforms, MSXML (version 3 or later) to be installed + on the slave machine. + +.. _libxslt: http://xmlsoft.org/XSLT/ +.. _`python bindings`: http://xmlsoft.org/XSLT/python.html + +Parameters +---------- + ++----------------+-----------------------------------------------------------+ +| Name | Description | ++================+===========================================================+ +| ``src`` | Path of the source XML file. | ++----------------+-----------------------------------------------------------+ +| ``dest`` | Path of the destition XML file. | ++----------------+-----------------------------------------------------------+ +| ``stylesheet`` | Path to the XSLT stylesheet file. | ++----------------+-----------------------------------------------------------+ + +All these are interpreted relative to the project source directory. + +Examples +-------- + +.. code-block:: xml + + + +This applies the stylesheet in ``util/convert.xsl`` to the source file +``src.xml``, and writes the resulting XML document to ``dest.xml``. diff --git a/trac-0.11/doc/index.txt b/trac-0.11/doc/index.txt new file mode 100644 --- /dev/null +++ b/trac-0.11/doc/index.txt @@ -0,0 +1,25 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +======= +Preface +======= + +.. image:: logo.png + :width: 538 + :height: 298 + :align: center + :alt: Bitten + :class: logo + +------------------------------- +Continuous Integration for Trac +------------------------------- + +Bitten is a Python-based framework for collecting various software metrics via +continuous integration. It builds on Trac to provide an integrated web-based +user interface. + + * `Installation `_ + * `Build Recipes `_ + * `Build Recipe Commands `_ + * `Generated API Documentation `_ diff --git a/trac-0.11/doc/install.txt b/trac-0.11/doc/install.txt new file mode 100644 --- /dev/null +++ b/trac-0.11/doc/install.txt @@ -0,0 +1,121 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +============ +Installation +============ + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +Prerequisites +============= + +Bitten is written in Python, so make sure that you have Python installed. +You'll need Python 2.3 or later. Also, make sure that setuptools_, version 0.6a2 +or later, is installed. + +.. _setuptools: http://peak.telecommunity.com/DevCenter/setuptools + +If that's taken care of, you just need to download and unpack the Bitten +distribution, and execute the command:: + + $ python setup.py install + +from the top of the directory where you unpacked (or checked out) the Bitten +code. Note that you may need administrator/root privileges for this step, as +it will by default attempt to install Bitten to the Python site-packages +directory on your system. + +It's also a good idea to run the unit tests at this point, to make sure that +the code works as expected on your platform:: + + $ python setup.py test + + +What's left to do now depends on whether you want to use the build master and +web interface, or just the build slave. In the latter case, you're already +done. You might need to install software that the build of your project +requires, but the Bitten build slave itself doesn't require anything extra. + +For the build master and web interface, you'll need to install Trac_ 0.10 or +later and the TracWebAdmin_ plugin. Please refer to the Trac documentation for +information on how it is installed. + +.. _trac: http://trac.edgewall.org/ +.. _tracwebadmin: http://trac.edgewall.org/wiki/WebAdmin + + +Build Master Configuration +========================== + +Once both Bitten and Trac are installed and working, you'll have to introduce +Bitten to your Trac project environment. If you don't have a Trac project +set up yet, you'll need to do so in order to use Bitten. + +If you already have a Trac project environment, the Bitten plugin needs to be +explicitly enabled in the Trac configuration. This is done by adding it to the +``[components]`` section in ``/path/to/projenv/conf/trac.ini``: + +.. code-block:: ini + + [components] + bitten.* = enabled + +The Trac web interface should now inform you with an error message that the +environment needs to be upgraded. To do this, run:: + + $ trac-admin /path/to/projenv upgrade + +This will create the database tables and directories that Bitten requires. +You probably also want to grant permissions to someone (such as yourself) +to manage build configurations, and allow anonymous users to view the +status and results of builds:: + + $ trac-admin /path/to/projenv permission add anonymous BUILD_EXEC + $ trac-admin /path/to/projenv permission add anonymous BUILD_VIEW + $ trac-admin /path/to/projenv permission add [yourname] BUILD_ADMIN + +You should now see an additional tab labeled "Build Status" in the Trac +navigation bar. This link will take you to the list of build configurations, +which at this point is of course empty. + +To add build configurations, you need to have the TracWebAdmin_ plugin +installed. + +.. warning:: The TracWebAdmin needs to be installed even if you're using Trac + 0.11 or later, which basically provides a builtin web + administration interface. Make sure that you disable the plugin in + that case, though (``webadmin.* = disabled``). While somewhat + counterintuitive, this process allows the Bitten administration UI + to neatly integrate into the new web administration interface added + in Trac 0.11. + +If both TracWebAdmin_ and Bitten are installed, and you are logged in as a user +with the required permissions, you should see additional administration pages +inside the “Admin” area, under a group named “Builds”. These pages allow you to +set options of the build master, and manage build configurations. + +Add a new build configuration and fill out the form. Also, add at least one +target platform after saving the configuration. Last but not least, you'll have +to "activate" your new build configuration. + + +Running the Build Slave +======================= + +The build slave can be run on any machine that can connect to the machine +on which the build master is running. The installation of Bitten should have put +a `bitten-slave` executable on your path. If the script is not on your path, +look for it in the `bin` or `scripts` subdirectory of your Python installation. + +To get a list of options for the build slave, execute it with the `--help` +option:: + + $ bitten-slave --help + +To run the build slave against a Bitten-enabled Trac site installed at +http://myproject.example.org/trac, you'd run:: + + $ bitten-slave http://myproject.example.org/trac/builds diff --git a/trac-0.11/doc/logo.pdf b/trac-0.11/doc/logo.pdf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..665402ec268b69d884c17379346cf580f2e6355c GIT binary patch literal 41793 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 diff --git a/trac-0.11/doc/logo.png b/trac-0.11/doc/logo.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..bb4daf76efb72105cca5ea1e1b97fe70dbde9ad9 GIT binary patch literal 20260 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�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 diff --git a/trac-0.11/doc/logo_small.png b/trac-0.11/doc/logo_small.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..aba3415df5c2aebadd03ccf631cd9549f721648a GIT binary patch literal 5889 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 diff --git a/trac-0.11/doc/recipes.txt b/trac-0.11/doc/recipes.txt new file mode 100644 --- /dev/null +++ b/trac-0.11/doc/recipes.txt @@ -0,0 +1,68 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +============= +Build Recipes +============= + +A build recipe tells a build slave how a project is to be built. It consists of +multiple build steps, each defining a command to execute, and where artifacts +can be found after that command has successfully completed. + +Build recipes are intended to supplement existing project build files (such as +Makefiles), not to replace them. In general, a recipe will be much simpler than +the build file itself, because it doesn't deal with all the details of the +build. It just automates the execution of the build and lets the build slave +locate any artifacts and metrics data generated in the course of the build. + +A recipe can and should split the build into multiple separate steps so that the +build slave can provide better status reporting to the build master while the +build is still in progress. This is important for builds that might take long to +execute. In addition, build steps help organize the build results for a more +structured presentation. + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +File Format +=========== + +Build recipes are stored internally in an XML-based format. Recipe documents +have a single ```` root element with one or more ```` child +elements. The steps are executed in the order they appear in the recipe. + +A ```` element will consist of any number of commands and reports. Most of +these elements are declared in XML namespaces, where the namespace URI defines +a collection of related commands. + +Commonly, the first step of any build recipe will perform the checkout from the +repository. + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + +See `Build Recipe Commands`_ for a comprehensive reference of the commands +available by default. + +.. _`build recipe commands`: commands.html diff --git a/trac-0.11/scripts/proxy.py b/trac-0.11/scripts/proxy.py new file mode 100644 --- /dev/null +++ b/trac-0.11/scripts/proxy.py @@ -0,0 +1,93 @@ +# Based on the proxy module from the Medusa project +# Used for inspecting the communication between two BEEP peers + +import asynchat +import asyncore +import socket +import sys + + +class proxy_server(asyncore.dispatcher): + + def __init__(self, host, port): + asyncore.dispatcher.__init__ (self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.there = (host, port) + here = ('', port + 1) + self.bind(here) + self.listen(5) + + def handle_accept(self): + proxy_receiver(self, self.accept()) + + +class proxy_sender(asynchat.async_chat): + + def __init__(self, receiver, address): + asynchat.async_chat.__init__(self) + self.receiver = receiver + self.set_terminator(None) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.buffer = '' + self.set_terminator('\r\n') + self.connect(address) + print 'L:', '' + + def handle_connect(self): + print 'L:', '' + + def collect_incoming_data(self, data): + self.buffer = self.buffer + data + + def found_terminator(self): + data = self.buffer + self.buffer = '' + for line in data.splitlines(): + print 'L:', '\x1b[35m' + line + '\x1b[0m' + self.receiver.push(data + '\r\n') + + def handle_close(self): + self.receiver.close() + self.close() + + +class proxy_receiver(asynchat.async_chat): + + channel_counter = 0 + + def __init__(self, server, (conn, addr)): + asynchat.async_chat.__init__(self, conn) + self.set_terminator('\r\n') + self.server = server + self.id = self.channel_counter + self.channel_counter = self.channel_counter + 1 + self.sender = proxy_sender (self, server.there) + self.sender.id = self.id + self.buffer = '' + + def collect_incoming_data (self, data): + self.buffer = self.buffer + data + + def found_terminator(self): + data = self.buffer + self.buffer = '' + for line in data.splitlines(): + print 'I:', '\x1b[34m' + line + '\x1b[0m' + self.sender.push (data + '\r\n') + + def handle_connect(self): + print 'I:', '' + + def handle_close(self): + print 'I:', '' + self.sender.close() + self.close() + + +if __name__ == '__main__': + if len(sys.argv) < 3: + print 'Usage: %s ' % sys.argv[0] + else: + ps = proxy_server(sys.argv[1], int(sys.argv[2])) + asyncore.loop() diff --git a/trac-0.11/setup.cfg b/trac-0.11/setup.cfg new file mode 100644 --- /dev/null +++ b/trac-0.11/setup.cfg @@ -0,0 +1,8 @@ +[egg_info] +tag_build = dev +tag_svn_revision = true + +[unittest] +xml_output = build/test-results.xml +coverage_summary = build/test-coverage.txt +coverage_dir = build/coverage diff --git a/trac-0.11/setup.py b/trac-0.11/setup.py new file mode 100755 --- /dev/null +++ b/trac-0.11/setup.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# Copyright (C) 2005-2007 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.edgewall.org/wiki/License. + +import os +from setuptools import setup, find_packages +import sys + +sys.path.append(os.path.join('doc', 'common')) +try: + from doctools import build_doc, test_doc +except ImportError: + build_doc = test_doc = None + +NS = 'http://bitten.cmlenz.net/tools/' + +setup( + name = 'Bitten', + version = '0.6', + description = 'Continuous integration for Trac', + long_description = \ +"""A Trac plugin for collecting software metrics via continuous integration.""", + author = 'Edgewall Software', + author_email = 'info@edgewall.org', + license = 'BSD', + url = 'http://bitten.edgewall.org/', + download_url = 'http://bitten.edgewall.org/wiki/Download', + zip_safe = False, + + packages = find_packages(exclude=['*.tests*']), + package_data = { + 'bitten': ['htdocs/*.*', + 'htdocs/charts_library/*.swf', + 'templates/*.html'] + }, + test_suite = 'bitten.tests.suite', + entry_points = { + 'console_scripts': [ + 'bitten-slave = bitten.slave:main' + ], + 'distutils.commands': [ + 'unittest = bitten.util.testrunner:unittest' + ], + 'trac.plugins': [ + 'bitten.admin = bitten.admin', + 'bitten.main = bitten.main', + 'bitten.master = bitten.master', + 'bitten.web_ui = bitten.web_ui', + 'bitten.testing = bitten.report.testing', + 'bitten.coverage = bitten.report.coverage' + ], + 'bitten.recipe_commands': [ + NS + 'sh#exec = bitten.build.shtools:exec_', + NS + 'sh#pipe = bitten.build.shtools:pipe', + NS + 'c#configure = bitten.build.ctools:configure', + NS + 'c#autoreconf = bitten.build.ctools:autoreconf', + NS + 'c#cppunit = bitten.build.ctools:cppunit', + NS + 'c#cunit = bitten.build.ctools:cunit', + NS + 'c#gcov = bitten.build.ctools:gcov', + NS + 'c#make = bitten.build.ctools:make', + NS + 'java#ant = bitten.build.javatools:ant', + NS + 'java#junit = bitten.build.javatools:junit', + NS + 'java#cobertura = bitten.build.javatools:cobertura', + NS + 'php#phing = bitten.build.phptools:phing', + NS + 'php#phpunit = bitten.build.phptools:phpunit', + NS + 'php#coverage = bitten.build.phptools:coverage', + NS + 'python#coverage = bitten.build.pythontools:coverage', + NS + 'python#distutils = bitten.build.pythontools:distutils', + NS + 'python#exec = bitten.build.pythontools:exec_', + NS + 'python#figleaf = bitten.build.pythontools:figleaf', + NS + 'python#pylint = bitten.build.pythontools:pylint', + NS + 'python#trace = bitten.build.pythontools:trace', + NS + 'python#unittest = bitten.build.pythontools:unittest', + NS + 'svn#checkout = bitten.build.svntools:checkout', + NS + 'svn#export = bitten.build.svntools:export', + NS + 'svn#update = bitten.build.svntools:update', + NS + 'xml#transform = bitten.build.xmltools:transform' + ] + }, + + cmdclass = {'build_doc': build_doc, 'test_doc': test_doc} +)