coverage_metrics.py 6.96 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
"""
Aggregate coverage data from XML reports.

groups.json is a JSON-encoded dict mapping group names to source file glob patterns:
{
    "group_1": "group1/*.py",
    "group_2": "group2/*.py"
}

This would calculate line coverage percentages for source files in each group,
and send those metrics to DataDog:

testeng.coverage.group_1 ==> 89.123
testeng.coverage.group_2 ==> 45.523

The tool uses the *union* of covered lines across each of the input
coverage XML reports.  If a line is covered *anywhere*, it's considered covered.
"""

import fnmatch
import json
from lxml import etree


class CoverageParseError(Exception):
    """
    Error occurred while parsing a coverage report.
    """
    pass


class CoverageData(object):
    """
    Aggregate coverage reports.
    """

    def __init__(self):
        """
        Initialize the coverage data, which has no information until you add a report.
        """
        self._coverage = dict()

    def add_report(self, report_str):
        """
        Add the coverage information from the XML `report_str` to the aggregate data.
        Raises a `CoverageParseError` if the report XML is not a valid coverage report.
        """
        try:
            root = etree.fromstring(report_str)

        except etree.XMLSyntaxError:
            raise CoverageParseError("Warning: Could not parse report as XML")

        if root is not None:

            # Get all classes (source files) in the report
            for class_node in root.xpath('//class'):

                class_filename = class_node.get('filename')

                if class_filename is None:
                    continue

                # If we haven't seen this source file before, create a dict
                # to store its coverage information.
                if class_filename not in self._coverage:
                    self._coverage[class_filename] = dict()

                # Store info for each line in the source file
                for line in class_node.xpath('lines/line'):

                    hits = line.get('hits')
                    line_num = line.get('number')

                    # Ignore lines that do not have the right attributes
                    if line_num is not None:

                        try:
                            line_num = int(line_num)
                            hits = int(hits)

                        except ValueError:
                            pass

                        else:

                            # If any report says the line is covered, set it to covered
                            if hits > 0:
                                self._coverage[class_filename][line_num] = 1

                            # Otherwise if the line is not already covered, set it to uncovered
                            elif line_num not in self._coverage[class_filename]:
                                self._coverage[class_filename][line_num] = 0

    def coverage(self, source_pattern="*"):
        """
        Calculate line coverage percentage (float) for source files that match
        `source_pattern` (a fnmatch-style glob pattern).

        If coverage could not be calculated (e.g. because no source files match
        the pattern), returns None.
        """
        num_covered = 0
        total = 0

        # Find source files that match the pattern then calculate total lines and number covered
        for filename in fnmatch.filter(self._coverage.keys(), source_pattern):
            num_covered += sum(self._coverage[filename].values())
            total += len(self._coverage[filename])

        # Calculate the percentage
        if total > 0:
            return float(num_covered) / float(total) * 100.0

        else:
            print u"Warning: No lines found in source files that match {}".format(source_pattern)
            return None

    @staticmethod
    def _parse_report(report_path):
        """
        Parse the coverage report as XML and return the resulting tree.
        If the report could not be found or parsed, return None.
        """
        try:
            return etree.parse(report_path)

        except IOError:
            print u"Warning: Could not open report at '{path}'".format(path=report_path)
            return None

        except ValueError:
            print u"Warning: Could not parse report at '{path}' as XML".format(path=report_path)
            return None


class CoverageMetrics(object):
    """
    Collect Coverage Reports for DataDog.
    """

    def __init__(self, group_json_path, report_paths):
        self._group_json_path = group_json_path
        self._report_paths = report_paths

    def coverage_metrics(self):
        """
        Find, parse, and create coverage metrics to be sent to DataDog.
        """
        print "Loading group definitions..."
        group_dict = self.load_group_defs(self._group_json_path)

        print "Parsing reports..."
        metrics = self.parse_reports(self._report_paths)

        print "Creating metrics..."
        stats = self.create_metrics(metrics, group_dict)
        print "Done."

        return stats

    @staticmethod
    def load_group_defs(group_json_path):
        """
        Load the dictionary mapping group names to source file patterns
        from the file located at `group_json_path`.

        Exits with an error message if the groups could not be parsed.
        """
        try:
            with open(group_json_path) as json_file:
                return json.load(json_file)

        except IOError:
            print u"Could not open group definition file at '{}'".format(group_json_path)
            raise

        except ValueError:
            print u"Could not parse group definitions in '{}'".format(group_json_path)
            raise

    @staticmethod
    def parse_reports(report_paths):
        """
        Parses each coverage report in `report_paths` and returns
        a `CoverageData` object containing the aggregate coverage information.
        """
        data = CoverageData()

        for path in report_paths:

            try:
                with open(path) as report_file:
                    data.add_report(report_file.read())

            except IOError:
                print u"Warning: could not open {}".format(path)

            except CoverageParseError:
                print u"Warning: could not parse {} as an XML coverage report".format(path)

        return data

    @staticmethod
    def create_metrics(data, groups):
        """
        Given a `CoverageData` object, create coverage percentages for each group.

        `groups` is a dict mapping aggregate group names to source file patterns.
        Group names are used in the name of the metric sent to DataDog.
        """
        metrics = {}
        for group_name, pattern in groups.iteritems():
            metric = 'test_eng.coverage.{group}'.format(group=group_name.replace(' ', '_'))
            percent = data.coverage(pattern)

            if percent is not None:
                print u"Sending {} ==> {}%".format(metric, percent)
                metrics[metric] = percent

        return metrics