=== added directory 'coverage-extractor'
=== added directory 'coverage-extractor/coverageextractor'
=== added file 'coverage-extractor/coverageextractor/__init__.py'
--- coverage-extractor/coverageextractor/__init__.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/__init__.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,75 @@
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from functools import wraps
+import time
+import xml.etree.ElementTree as etree
+
+
+class CoverageExtractorException(ValueError):
+ """Bad things happen to good extractors."""
+
+
+def extract_coverage_data(coverage_xml):
+ """Return line and branch numbers from coverage.xml.
+
+ :param coverage_xml: coverage.xml string
+ """
+ try:
+ # root node is coverage, more complex patterns possible later
+ coverage_node = etree.fromstring(coverage_xml)
+ return (
+ float(coverage_node.attrib['line-rate']),
+ float(coverage_node.attrib['branch-rate']),
+ )
+ except etree.ParseError as e:
+ raise CoverageExtractorException(
+ "Failed to parse coverage.xml: {}".format(e.message)
+ )
+ except ValueError as e:
+ raise CoverageExtractorException(
+ "Failed to parse coverage numbers: {}".format(e.message)
+ )
+
+
+def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None):
+ """Retry calling the decorated function using an exponential backoff.
+
+ http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/
+ Original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry
+
+ :param ExceptionToCheck: the exception to check. may be a tuple of
+ exceptions to check
+ :param tries: number of times to try (not retry) before giving up
+ :param delay: initial delay between retries in seconds
+ :param backoff: backoff multiplier e.g. value of 2 will double the delay
+ each retry
+ :param logger: logger.Logger to use.
+ """
+ def deco_retry(f):
+ @wraps(f)
+ def f_retry(*args, **kwargs):
+ for i in range(tries):
+ try:
+ return f(*args, **kwargs)
+ except ExceptionToCheck as e:
+ d = delay * backoff ** i
+ if logger:
+ logger.warning(
+ "%s, retrying in %d seconds. . . ." % (e, d))
+ time.sleep(d)
+ return f(*args, **kwargs)
+ return f_retry # true decorator
+ return deco_retry
=== added file 'coverage-extractor/coverageextractor/extraction.py'
--- coverage-extractor/coverageextractor/extraction.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/extraction.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,226 @@
+#!/usr/bin/env python
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import json
+
+from ci_utils import amqp_utils
+from ci_utils.data_store import DataStoreException
+from coverageextractor import (
+ CoverageExtractorException,
+ extract_coverage_data,
+ retry,
+)
+
+from nfss_client import (
+ NfssClient,
+ NfssClientError,
+ NFSS_CONFIG_PATH,
+)
+
+SUCCESS_TEMPLATE = u'Deposited coverage.xml into swift for ticket %s.'
+MISSING_PARAMS_TEMPLATE = (u'Failed to handle request: missing coverage.xml '
+ 'filename for ticket %s')
+PARSE_FAILURE_TEMPLATE = u'Failed to parse coverage.xml for ticket %s: %s'
+SWIFT_GET_FAILURE_TEMPLATE = (u'Failed to retrieve coverage.xml from swift '
+ 'for ticket %s: %s')
+SWIFT_PUT_FAILURE_TEMPLATE = (u'Failed to put coverage.xml into swift for '
+ 'ticket %s: %s')
+JSON_ENCODING_FAILURE_TEMPLATE = (u'Failed to encode JSON for '
+ 'ticket %s: %s')
+NFSS_CLIENT_ERROR_TEMPLATE = u'Failed to POST to NFSS for ticket %s: %s'
+
+
+def generate_and_put_artifact(
+ data_store,
+ name='coverage-extractor.output.log',
+ type='LOGS',
+ message=None,
+ reference=None):
+ """Return a uci-artifact generated and deposited in swift.
+
+ NOTE that if no reference is supplied, the artifact is deposited
+ into swift and the resulting link is reported in the returned
+ artifact; provided for convenience in logging.
+
+ :param data_store: DataStore into which to put
+ :param name: filename of artifact in swift
+ :param type: 'LOGS' for logs or 'RESULTS' for coverage.xml
+ :param message: a log message
+ :param reference: swift link; supplied upon deposit if not specified
+ """
+ artifact = {
+ 'name': name,
+ 'type': type,
+ }
+ if message is not None:
+ artifact['message'] = message
+ if reference is None:
+ reference = data_store.put_file(name, str(artifact), 'text/plain')
+ artifact['reference'] = reference
+ return artifact
+
+
+class ExtractionFailed(Exception):
+ """Return a dict upon operation failure.."""
+
+ def __init__(self, return_tuple):
+ self.return_tuple = return_tuple
+
+
+class Extraction(object):
+
+ def __init__(self, ticket_id, logger, data_store, nfss_config_path=None):
+ self.ticket_id = ticket_id
+ self.logger = logger
+ self.data_store = data_store
+ # XXX alesage 20141022
+ # Possibly we want to own the NFSS client here instead?
+ self.nfss_config_path = nfss_config_path or NFSS_CONFIG_PATH
+ self.result = dict(artifacts=[])
+
+ def try_get_project_and_version(self, params):
+ """Extract project name and version from params.
+
+ :param params: ticket system handle_request calling params
+ """
+ # XXX alesage 20141022
+ # for the moment we treat the one-subticket case; a ticket may
+ # report multiple builds corresponding to architectures, e.g.
+ subticket = params['subtickets'][0]
+ return subticket['name'], subticket['version']
+
+ def try_get_coverage_xml_path(self, params):
+ """Extract coverage.xml path from dict of params.
+
+ :param params: dict, hopefully with 'coverage_xml_path' as key
+ """
+ # XXX alesage 20141022
+ # Consider the case of multiple coverage.xml?
+ try:
+ return params['coverage_xml_path']
+ except KeyError:
+ self.report_failed(
+ MISSING_PARAMS_TEMPLATE,
+ self.ticket_id
+ )
+
+ def try_get_coverage_xml(self, coverage_xml_path):
+ """Get coverage.xml at given path from swift.
+
+ :param coverage_xml_path: swift path from which to get
+ """
+ try:
+ @retry(DataStoreException, logger=self.logger)
+ def get_build_log_actual():
+ return self.data_store.get_file(coverage_xml_path)
+ return get_build_log_actual()
+ except DataStoreException as e:
+ self.report_failed(
+ SWIFT_GET_FAILURE_TEMPLATE,
+ self.ticket_id,
+ e.message,
+ )
+
+ def try_extract_coverage_data(self, coverage_xml):
+ """Get line and branch percentages from coverage.xml.
+
+ :param coverage_xml: coverage_xml string
+ """
+ try:
+ return extract_coverage_data(coverage_xml)
+ except CoverageExtractorException as e:
+ self.report_failed(
+ PARSE_FAILURE_TEMPLATE,
+ self.ticket_id,
+ e.message,
+ )
+
+ def try_compose_nfss_json(
+ self,
+ name,
+ version,
+ line_coverage,
+ branch_coverage):
+ """Compose a JSON string of data for submission to NFSS.
+
+ :param name: package name
+ :param version: package version string
+ :param line_coverage: line coverage (float)
+ :param branch_coverage: branch coverage (float)
+ """
+ try:
+ return json.dumps({
+ 'name': name,
+ 'version': version,
+ 'line_coverage': line_coverage,
+ 'branch_coverage': branch_coverage,
+ })
+ except ValueError as e:
+ # This is pretty unlikely.
+ self.report_failed(
+ JSON_ENCODING_FAILURE_TEMPLATE,
+ self.ticket_id,
+ e.message
+ )
+
+ def try_post_data_to_nfss(self, json, test_name, project_name='coverage'):
+ """Post a JSON string of coverage data to NFSS.
+
+ :param json: coverage data JSON to post to NFSS
+ """
+ try:
+ nfss_client = NfssClient(self.nfss_config_path)
+ nfss_client.post_data(json, test_name, project_name)
+ except NfssClientError as e:
+ self.report_failed(
+ NFSS_CLIENT_ERROR_TEMPLATE,
+ self.ticket_id,
+ e.message
+ )
+
+ def report_failed(self, format_str, *str_args):
+ """Convenience function to raise and log a failiure.
+
+ :param format_str: template to log
+ :str_args str_args: items with which to format format_str
+ """
+ message = format_str % str_args
+ self.logger.error(message)
+ self.result['message'] = message
+ self.result['artifacts'].append(
+ generate_and_put_artifact(self.data_store, message=message)
+ )
+ raise ExtractionFailed(
+ (amqp_utils.progress_failed, self.result)
+ )
+
+ def report_succeeded(self, ticket_id):
+ """Deposit a result artifact and return a log artifact.
+
+ :param ticket_id: ticket for which to report retrieval success
+ """
+ message = SUCCESS_TEMPLATE % ticket_id
+ # NOTE that logging compels us to pass params to info (can't
+ # reuse message declared above)
+ self.logger.info(SUCCESS_TEMPLATE, ticket_id)
+ self.result['artifacts'].append(
+ generate_and_put_artifact(
+ self.data_store,
+ message=message
+ )
+ )
+ self.result['message'] = message
+ return amqp_utils.progress_completed, self.result
=== added file 'coverage-extractor/coverageextractor/nfss_client.py'
--- coverage-extractor/coverageextractor/nfss_client.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/nfss_client.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,80 @@
+#!/usr/bin/env python
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from ConfigParser import ConfigParser
+
+from requests_oauthlib import OAuth1Session
+
+# XXX alesage 20141022
+# thomi, where to find this config?
+NFSS_CONFIG_PATH = "TODO-fake-nfss-config-path"
+
+
+class NfssClientError(Exception):
+ """Non-functional non-functional stats service."""
+
+
+def parse_nfss_config(nfss_config_path):
+ """Return a ConfigParser config from NFSS config filepath.
+
+ NOTE that we only return the 'nfss' section of the config.
+
+ :param nfss_config_path: path to NFSS config
+ """
+ # XXX alesage 20141022
+ # Discussed with thomi and fginther, need to sort the details of
+ # the config deployment, defer for the moment; an Asana task has
+ # been created to discuss in D.C.
+ config = ConfigParser()
+ config.read(nfss_config_path)
+ # are all of our ducks in a row?
+ for config_item in (
+ 'client_access_key',
+ 'resource_owner_key',
+ 'resource_owner_secret',
+ 'backend'):
+ # (we'll also raise KeyError on missing 'nfss' section)
+ if config_item not in config['nfss']:
+ raise KeyError(config_item)
+ return config['nfss']
+
+
+class NfssClient(object):
+
+ def __init__(self, nfss_config_path=None):
+ if nfss_config_path is None:
+ self.nfss_config_path = NFSS_CONFIG_PATH
+ try:
+ # XXX alesage 20141022
+ # Discuss how to deploy this config properly with thomi
+ # and fginther, following from
+ # https://code.launchpad.net/~allanlesage/uci-engine/coverage-extractor/+merge/238782. # NOQA
+ self.config = parse_nfss_config(nfss_config_path)
+ except KeyError as e:
+ raise NfssClientError('Failed to parse NFSS config: '
+ '%s' % e.message)
+ self.session = OAuth1Session(
+ self.config['client_access_key'],
+ resource_owner_key=self.config['resource_owner_key'],
+ resource_owner_secret=self.config['resource_owner_secret'],
+ )
+
+ def post_data(self, json, project_name, test_name):
+ """POST JSON result data to NFSS at a given project and test."""
+ url = '/'.join((self.config['backend'], project_name, test_name))
+ r = self.session.post(url, dict(data=json))
+ if r.status_code != 200:
+ raise NfssClientError("Failed to POST to NFSS: %s" % r.content)
=== added file 'coverage-extractor/coverageextractor/run_worker.py'
--- coverage-extractor/coverageextractor/run_worker.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/run_worker.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from ci_utils import amqp_utils, amqp_worker
+from coverageextractor.extraction import Extraction, ExtractionFailed
+
+
+class CoverageExtractorWorker(amqp_worker.AMQPWorker):
+
+ def __init__(self):
+ super(CoverageExtractorWorker, self).__init__('coverageextractor')
+
+ def handle_request(self, params, logger):
+ """Extract coverage numbers from coverage.xml and post to NFSS.
+
+ :param params: dictionary of params from incoming message
+ :param logger: logging.Logger for reporting
+ """
+ # XXX alesage 20141022
+ # Consider multi-coverage.xml builds, e.g. tickets reporting
+ # builds of a package for more than one architecture. These
+ # could each initiate an Extraction, e.g.? Also beware naming
+ # overlap for results.
+ ticket_id = params['ticket_id']
+ data_store = self._create_data_store(ticket_id)
+ extraction = Extraction(
+ ticket_id,
+ logger,
+ data_store,
+ )
+ try:
+ project, version = extraction.try_get_project_and_version(params)
+ coverage_xml_path = extraction.try_get_coverage_xml_path(params)
+ coverage_xml = extraction.try_get_coverage_xml(coverage_xml_path)
+ line_data, branch_data = extraction.try_extract_coverage_data(
+ coverage_xml
+ )
+ data = extraction.try_compose_nfss_json(
+ project,
+ version,
+ line_data,
+ branch_data,
+ )
+ extraction.try_post_data_to_nfss(data, project)
+ except ExtractionFailed as e:
+ return e.return_tuple
+ return extraction.report_succeeded(ticket_id)
+
+
+if __name__ == '__main__':
+ CoverageExtractorWorker().main(amqp_utils.COVERAGE_RETRIEVER_QUEUE)
=== added directory 'coverage-extractor/coverageextractor/tests'
=== added file 'coverage-extractor/coverageextractor/tests/__init__.py'
--- coverage-extractor/coverageextractor/tests/__init__.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/tests/__init__.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,86 @@
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from contextlib import contextmanager
+import unittest
+
+from mock import patch
+
+from ci_utils.amqp_worker import AMQPWorker
+from ci_utils.testing.fixtures import FakeDataStore
+
+vanilla_coverage_xml = u"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""" # NOQA
+
+
+class CoverageExtractorTestCase(unittest.TestCase):
+
+ @contextmanager
+ def patch_create_data_store(self):
+ """Patch in a FakeDataStore for testing."""
+ self.fake_data_store = FakeDataStore('fake_ticket_id')
+ self.addCleanup(self.fake_data_store.delete)
+ with patch.object(
+ AMQPWorker,
+ '_create_data_store',
+ return_value=self.fake_data_store) as create_data_store_patch:
+ yield create_data_store_patch
+
+ def put_fake_coverage_xml(
+ self,
+ data_store,
+ coverage_xml=vanilla_coverage_xml):
+ """Put a build log into our fake data store.
+
+ :param data_store: FakeDataStore into which to put
+ :param coverage_xml: str defaults to vanilla_coverage_xml
+ """
+ data_store.put_file(
+ 'coverage.xml',
+ coverage_xml,
+ content_type='text/plain'
+ )
=== added file 'coverage-extractor/coverageextractor/tests/test_extract_coverage_data.py'
--- coverage-extractor/coverageextractor/tests/test_extract_coverage_data.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/tests/test_extract_coverage_data.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,162 @@
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import unittest
+
+from coverageextractor import (
+ CoverageExtractorException,
+ extract_coverage_data,
+)
+from coverageextractor.tests import vanilla_coverage_xml
+
+
+# here we refuse to close our tag :|
+invalid_xml_coverage_xml = u"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""" # NOQA
+
+# NOTE a *baseball* dingbat in sources filepath
+unicode_coverage_xml = unicode("""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""") # NOQA
+
+# we'll almost certainly never see garbled coverage data but hey
+garbled_numbers_coverage_xml = u"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""" # NOQA
+
+
+class ExtractCoverageDataTestCase(unittest.TestCase):
+
+ def test_extract_coverage_data(self):
+ """Extract coverage data from a vanilla build log bytestring."""
+ line_coverage, branch_coverage = extract_coverage_data(
+ vanilla_coverage_xml
+ )
+ self.assertAlmostEqual(1.0, line_coverage)
+ self.assertAlmostEqual(1.0, branch_coverage)
+
+ def test_invalid_xml_raises(self):
+ """We raise an exception if we find invalid XML."""
+ self.assertRaises(
+ CoverageExtractorException,
+ extract_coverage_data,
+ invalid_xml_coverage_xml
+ )
+
+ def test_garbled_numbers_raise(self):
+ """We raise an exception if coverage numbers don't float."""
+ self.assertRaises(
+ CoverageExtractorException,
+ extract_coverage_data,
+ garbled_numbers_coverage_xml
+ )
+
+ def test_unicode_parses(self):
+ """Extract coverage data from a build log with unicode characters."""
+ line_coverage, branch_coverage = extract_coverage_data(
+ unicode_coverage_xml
+ )
+ self.assertAlmostEqual(1.0, line_coverage)
+ self.assertAlmostEqual(1.0, branch_coverage)
=== added file 'coverage-extractor/coverageextractor/tests/test_extraction.py'
--- coverage-extractor/coverageextractor/tests/test_extraction.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/tests/test_extraction.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,233 @@
+#!/usr/bin/env python
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import logging
+
+from mock import patch, Mock
+
+from ci_utils.data_store import DataStoreException
+from coverageextractor import CoverageExtractorException
+from coverageextractor.nfss_client import NfssClient, NfssClientError
+from coverageextractor.tests import (
+ CoverageExtractorTestCase,
+ vanilla_coverage_xml,
+)
+from coverageextractor.extraction import (
+ Extraction,
+ ExtractionFailed,
+ JSON_ENCODING_FAILURE_TEMPLATE,
+ NFSS_CLIENT_ERROR_TEMPLATE,
+ PARSE_FAILURE_TEMPLATE,
+ SUCCESS_TEMPLATE,
+ SWIFT_GET_FAILURE_TEMPLATE,
+)
+
+
+class ExtractionTestCase(CoverageExtractorTestCase):
+
+ def test_try_get_project_and_version(self):
+ """Vanilla get project and version from subticket."""
+ params = {
+ 'subtickets': [
+ {
+ 'name': 'libfibonacci',
+ 'version': '0.1-0ubuntu1',
+ },
+ ]
+ }
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ self.assertEqual(
+ ('libfibonacci', '0.1-0ubuntu1'),
+ ext.try_get_project_and_version(params)
+ )
+
+ def test_try_get_project_and_version_raises(self):
+ """Failure to find project and version raises."""
+
+ def test_try_get_coverage_xml_path(self):
+ """We get the coverage.xml path from params."""
+ with self.patch_create_data_store() as fake_data_store:
+ params = {'coverage_xml_path': 'coverage.xml'}
+ ext = Extraction('fake-ticket-id', 'fake-logger', fake_data_store)
+ self.assertEqual(
+ 'coverage.xml',
+ ext.try_get_coverage_xml_path(params),
+ )
+
+ @patch.object(Extraction, 'report_failed')
+ def test_extract_coverage_xml_path_failure(self, report_failed):
+ """Failure to get the coverage.xml path from params fails."""
+ with self.patch_create_data_store() as fake_data_store:
+ malevolent_params = {'gibberish': 'coverage.xml'}
+ ext = Extraction('fake-ticket-id', 'fake-logger', fake_data_store)
+ ext.try_get_coverage_xml_path(malevolent_params)
+ report_failed.assert_called()
+
+ def test_try_get_coverage_xml(self):
+ """Retrieve our coverage.xml from swift."""
+ with self.patch_create_data_store() as fake_data_store:
+ fake_data_store.get_file.return_value = vanilla_coverage_xml
+ ext = Extraction('fake-ticket-id', 'fake-logger', fake_data_store)
+ self.assertEqual(
+ vanilla_coverage_xml,
+ ext.try_get_coverage_xml('coverage.xml'),
+ )
+
+ def test_try_get_coverage_xml_retries(self):
+ """Retry if swift fails to yield our coverage.xml."""
+ fake_logger = Mock(spec_set=logging.Logger)
+ with self.patch_create_data_store() as fake_data_store:
+ ext = Extraction('fake-ticket-id', fake_logger, fake_data_store)
+ fake_data_store.get_file.side_effect = [
+ DataStoreException("No log!"),
+ DataStoreException("Still no log!"),
+ vanilla_coverage_xml
+ ]
+ with patch('coverageextractor.time.sleep'): # for the impatient
+ self.assertEqual(
+ vanilla_coverage_xml,
+ ext.try_get_coverage_xml('coverage.xml')
+ )
+ self.assertEqual(3, fake_data_store.get_file.call_count)
+ fake_logger.warning.assert_called()
+
+ @patch.object(Extraction, 'report_failed')
+ def test_try_get_coverage_xml_raises(self, report_failed):
+ """Return failure if swift fails to yield our coverage.xml."""
+ fake_logger = Mock(spec_set=logging.Logger)
+ with self.patch_create_data_store() as fake_data_store:
+ ext = Extraction('fake-ticket-id', fake_logger, fake_data_store)
+ fake_data_store.get_file.side_effect = DataStoreException(
+ 'Drool...'
+ )
+ with patch('coverageextractor.time.sleep'): # for the impatient
+ ext.try_get_coverage_xml('coverage.xml')
+ report_failed.assert_called_with(
+ SWIFT_GET_FAILURE_TEMPLATE,
+ 'fake-ticket-id',
+ 'Drool...',
+ )
+
+ def test_try_extract_coverage_data(self):
+ """Extract coverage data from coverage.xml."""
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ line_coverage, branch_coverage = ext.try_extract_coverage_data(
+ vanilla_coverage_xml
+ )
+ self.assertAlmostEqual(1.0, line_coverage)
+ self.assertAlmostEqual(1.0, branch_coverage)
+
+ @patch.object(Extraction, 'report_failed')
+ @patch('coverageextractor.extraction.extract_coverage_data')
+ def test_try_extract_coverage_data_raises(
+ self,
+ extract_coverage_data,
+ report_failed):
+ """Report failure when we fail to extract coverage data."""
+ extract_coverage_data.side_effect = CoverageExtractorException(
+ "No data for you!"
+ )
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ ext.try_extract_coverage_data(vanilla_coverage_xml)
+ report_failed.assert_called_with(
+ PARSE_FAILURE_TEMPLATE,
+ 'fake-ticket-id',
+ "No data for you!",
+ )
+
+ def test_compose_nfss_json(self):
+ """Dump a string of data to put to NFSS."""
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ json = ext.try_compose_nfss_json(
+ 'fake-package-name',
+ '0.1.PBJ.BBQ-0ubuntu1',
+ 1.0,
+ 0.572,
+ )
+ self.assertEqual(
+ '{"version": "0.1.PBJ.BBQ-0ubuntu1", "name": "fake-package-name", "line_coverage": 1.0, "branch_coverage": 0.572}', # NOQA
+ json
+ )
+
+ @patch.object(Extraction, 'report_failed')
+ @patch('coverageextractor.extraction.json.dumps')
+ def test_compose_nfss_json_raises(self, dumps, report_failed):
+ """Handle the error from un-jsonnable params.
+
+ This is pretty unlikely.
+ """
+ dumps.side_effect = ValueError('This is pretty unlikely.')
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ ext.try_compose_nfss_json(
+ 'fake-package-name',
+ '0.1.PBJ.BBQ-0ubuntu1',
+ 1.0,
+ 0.572,
+ )
+ report_failed.assert_called_with(
+ JSON_ENCODING_FAILURE_TEMPLATE,
+ 'fake-ticket-id',
+ "This is pretty unlikely.",
+ )
+
+ @patch.object(NfssClient, 'post_data')
+ def test_try_post_data_to_nfss(self, post_data):
+ """Post JSON result to NFSS."""
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ with patch.object(NfssClient, '__init__', new=Mock(return_value=None)):
+ ext.try_post_data_to_nfss({'foo': 42}, 'libfibonacci', 'coverage')
+ post_data.assert_called_with({'foo': 42}, 'libfibonacci', 'coverage')
+
+ @patch.object(Extraction, 'report_failed')
+ @patch.object(NfssClient, 'post_data')
+ def test_nfss_doesnt_love_us(self, post_data, report_failed):
+ """Bad post to NFSS reports failure."""
+ ext = Extraction('fake-ticket-id', 'fake-logger', 'fake-data-store')
+ post_data.side_effect = NfssClientError('NFSS luvs u <3 .')
+ with patch.object(NfssClient, '__init__', new=Mock(return_value=None)):
+ ext.try_post_data_to_nfss({'foo': 42}, 'libfibonacci', 'coverage')
+ report_failed.assert_called_with(
+ NFSS_CLIENT_ERROR_TEMPLATE,
+ 'fake-ticket-id',
+ 'NFSS luvs u <3 .',
+ )
+
+ def test_report_failed_raises(self):
+ """Internal catch-all handler raises ExtractionFailed."""
+ with self.patch_create_data_store() as fake_data_store:
+ fake_logger = Mock(spec_set=logging.Logger)
+ ext = Extraction('fake-ticket-id', fake_logger, fake_data_store)
+ self.assertRaises(
+ ExtractionFailed,
+ lambda: ext.report_failed("%s %s", 'foo', 'bar')
+ )
+ fake_logger.error.assert_called_with("foo bar")
+
+ @patch('coverageextractor.extraction.generate_and_put_artifact')
+ def test_report_succeeded(self, generate_and_put_artifact):
+ """Report success to swift and logging."""
+ generate_and_put_artifact.side_effect = ['coverage.xml',
+ 'success-message']
+ with self.patch_create_data_store() as fake_data_store:
+ fake_logger = Mock(spec_set=logging.Logger)
+ ext = Extraction('fake-ticket-id', fake_logger, fake_data_store)
+ ext.report_succeeded('fake-ticket-id')
+ fake_logger.info.assert_called_with(SUCCESS_TEMPLATE, 'fake-ticket-id')
+ generate_and_put_artifact.assert_any_call(
+ fake_data_store,
+ message=(SUCCESS_TEMPLATE % 'fake-ticket-id'),
+ )
+ self.assertEqual(1, generate_and_put_artifact.call_count)
=== added file 'coverage-extractor/coverageextractor/tests/test_handle_request.py'
--- coverage-extractor/coverageextractor/tests/test_handle_request.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/tests/test_handle_request.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,121 @@
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import logging
+import os
+
+from mock import Mock, patch
+
+from ci_utils import amqp_utils
+from coverageextractor.extraction import Extraction
+from coverageextractor.tests import CoverageExtractorTestCase
+from coverageextractor.extraction import (
+ MISSING_PARAMS_TEMPLATE,
+ SUCCESS_TEMPLATE,
+)
+from coverageextractor.run_worker import CoverageExtractorWorker
+
+
+class HandleRequestTestCase(CoverageExtractorTestCase):
+
+ @patch.object(Extraction, 'try_post_data_to_nfss')
+ def test_vanilla_handle_request(self, try_post_data_to_nfss):
+ """Large integration test for coverage extraction.
+
+ NOTE that we do patch the NFSS call.
+ """
+ fake_logger = Mock(spec_set=logging.Logger)
+ with self.patch_create_data_store():
+ self.put_fake_coverage_xml(self.fake_data_store)
+ worker = CoverageExtractorWorker()
+ fake_params = {
+ 'coverage_xml_path': 'coverage.xml',
+ 'ticket_id': 'fake_ticket_id',
+ 'subtickets': [
+ {
+ 'name': 'libfibonacci',
+ 'version': '0.1-0ubuntu1',
+ },
+ ],
+ }
+ amqp_result, result_data = worker.handle_request(
+ fake_params,
+ fake_logger,
+ )
+ self.assertEqual(amqp_utils.progress_completed, amqp_result)
+ self.assertEqual(
+ SUCCESS_TEMPLATE % 'fake_ticket_id',
+ result_data['message'],
+ )
+ fake_logger.info.assert_called_with(
+ SUCCESS_TEMPLATE, 'fake_ticket_id'
+ )
+ self.assertEqual(1, len(result_data['artifacts']))
+ # find a log message as our only artifact
+ log_artifact = result_data['artifacts'][0]
+ self.assertEqual(
+ SUCCESS_TEMPLATE % 'fake_ticket_id',
+ log_artifact['message'],
+ )
+ self.assertEqual('LOGS', log_artifact['type'])
+ self.assertEqual(
+ 'file://' + os.path.join(
+ os.path.abspath(os.curdir),
+ 'fake_ticket_id/coverage-extractor.output.log'),
+ log_artifact['reference']
+ )
+
+ def test_exception_returns_failure(self):
+ """An exception in our imperatives returns a failure.
+
+ Actuate a KeyError in try_extract_coverage_xml_path, inspect our
+ results to make sure we're logging, depositing a log in swift.
+ """
+ fake_logger = Mock(spec_set=logging.Logger)
+ with self.patch_create_data_store() as fake_data_store:
+ self.put_fake_coverage_xml(fake_data_store)
+ worker = CoverageExtractorWorker()
+ # actuate a KeyError in try_extract_coverage_xml_path
+ malevolent_params = {
+ 'gibberish': 'coverage.xml',
+ 'ticket_id': 'fake_ticket_id',
+ 'subtickets': [
+ {
+ 'name': 'libfibonacci',
+ 'version': '0.1-0ubuntu1',
+ },
+ ],
+ }
+ amqp_result, result_data = worker.handle_request(
+ malevolent_params,
+ fake_logger
+ )
+ self.assertEqual(amqp_utils.progress_failed, amqp_result)
+ self.assertEqual(
+ MISSING_PARAMS_TEMPLATE % 'fake_ticket_id',
+ result_data['message']
+ )
+ self.assertEqual(1, len(result_data['artifacts']))
+ artifact = result_data['artifacts'][0]
+ self.assertEqual(
+ 'file://' + os.path.join(
+ os.path.abspath(os.curdir),
+ 'fake_ticket_id/coverage-extractor.output.log'),
+ artifact['reference'])
+ self.assertEqual(
+ MISSING_PARAMS_TEMPLATE % 'fake_ticket_id',
+ result_data['message']
+ )
+ self.assertEqual('LOGS', artifact['type'])
=== added file 'coverage-extractor/coverageextractor/tests/test_nfss_client.py'
--- coverage-extractor/coverageextractor/tests/test_nfss_client.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/coverageextractor/tests/test_nfss_client.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,150 @@
+#!/usr/bin/env python
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import unittest
+
+from mock import Mock, patch
+
+from coverageextractor.nfss_client import (
+ NFSS_CONFIG_PATH,
+ NfssClient,
+ NfssClientError,
+ parse_nfss_config,
+)
+
+
+class ParseNfssConfigTestCase(unittest.TestCase):
+
+ @patch('coverageextractor.nfss_client.ConfigParser')
+ def test_parse(self, config_parser):
+ """We can successfully parse an NFSS config."""
+ nfss_config_dict = {
+ 'client_access_key': 'fake-client-acccess-key',
+ 'resource_owner_key': 'fake-resource-owner-key',
+ 'resource_owner_secret': 'fake-resource-owner-secret',
+ 'backend': 'fake-backend',
+ }
+ # yes these are weird due to ConfigParser backwardness
+ config_parser.return_value.read = Mock()
+ # "dance like no-one is watching"
+ config_parser.return_value.__getitem__ = Mock(
+ return_value=nfss_config_dict
+ )
+ self.assertEqual(
+ nfss_config_dict,
+ parse_nfss_config('fake-config-path')
+ )
+
+ @patch('coverageextractor.nfss_client.ConfigParser')
+ def test_no_nfss_section_raises(self, config_parser):
+ """No 'nfss' section in config raises."""
+ config_parser.return_value.read = Mock()
+ config_parser.return_value.__getitem__ = Mock(
+ side_effect=KeyError('client_access_key')
+ )
+ with self.assertRaises(KeyError):
+ parse_nfss_config('fake-config-path')
+
+ @patch('coverageextractor.nfss_client.ConfigParser')
+ def test_missing_key_raises(self, config_parser):
+ """A missing key in the config raises."""
+ nfss_config_dict = {}
+ config_parser.return_value.read = Mock()
+ config_parser.return_value.__getitem__ = Mock(
+ return_value=nfss_config_dict
+ )
+ with self.assertRaises(KeyError):
+ parse_nfss_config('fake-config-path')
+
+
+class NfssClientTest(unittest.TestCase):
+
+ @patch('coverageextractor.nfss_client.OAuth1Session')
+ @patch('coverageextractor.nfss_client.parse_nfss_config')
+ def test_init(self, parse_nfss_config, oauth_session):
+ """We can create a NfssClient."""
+ parse_nfss_config.return_value = {
+ 'client_access_key': 'fake-client-access-key',
+ 'resource_owner_key': 'fake-resource-owner-key',
+ 'resource_owner_secret': 'fake-resource-owner-secret'
+ }
+ NfssClient('fake-config-path')
+ oauth_session.assert_called_with(
+ 'fake-client-access-key',
+ resource_owner_key='fake-resource-owner-key',
+ resource_owner_secret='fake-resource-owner-secret',
+ )
+
+ @patch('coverageextractor.nfss_client.OAuth1Session')
+ @patch('coverageextractor.nfss_client.parse_nfss_config')
+ def test_no_config_path(self, parse_nfss_config, oauth_session):
+ """Config path defaults if not passed in as param."""
+ parse_nfss_config.return_value = {
+ 'client_access_key': 'fake-client-access-key',
+ 'resource_owner_key': 'fake-resource-owner-key',
+ 'resource_owner_secret': 'fake-resource-owner-secret'
+ }
+ nfss_client = NfssClient()
+ oauth_session.assert_called_with(
+ 'fake-client-access-key',
+ resource_owner_key='fake-resource-owner-key',
+ resource_owner_secret='fake-resource-owner-secret',
+ )
+ self.assertEqual(NFSS_CONFIG_PATH, nfss_client.nfss_config_path)
+
+ @patch('coverageextractor.nfss_client.parse_nfss_config')
+ def test_init_raises(self, parse_nfss_config):
+ """We can create a NfssClient."""
+ parse_nfss_config.side_effect = KeyError('Mangled config!')
+ with self.assertRaises(NfssClientError):
+ NfssClient('fake-config-path')
+
+ def test_post_data(self):
+ """Vanilla POST to NFSS."""
+ with patch.object(
+ NfssClient,
+ '__init__',
+ new=Mock(return_value=None)):
+ nfss_client = NfssClient()
+ nfss_client.config = {'backend': 'http://NFSS.io'}
+ nfss_client.session = Mock(
+ # successful POST
+ post=Mock(return_value=Mock(status_code=200)),
+ )
+ nfss_client.post_data(
+ {'foo': 'bar'},
+ 'coverage',
+ 'libfibonacci',
+ )
+
+ def test_bad_post_data_raises(self):
+ """Bad POST to NFSS raises."""
+ with patch.object(
+ NfssClient,
+ '__init__',
+ new=Mock(return_value=None)):
+ nfss_client = NfssClient()
+ nfss_client.config = {'backend': 'http://NFSS.io'}
+ nfss_client.session = Mock(
+ # successful POST
+ post=Mock(return_value=Mock(status_code=500)),
+ )
+ with self.assertRaises(NfssClientError):
+ nfss_client.post_data(
+ {'foo': 'bar'},
+ 'coverage',
+ 'libfibonacci',
+ )
=== added file 'coverage-extractor/setup.py'
--- coverage-extractor/setup.py 1970-01-01 00:00:00 +0000
+++ coverage-extractor/setup.py 2014-10-23 21:46:26 +0000
@@ -0,0 +1,34 @@
+#!/usr/bin/env python
+# Ubuntu CI Engine
+# Copyright 2014 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from setuptools import setup, find_packages
+
+requires = [
+ 'amqplib==1.0.0',
+ 'python-swiftclient>=1.8.0',
+ 'PyYAML==3.10',
+ 'requests-oauthlib>=0.4.1',
+ 'ucitests==0.1.5',
+]
+
+setup(
+ name='coverageextractor',
+ version='0.1',
+ packages=find_packages(),
+ description='Coverage Retriever',
+ install_requires=requires,
+)
=== added file 'coverage-extractor/tox.ini'
--- coverage-extractor/tox.ini 1970-01-01 00:00:00 +0000
+++ coverage-extractor/tox.ini 2014-10-23 21:46:26 +0000
@@ -0,0 +1,19 @@
+[tox]
+envlist = py27, flake8
+
+[testenv]
+deps =
+ coverage
+ mock
+commands =
+ {envpython} setup.py develop
+ coverage erase
+ coverage run --omit=.tox/*,*/tests/*,ci_utils/* -m unittest discover -s coverageextractor
+ coverage report -m
+ coverage html
+
+[testenv:flake8]
+deps =
+ flake8
+commands =
+ flake8 coverageextractor
=== modified file 'testing/run_tests.py'
--- testing/run_tests.py 2014-10-10 10:00:49 +0000
+++ testing/run_tests.py 2014-10-23 21:46:26 +0000
@@ -121,6 +121,7 @@
'ppa-creator',
'validator',
'coverage-retriever',
+ 'coverage-extractor',
]
loader = loaders.Loader()
# setuptools tends to leave eggs all over the place