|
| 1 | +#!/usr/bin/python |
| 2 | +"""Ensure our test coverage only increases. |
| 3 | +
|
| 4 | +Easier than figuring out how to get hpc-coveralls to work with Stack. |
| 5 | +
|
| 6 | +If this fails, and the coverage went down: add some tests. |
| 7 | +If this fails, and the coverage went up: edit ``DESIRED_COVERAGE`` to match the new value. |
| 8 | +If this succeeds, great. |
| 9 | +
|
| 10 | +If you want to get details of what's covered, run:: |
| 11 | +
|
| 12 | + $ stack test --coverage |
| 13 | +
|
| 14 | +And look at the generated HTML. |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import division |
| 18 | +from pprint import pprint |
| 19 | +import re |
| 20 | +import subprocess |
| 21 | +import sys |
| 22 | + |
| 23 | + |
| 24 | +EXPRESSIONS = 'expressions' |
| 25 | +BOOLEANS = 'booleans' |
| 26 | +ALTERNATIVES = 'alternatives' |
| 27 | +LOCAL_DECLS = 'local_decls' |
| 28 | +TOP_LEVEL_DECLS = 'top_level_decls' |
| 29 | + |
| 30 | + |
| 31 | +"""The lack of coverage we are willing to tolerate. |
| 32 | +
|
| 33 | +In a just world, this would be a separate config file, or command-line arguments. |
| 34 | +
|
| 35 | +Each item represents the number of "things" we are OK with not being covered. |
| 36 | +""" |
| 37 | +COVERAGE_TOLERANCE = { |
| 38 | + ALTERNATIVES: 175, |
| 39 | + BOOLEANS: 8, |
| 40 | + EXPRESSIONS: 1494, |
| 41 | + LOCAL_DECLS: 15, |
| 42 | + TOP_LEVEL_DECLS: 685, |
| 43 | +} |
| 44 | + |
| 45 | + |
| 46 | +def get_report_summary(): |
| 47 | + """Run ``stack hpc report --all`` and return the output. |
| 48 | +
|
| 49 | + Assumes that ``stack test --coverage`` has already been run. |
| 50 | + """ |
| 51 | + process = subprocess.Popen(["stack", "hpc", "report", "--all"], stderr=subprocess.PIPE) |
| 52 | + stdout, stderr = process.communicate() |
| 53 | + return stderr |
| 54 | + |
| 55 | + |
| 56 | +"""Parse a line from the summary. |
| 57 | +
|
| 58 | +Takes a line like: |
| 59 | + NN% thingy wotsit used (YYYY/ZZZZ) |
| 60 | +
|
| 61 | +And turns it into: |
| 62 | + ("thingy wotsit used", "YYYY", "ZZZZ") |
| 63 | +""" |
| 64 | +_summary_line_re = re.compile(r'^\d\d% ([a-z -]+) \((\d+)/(\d+)\)$') |
| 65 | + |
| 66 | + |
| 67 | +"""Map from the human-readable descriptions to keys in the summary dict.""" |
| 68 | +_summary_line_entries = { |
| 69 | + 'expressions used': EXPRESSIONS, |
| 70 | + 'boolean coverage': BOOLEANS, |
| 71 | + 'alternatives used': ALTERNATIVES, |
| 72 | + 'local declarations used': LOCAL_DECLS, |
| 73 | + 'top-level declarations used': TOP_LEVEL_DECLS, |
| 74 | +} |
| 75 | + |
| 76 | +def parse_summary_line(summary_line): |
| 77 | + """Parse a line in the summary that indicates coverage we want to ratchet. |
| 78 | +
|
| 79 | + Turns:: |
| 80 | +
|
| 81 | + NN% thingy wotsit used (YYYY/ZZZZ) |
| 82 | +
|
| 83 | + Into:: |
| 84 | +
|
| 85 | + ('thingy', YYYY, ZZZZ) |
| 86 | +
|
| 87 | + Returns ``None`` if the line doesn't match the pattern. |
| 88 | + """ |
| 89 | + match = _summary_line_re.match(summary_line.strip()) |
| 90 | + if match is None: |
| 91 | + return |
| 92 | + description, covered, total = match.groups() |
| 93 | + try: |
| 94 | + key = _summary_line_entries[description] # XXX: Explodes if output changes. |
| 95 | + except KeyError: |
| 96 | + return |
| 97 | + return key, int(covered), int(total) |
| 98 | + |
| 99 | + |
| 100 | +def parse_report_summary(summary): |
| 101 | + """Parse the output of ``stack hpc report --all``. |
| 102 | +
|
| 103 | + Turns this:: |
| 104 | +
|
| 105 | + Getting project config file from STACK_YAML environment |
| 106 | + Generating combined report |
| 107 | + 57% expressions used (2172/3801) |
| 108 | + 47% boolean coverage (9/19) |
| 109 | + 38% guards (5/13), 4 always True, 4 unevaluated |
| 110 | + 75% 'if' conditions (3/4), 1 unevaluated |
| 111 | + 50% qualifiers (1/2), 1 always True |
| 112 | + 45% alternatives used (156/344) |
| 113 | + 81% local declarations used (70/86) |
| 114 | + 33% top-level declarations used (348/1052) |
| 115 | + The combined report is available at /path/hpc_index.html |
| 116 | +
|
| 117 | + Into this:: |
| 118 | +
|
| 119 | + {'expressions': (2172, 3801), |
| 120 | + 'booleans': (9, 19), |
| 121 | + 'alternatives': (156, 344), |
| 122 | + 'local_decls': (70, 86), |
| 123 | + 'top_level_decls': (348, 1052), |
| 124 | + } |
| 125 | + """ |
| 126 | + report = {} |
| 127 | + for line in summary.splitlines(): |
| 128 | + parsed = parse_summary_line(line) |
| 129 | + if not parsed: |
| 130 | + continue |
| 131 | + key, covered, total = parsed |
| 132 | + report[key] = (covered, total) |
| 133 | + return report |
| 134 | + |
| 135 | + |
| 136 | +def compare_values((covered, total), tolerance): |
| 137 | + """Compare measured coverage values with our tolerated lack of coverage. |
| 138 | +
|
| 139 | + Return -1 if coverage has got worse, 0 if it is the same, 1 if it is better. |
| 140 | + """ |
| 141 | + missing = total - covered |
| 142 | + return cmp(tolerance, missing) |
| 143 | + |
| 144 | + |
| 145 | +def compare_coverage(report, desired): |
| 146 | + comparison = {} |
| 147 | + for key, actual in report.items(): |
| 148 | + tolerance = desired.get(key, 0) |
| 149 | + if actual: |
| 150 | + comparison[key] = compare_values(actual, tolerance) |
| 151 | + else: |
| 152 | + comparison[key] = None |
| 153 | + return comparison |
| 154 | + |
| 155 | + |
| 156 | +def format_result(result): |
| 157 | + if result < 0: |
| 158 | + return 'WORSE' |
| 159 | + elif result == 0: |
| 160 | + return 'OK' |
| 161 | + else: |
| 162 | + return 'BETTER' |
| 163 | + |
| 164 | + |
| 165 | +def format_entry(key, result, desired, actual): |
| 166 | + covered, total = actual |
| 167 | + formatted_result = format_result(result) |
| 168 | + # TODO: Align results |
| 169 | + if result: |
| 170 | + return '%s: %s (%d missing => %d missing)' % ( |
| 171 | + key, formatted_result, desired, total - covered, |
| 172 | + ) |
| 173 | + else: |
| 174 | + return '%s: %s' % (key, formatted_result) |
| 175 | + |
| 176 | + |
| 177 | +def main(): |
| 178 | + report = parse_report_summary(get_report_summary()) |
| 179 | + comparison = compare_coverage(report, COVERAGE_TOLERANCE) |
| 180 | + all_same = True |
| 181 | + for key, value in sorted(comparison.items()): |
| 182 | + if value != 0: |
| 183 | + all_same = False |
| 184 | + print format_entry(key, value, COVERAGE_TOLERANCE.get(key, 0), report[key]) |
| 185 | + sys.exit(0 if all_same else 2) |
| 186 | + |
| 187 | + |
| 188 | +if __name__ == '__main__': |
| 189 | + main() |
0 commit comments