| 1 | """If you have Ned Batchelder's coverage_ module installed, you may activate a |
|---|
| 2 | coverage report with the ``--with-coverage`` switch or NOSE_WITH_COVERAGE |
|---|
| 3 | environment variable. The coverage report will cover any python source module |
|---|
| 4 | imported after the start of the test run, excluding modules that match |
|---|
| 5 | testMatch. If you want to include those modules too, use the ``--cover-tests`` |
|---|
| 6 | switch, or set the NOSE_COVER_TESTS environment variable to a true value. To |
|---|
| 7 | restrict the coverage report to modules from a particular package or packages, |
|---|
| 8 | use the ``--cover-packages`` switch or the NOSE_COVER_PACKAGES environment |
|---|
| 9 | variable. |
|---|
| 10 | |
|---|
| 11 | .. _coverage: http://www.nedbatchelder.com/code/modules/coverage.html |
|---|
| 12 | """ |
|---|
| 13 | import logging |
|---|
| 14 | import os |
|---|
| 15 | import sys |
|---|
| 16 | from nose.plugins.base import Plugin |
|---|
| 17 | from nose.util import src, tolist |
|---|
| 18 | |
|---|
| 19 | log = logging.getLogger(__name__) |
|---|
| 20 | |
|---|
| 21 | COVERAGE_TEMPLATE = '''<html> |
|---|
| 22 | <head> |
|---|
| 23 | %(title)s |
|---|
| 24 | </head> |
|---|
| 25 | <body> |
|---|
| 26 | %(header)s |
|---|
| 27 | <style> |
|---|
| 28 | .coverage pre {float: left; margin: 0px 1em; border: none; |
|---|
| 29 | padding: 0px; } |
|---|
| 30 | .num pre { margin: 0px } |
|---|
| 31 | .nocov, .nocov pre {background-color: #faa} |
|---|
| 32 | .cov, .cov pre {background-color: #cfc} |
|---|
| 33 | div.coverage div { clear: both; height: 1.1em} |
|---|
| 34 | </style> |
|---|
| 35 | <div class="stats"> |
|---|
| 36 | %(stats)s |
|---|
| 37 | </div> |
|---|
| 38 | <div class="coverage"> |
|---|
| 39 | %(body)s |
|---|
| 40 | </div> |
|---|
| 41 | </body> |
|---|
| 42 | </html> |
|---|
| 43 | ''' |
|---|
| 44 | |
|---|
| 45 | COVERAGE_STATS_TEMPLATE = '''Covered: %(covered)s lines<br/> |
|---|
| 46 | Missed: %(missed)s lines<br/> |
|---|
| 47 | Skipped %(skipped)s lines<br/> |
|---|
| 48 | Percent: %(percent)s %%<br/> |
|---|
| 49 | ''' |
|---|
| 50 | |
|---|
| 51 | |
|---|
| 52 | class Coverage(Plugin): |
|---|
| 53 | """ |
|---|
| 54 | Activate a coverage report using Ned Batchelder's coverage module. |
|---|
| 55 | """ |
|---|
| 56 | coverTests = False |
|---|
| 57 | coverPackages = None |
|---|
| 58 | score = 200 |
|---|
| 59 | status = {} |
|---|
| 60 | |
|---|
| 61 | def options(self, parser, env): |
|---|
| 62 | """ |
|---|
| 63 | Add options to command line. |
|---|
| 64 | """ |
|---|
| 65 | Plugin.options(self, parser, env) |
|---|
| 66 | parser.add_option("--cover-package", action="append", |
|---|
| 67 | default=env.get('NOSE_COVER_PACKAGE'), |
|---|
| 68 | metavar="PACKAGE", |
|---|
| 69 | dest="cover_packages", |
|---|
| 70 | help="Restrict coverage output to selected packages " |
|---|
| 71 | "[NOSE_COVER_PACKAGE]") |
|---|
| 72 | parser.add_option("--cover-erase", action="store_true", |
|---|
| 73 | default=env.get('NOSE_COVER_ERASE'), |
|---|
| 74 | dest="cover_erase", |
|---|
| 75 | help="Erase previously collected coverage " |
|---|
| 76 | "statistics before run") |
|---|
| 77 | parser.add_option("--cover-tests", action="store_true", |
|---|
| 78 | dest="cover_tests", |
|---|
| 79 | default=env.get('NOSE_COVER_TESTS'), |
|---|
| 80 | help="Include test modules in coverage report " |
|---|
| 81 | "[NOSE_COVER_TESTS]") |
|---|
| 82 | parser.add_option("--cover-inclusive", action="store_true", |
|---|
| 83 | dest="cover_inclusive", |
|---|
| 84 | default=env.get('NOSE_COVER_INCLUSIVE'), |
|---|
| 85 | help="Include all python files under working " |
|---|
| 86 | "directory in coverage report. Useful for " |
|---|
| 87 | "discovering holes in test coverage if not all " |
|---|
| 88 | "files are imported by the test suite. " |
|---|
| 89 | "[NOSE_COVER_INCLUSIVE]") |
|---|
| 90 | parser.add_option("--cover-html", action="store_true", |
|---|
| 91 | default=env.get('NOSE_COVER_HTML'), |
|---|
| 92 | dest='cover_html', |
|---|
| 93 | help="Produce HTML coverage information") |
|---|
| 94 | parser.add_option('--cover-html-dir', action='store', |
|---|
| 95 | default=env.get('NOSE_COVER_HTML_DIR', 'cover'), |
|---|
| 96 | dest='cover_html_dir', |
|---|
| 97 | metavar='DIR', |
|---|
| 98 | help='Produce HTML coverage information in dir') |
|---|
| 99 | |
|---|
| 100 | def configure(self, options, config): |
|---|
| 101 | """ |
|---|
| 102 | Configure plugin. |
|---|
| 103 | """ |
|---|
| 104 | try: |
|---|
| 105 | self.status.pop('active') |
|---|
| 106 | except KeyError: |
|---|
| 107 | pass |
|---|
| 108 | Plugin.configure(self, options, config) |
|---|
| 109 | if self.enabled: |
|---|
| 110 | try: |
|---|
| 111 | import coverage |
|---|
| 112 | except ImportError: |
|---|
| 113 | log.error("Coverage not available: " |
|---|
| 114 | "unable to import coverage module") |
|---|
| 115 | self.enabled = False |
|---|
| 116 | return |
|---|
| 117 | self.conf = config |
|---|
| 118 | self.coverErase = options.cover_erase |
|---|
| 119 | self.coverTests = options.cover_tests |
|---|
| 120 | self.coverPackages = [] |
|---|
| 121 | if options.cover_packages: |
|---|
| 122 | for pkgs in [tolist(x) for x in options.cover_packages]: |
|---|
| 123 | self.coverPackages.extend(pkgs) |
|---|
| 124 | self.coverInclusive = options.cover_inclusive |
|---|
| 125 | if self.coverPackages: |
|---|
| 126 | log.info("Coverage report will include only packages: %s", |
|---|
| 127 | self.coverPackages) |
|---|
| 128 | self.coverHtmlDir = None |
|---|
| 129 | if options.cover_html: |
|---|
| 130 | self.coverHtmlDir = options.cover_html_dir |
|---|
| 131 | log.debug('Will put HTML coverage report in %s', self.coverHtmlDir) |
|---|
| 132 | if self.enabled: |
|---|
| 133 | self.status['active'] = True |
|---|
| 134 | |
|---|
| 135 | def begin(self): |
|---|
| 136 | """ |
|---|
| 137 | Begin recording coverage information. |
|---|
| 138 | """ |
|---|
| 139 | log.debug("Coverage begin") |
|---|
| 140 | import coverage |
|---|
| 141 | self.skipModules = sys.modules.keys()[:] |
|---|
| 142 | if self.coverErase: |
|---|
| 143 | log.debug("Clearing previously collected coverage statistics") |
|---|
| 144 | coverage.erase() |
|---|
| 145 | coverage.exclude('#pragma[: ]+[nN][oO] [cC][oO][vV][eE][rR]') |
|---|
| 146 | coverage.start() |
|---|
| 147 | |
|---|
| 148 | def report(self, stream): |
|---|
| 149 | """ |
|---|
| 150 | Output code coverage report. |
|---|
| 151 | """ |
|---|
| 152 | log.debug("Coverage report") |
|---|
| 153 | import coverage |
|---|
| 154 | coverage.stop() |
|---|
| 155 | modules = [ module |
|---|
| 156 | for name, module in sys.modules.items() |
|---|
| 157 | if self.wantModuleCoverage(name, module) ] |
|---|
| 158 | log.debug("Coverage report will cover modules: %s", modules) |
|---|
| 159 | coverage.report(modules, file=stream) |
|---|
| 160 | if self.coverHtmlDir: |
|---|
| 161 | if not os.path.exists(self.coverHtmlDir): |
|---|
| 162 | os.makedirs(self.coverHtmlDir) |
|---|
| 163 | log.debug("Generating HTML coverage report") |
|---|
| 164 | files = {} |
|---|
| 165 | for m in modules: |
|---|
| 166 | if hasattr(m, '__name__') and hasattr(m, '__file__'): |
|---|
| 167 | files[m.__name__] = m.__file__ |
|---|
| 168 | coverage.annotate(files.values()) |
|---|
| 169 | global_stats = {'covered': 0, 'missed': 0, 'skipped': 0} |
|---|
| 170 | file_list = [] |
|---|
| 171 | for m, f in files.iteritems(): |
|---|
| 172 | if f.endswith('pyc'): |
|---|
| 173 | f = f[:-1] |
|---|
| 174 | coverfile = f+',cover' |
|---|
| 175 | outfile, stats = self.htmlAnnotate(m, f, coverfile, |
|---|
| 176 | self.coverHtmlDir) |
|---|
| 177 | for field in ('covered', 'missed', 'skipped'): |
|---|
| 178 | global_stats[field] += stats[field] |
|---|
| 179 | file_list.append((stats['percent'], m, outfile, stats)) |
|---|
| 180 | os.unlink(coverfile) |
|---|
| 181 | file_list.sort() |
|---|
| 182 | global_stats['percent'] = self.computePercent( |
|---|
| 183 | global_stats['covered'], global_stats['missed']) |
|---|
| 184 | # Now write out an index file for the coverage HTML |
|---|
| 185 | index = open(os.path.join(self.coverHtmlDir, 'index.html'), 'w') |
|---|
| 186 | index.write('<html><head><title>Coverage Index</title></head>' |
|---|
| 187 | '<body><p>') |
|---|
| 188 | index.write(COVERAGE_STATS_TEMPLATE % global_stats) |
|---|
| 189 | index.write('<table><tr><td>File</td><td>Covered</td><td>Missed' |
|---|
| 190 | '</td><td>Skipped</td><td>Percent</td></tr>') |
|---|
| 191 | for junk, name, outfile, stats in file_list: |
|---|
| 192 | stats['a'] = '<a href="%s">%s</a>' % (outfile, name) |
|---|
| 193 | index.write('<tr><td>%(a)s</td><td>%(covered)s</td><td>' |
|---|
| 194 | '%(missed)s</td><td>%(skipped)s</td><td>' |
|---|
| 195 | '%(percent)s %%</td></tr>' % stats) |
|---|
| 196 | index.write('</table></p></html') |
|---|
| 197 | index.close() |
|---|
| 198 | |
|---|
| 199 | def htmlAnnotate(self, name, file, coverfile, outputDir): |
|---|
| 200 | log.debug('Name: %s file: %s' % (name, file, )) |
|---|
| 201 | rows = [] |
|---|
| 202 | data = open(coverfile, 'r').read().split('\n') |
|---|
| 203 | padding = len(str(len(data))) |
|---|
| 204 | stats = {'covered': 0, 'missed': 0, 'skipped': 0} |
|---|
| 205 | for lineno, line in enumerate(data): |
|---|
| 206 | lineno += 1 |
|---|
| 207 | if line: |
|---|
| 208 | status = line[0] |
|---|
| 209 | line = line[2:] |
|---|
| 210 | else: |
|---|
| 211 | status = '' |
|---|
| 212 | line = '' |
|---|
| 213 | lineno = (' ' * (padding - len(str(lineno)))) + str(lineno) |
|---|
| 214 | for old, new in (('&', '&'), ('<', '<'), ('>', '>'), |
|---|
| 215 | ('"', '"'), ): |
|---|
| 216 | line = line.replace(old, new) |
|---|
| 217 | if status == '!': |
|---|
| 218 | rows.append('<div class="nocov"><span class="num"><pre>' |
|---|
| 219 | '%s</pre></span><pre>%s</pre></div>' % (lineno, |
|---|
| 220 | line)) |
|---|
| 221 | stats['missed'] += 1 |
|---|
| 222 | elif status == '>': |
|---|
| 223 | rows.append('<div class="cov"><span class="num"><pre>%s</pre>' |
|---|
| 224 | '</span><pre>%s</pre></div>' % (lineno, line)) |
|---|
| 225 | stats['covered'] += 1 |
|---|
| 226 | else: |
|---|
| 227 | rows.append('<div class="skip"><span class="num"><pre>%s</pre>' |
|---|
| 228 | '</span><pre>%s</pre></div>' % (lineno, line)) |
|---|
| 229 | stats['skipped'] += 1 |
|---|
| 230 | stats['percent'] = self.computePercent(stats['covered'], |
|---|
| 231 | stats['missed']) |
|---|
| 232 | html = COVERAGE_TEMPLATE % {'title': '<title>%s</title>' % name, |
|---|
| 233 | 'header': name, |
|---|
| 234 | 'body': '\n'.join(rows), |
|---|
| 235 | 'stats': COVERAGE_STATS_TEMPLATE % stats, |
|---|
| 236 | } |
|---|
| 237 | outfilename = name + '.html' |
|---|
| 238 | outfile = open(os.path.join(outputDir, outfilename), 'w') |
|---|
| 239 | outfile.write(html) |
|---|
| 240 | outfile.close() |
|---|
| 241 | return outfilename, stats |
|---|
| 242 | |
|---|
| 243 | def computePercent(self, covered, missed): |
|---|
| 244 | if covered + missed == 0: |
|---|
| 245 | percent = 1 |
|---|
| 246 | else: |
|---|
| 247 | percent = covered/(covered+missed+0.0) |
|---|
| 248 | return int(percent * 100) |
|---|
| 249 | |
|---|
| 250 | def wantModuleCoverage(self, name, module): |
|---|
| 251 | if not hasattr(module, '__file__'): |
|---|
| 252 | log.debug("no coverage of %s: no __file__", name) |
|---|
| 253 | return False |
|---|
| 254 | module_file = src(module.__file__) |
|---|
| 255 | if not module_file or not module_file.endswith('.py'): |
|---|
| 256 | log.debug("no coverage of %s: not a python file", name) |
|---|
| 257 | return False |
|---|
| 258 | if self.coverPackages: |
|---|
| 259 | for package in self.coverPackages: |
|---|
| 260 | if (name.startswith(package) |
|---|
| 261 | and (self.coverTests |
|---|
| 262 | or not self.conf.testMatch.search(name))): |
|---|
| 263 | log.debug("coverage for %s", name) |
|---|
| 264 | return True |
|---|
| 265 | if name in self.skipModules: |
|---|
| 266 | log.debug("no coverage for %s: loaded before coverage start", |
|---|
| 267 | name) |
|---|
| 268 | return False |
|---|
| 269 | if self.conf.testMatch.search(name) and not self.coverTests: |
|---|
| 270 | log.debug("no coverage for %s: is a test", name) |
|---|
| 271 | return False |
|---|
| 272 | # accept any package that passed the previous tests, unless |
|---|
| 273 | # coverPackages is on -- in that case, if we wanted this |
|---|
| 274 | # module, we would have already returned True |
|---|
| 275 | return not self.coverPackages |
|---|
| 276 | |
|---|
| 277 | def wantFile(self, file, package=None): |
|---|
| 278 | """If inclusive coverage enabled, return true for all source files |
|---|
| 279 | in wanted packages. |
|---|
| 280 | """ |
|---|
| 281 | if self.coverInclusive: |
|---|
| 282 | if file.endswith(".py"): |
|---|
| 283 | if package and self.coverPackages: |
|---|
| 284 | for want in self.coverPackages: |
|---|
| 285 | if package.startswith(want): |
|---|
| 286 | return True |
|---|
| 287 | else: |
|---|
| 288 | return True |
|---|
| 289 | return None |
|---|